Repository: conan-io/conan
Pull request #18171 (2025-04-24 10:23:27)

Improvements in audit reports
Changelog: Fix: Some improvements in conan audit reports.
Docs: Omit

This PR updates the `conan audit` output to include packages that could not be scanned because they were not found in the vulnerability Catalog.

Previously, these packages were silently ignored. Now, they are explicitly reported in the output as follows:

~~~text
...
*****************
* pepito/3.13.3 *
*****************

Package 'pepito/3.13.3' not scanned: Not found.
...
~~~

This reporting is also included in the HTML output format.

<img width="1434" alt="image" src="https://github.com/user-attachments/assets/bdf10513-ddbd-4296-ad3d-c9ec23c3a2cd" />

Additionally, this PR homogenizes the error handling behavior between private providers and the Conan Center provider:

Previously, if a private provider encountered a reference not found in its Catalog, it would often print an error and stop processing, preventing results for subsequent references from being displayed.

**Example before these changes** (using a private provider with a Conan package that doesn't exist in the Catalog):

~~~text
Requirements
    pepito/3.13.3#289814a8735db2eb0575d9664d0e3783 - Cache
    zlib/1.2.13#9df41c65e2c2b6ef47633dc32e0b699a - Cache
    zstd/1.5.5#8400bcba21d1b495dca55edf3f065246 - Cache
ERROR: pepito/3.13.3 was not found in the catalog
~~~

**Example after these changes:**

Now, the error is reported for the package, but processing continues for other references, and a final summary is provided:

~~~text
Requirements
    pepito/3.13.3#289814a8735db2eb0575d9664d0e3783 - Cache
    zlib/1.2.13#9df41c65e2c2b6ef47633dc32e0b699a - Cache
    zstd/1.5.5#8400bcba21d1b495dca55edf3f065246 - Cache

*****************
* pepito/3.13.3 *
*****************

Package 'pepito/3.13.3' not scanned: Not found.
...
~~~

The formatters handle two levels of errors:

- Per-package errors: Occur when fetching CVEs for a specific package fails (e.g., the package is not found in the Catalog). These errors are reported in the output for that package, and processing continues.

- Global errors: as "conan_error" so they are handled automatically by the formatters, occur due to issues like hitting a rate limit or encountering an HTTP exception that halts the process. In this case, partial results gathered before the error are displayed, and then an exception is raised.


Also some refactors to make more similar the way the vulnerabilities are processed in the Conan Center provider and the private ones, although it could still be improved, but I did not want do add more changes.




uilianries: @czoido As you are touching the error handling, the audit forwards provider errors to "error" here: https://github.com/conan-io/conan/blob/develop2/conan/cli/commands/audit.py#L68

I guess it could be updated as well to use "conan_error" instead of. 
czoido: > @czoido As you are touching the error handling, the audit forwards provider errors to "error" here: https://github.com/conan-io/conan/blob/develop2/conan/cli/commands/audit.py#L68
> 
> I guess it could be updated as well to use "conan_error" instead of.

absolutely! thanks for pointing that out

Patch:
diff --git a/conan/cli/commands/audit.py b/conan/cli/commands/audit.py
index 2288353ed..e002140c6 100644
--- a/conan/cli/commands/audit.py
+++ b/conan/cli/commands/audit.py
@@ -65,7 +65,7 @@ def audit_scan(conan_api: ConanAPI, parser, subparser, *args):
     deps_graph.report_graph_error()
 
     if deps_graph.error:
-        return {"error": deps_graph.error}
+        return {"conan_error": deps_graph.error}
 
     provider = conan_api.audit.get_provider(args.provider or CONAN_CENTER_AUDIT_PROVIDER_NAME)
 
diff --git a/conan/cli/formatters/audit/vulnerabilities.py b/conan/cli/formatters/audit/vulnerabilities.py
index 8114dda27..bd78dd0ce 100644
--- a/conan/cli/formatters/audit/vulnerabilities.py
+++ b/conan/cli/formatters/audit/vulnerabilities.py
@@ -2,7 +2,8 @@ import json
 
 from jinja2 import select_autoescape, Template
 
-from conan.api.output import cli_out_write, Color, ConanOutput
+from conan.api.output import cli_out_write, Color
+from conan.errors import ConanException
 
 severity_order = {
     "Critical": 4,
@@ -13,9 +14,6 @@ severity_order = {
 
 
 def text_vuln_formatter(result):
-    from conan.api.output import cli_out_write, Color
-
-    response, errors_in_response = result
 
     severity_colors = {
         "Critical": Color.BRIGHT_RED,
@@ -38,15 +36,10 @@ def text_vuln_formatter(result):
         lines.append(" " * indent + txt)
         return "\n".join(lines)
 
-    if not response or "data" not in response or not response["data"]:
-        if not errors_in_response:
-            cli_out_write("No vulnerabilities found.\n", fg=Color.BRIGHT_GREEN)
-        return
-
     total_vulns = 0
     summary_lines = []
 
-    for ref, pkg_info in response["data"].items():
+    for ref, pkg_info in result["data"].items():
         edges = pkg_info.get("vulnerabilities", {}).get("edges", [])
         count = len(edges)
 
@@ -55,10 +48,14 @@ def text_vuln_formatter(result):
         cli_out_write(f"* {ref} *", fg=Color.BRIGHT_WHITE)
         cli_out_write(border_line, fg=Color.BRIGHT_WHITE)
 
+        if "error" in pkg_info:
+            details = pkg_info["error"].get("details", "")
+            cli_out_write(f"\n{details}\n", fg=Color.BRIGHT_YELLOW)
+            continue
+
         if not count:
-            if not errors_in_response:
-                cli_out_write("\nNo vulnerabilities found.\n", fg=Color.BRIGHT_GREEN)
-                continue
+            cli_out_write("\nNo vulnerabilities found.\n", fg=Color.BRIGHT_GREEN)
+            continue
 
         total_vulns += count
         summary_lines.append(
@@ -91,6 +88,7 @@ def text_vuln_formatter(result):
             cli_out_write("")
 
     color_for_total = Color.BRIGHT_RED if total_vulns else Color.BRIGHT_GREEN
+
     cli_out_write(f"Total vulnerabilities found: {total_vulns}\n", fg=color_for_total)
 
     if total_vulns > 0:
@@ -102,18 +100,17 @@ def text_vuln_formatter(result):
                       "through patches applied in the recipe.\nTo verify if a patch has been applied, check the recipe in Conan Center.\n",
                       fg=Color.BRIGHT_YELLOW)
 
-    cli_out_write("\nVulnerability information provided by JFrog Advanced Security. Please check "
-                  "https://jfrog.com/advanced-security/ for more information.\n",
-                  fg=Color.BRIGHT_GREEN)
-    cli_out_write("You can send questions and report issues about "
-                  "the returned vulnerabilities to conan-research@jfrog.com.\n",
-                  fg=Color.BRIGHT_GREEN)
+    if total_vulns > 0 or not "error" in result:
+        cli_out_write("\nVulnerability information provided by JFrog Advanced Security. Please check "
+                      "https://jfrog.com/advanced-security/ for more information.\n",
+                      fg=Color.BRIGHT_GREEN)
+        cli_out_write("You can send questions and report issues about "
+                      "the returned vulnerabilities to conan-research@jfrog.com.\n",
+                      fg=Color.BRIGHT_GREEN)
 
 
 def json_vuln_formatter(result):
-    response, errors_in_response = result
-    if not errors_in_response or response["data"]:
-        cli_out_write(json.dumps(response, indent=4))
+    cli_out_write(json.dumps(result, indent=4))
 
 
 def _render_vulns(vulns, template):
@@ -233,18 +230,18 @@ vuln_html = """
 """
 
 def html_vuln_formatter(result):
-    response, errors_in_response = result
     vulns = []
-    for ref, pkg_info in response["data"].items():
+    for ref, pkg_info in result["data"].items():
         edges = pkg_info.get("vulnerabilities", {}).get("edges", [])
         if not edges:
+            description = "No vulnerabilities found." if not "error" in pkg_info else pkg_info["error"].get("details", "")
             vulns.append({
                 "package": ref,
                 "vuln_id": "-",
                 "aliases": [],
                 "severity": "N/A",
                 "score": "-",
-                "description": "No vulnerabilities found.",
+                "description": description,
                 "references": []
             })
         else:
@@ -268,5 +265,5 @@ def html_vuln_formatter(result):
                     "description": desc,
                     "references": references,
                 })
-    if not errors_in_response or response["data"]:
-        cli_out_write(_render_vulns(vulns, vuln_html))
+
+    cli_out_write(_render_vulns(vulns, vuln_html))
diff --git a/conan/internal/api/audit/providers.py b/conan/internal/api/audit/providers.py
index 5e83913f4..6d7e491ec 100644
--- a/conan/internal/api/audit/providers.py
+++ b/conan/internal/api/audit/providers.py
@@ -4,17 +4,23 @@ from conan.api.output import Color, ConanOutput
 from conan.errors import ConanException
 
 
+def _build_headers(token):
+    return {"Content-Type": "application/json",
+            "Accept": "application/json",
+            "Authorization": f"Bearer {token}"}
+
+
 class ConanCenterProvider:
     def __init__(self, conan_api, name, provider_data):
         self.name = name
         self.url = provider_data["url"]
         self.type = provider_data["type"]
-        self.token = provider_data.get("token")
+        self._token = provider_data.get("token")
         self._session = conan_api.remotes.requester
         self._query_url = urljoin(self.url, "api/v1/query")
 
     def get_cves(self, refs):
-        if not self.token:
+        if not self._token:
             from conan.api.subapi.audit import CONAN_CENTER_AUDIT_PROVIDER_NAME
             if self.name == CONAN_CENTER_AUDIT_PROVIDER_NAME:
                 output = ConanOutput()
@@ -34,26 +40,21 @@ class ConanCenterProvider:
 
             raise ConanException("Missing authentication token. Please authenticate and retry.")
 
-        headers = {"Content-Type": "application/json",
-                   "Accept": "application/json",
-                   "Authorization": f"Bearer {self.token}"}
+        result = {"data": {}, "error": None}
 
-        result = {"data": {}}
-        errors_in_response = False
         for ref in refs:
             ConanOutput().info(f"Requesting vulnerability info for: {ref}")
-            response = self._session.post(self._query_url, headers=headers,
+            response = self._session.post(self._query_url, headers=_build_headers(self._token),
                                           json={"reference": str(ref)})
             if response.status_code == 200:
-                result["data"].setdefault(str(ref), {}).update(response.json()["data"]["query"])
+                result["data"][str(ref)] = response.json().get("data", {}).get("query", {})
             elif response.status_code == 400:
                 ConanOutput().warning(f"Package '{ref}' not found.\n"
                                       f"Only libraries available in Conan Center can be queried for vulnerabilities.\n"
                                       f"Please ensure the package exists in the official repository: https://conan.io/center\n"
                                       f"If the package exists in the repository, please report it to conan-research@jfrog.com.\n")
-                # errors_in_response = True
+                result["data"][str(ref)] = {"error": {"details": f"Package '{ref}' not scanned: Not found."}}
                 continue
-
             elif response.status_code == 403:
                 # TODO: How to report auth error to the user
                 ConanOutput().error(f"Authentication error ({response.status_code}).\n"
@@ -61,7 +62,7 @@ class ConanCenterProvider:
                                     f" - Set a valid token using: 'conan audit provider auth {self.name} --token=<your_token>'\n"
                                     f" - If you don’t have a token, register at: https://audit.conan.io/register")
 
-                errors_in_response = True
+                result["conan_error"] = f"Authentication error ({response.status_code})."
                 break
             elif response.status_code == 429:
                 reset_seconds = int(response.headers.get("retry-after", 0))
@@ -92,19 +93,17 @@ class ConanCenterProvider:
                 output.write("https://jfrog.com/devops-native-security/", newline=True,
                              fg=Color.BRIGHT_BLUE)
                 output.write("\n")
-                ConanOutput().error("Rate limit exceeded.\n")
-                errors_in_response = True
+
+                result["conan_error"] = "Rate limit exceeded."
                 break
             elif response.status_code == 500:
                 # TODO: How to report internal server error to the user
-                ConanOutput().error(f"Internal server error ({response.status_code})")
-                errors_in_response = True
+                result["conan_error"] = f"Internal server error ({response.status_code})"
                 break
             else:
-                ConanOutput().error(f"Error in {ref} ({response.status_code})")
-                errors_in_response = True
+                result["conan_error"] = f"Error in {ref} ({response.status_code})"
                 break
-        return result, errors_in_response
+        return result
 
 
 class PrivateProvider:
@@ -112,20 +111,29 @@ class PrivateProvider:
         self.name = name
         self.url = provider_data["url"]
         self.type = provider_data["type"]
-        self.data = provider_data
+        self._token = provider_data.get("token")
         self._session = conan_api.remotes.requester
         self._query_url = urljoin(self.url, "catalog/api/v0/public/graphql")
 
     def get_cves(self, refs):
-        result = {"data": {}}
+        if not self._token:
+            raise ConanException(f"Missing authentication token for '{self.name}' provider.\n"
+                                 f"Please authenticate using 'conan audit provider auth' and retry.")
+
+        result = {"data": {}, "error": None}
+
         for ref in refs:
-            response = self._get(ref)
-            if "error" in response:
-                ConanOutput().error(response['error']['details'])
-                result["error"] = response["error"]
-                return result, True
-            result["data"].setdefault(str(ref), {}).update(response["data"]["query"])
-        return result, False
+            try:
+                response = self._get(ref)
+                if "error" in response:
+                    result["data"][str(ref)] = {"error": {"details": response["error"]}}
+                else:
+                    result["data"][str(ref)] = response.get("data",{}).get("query",{})
+            except Exception as e:
+                result["conan_error"] = str(e)
+                break
+
+        return result
 
     @staticmethod
     def _build_query(ref):
@@ -169,24 +177,18 @@ class PrivateProvider:
 
         def _replace_message(message):
             if "not found" in message:
-                return f"{ref} was not found in the catalog"
+                return f"Package '{ref}' not scanned: Not found."
             return None
 
         error_msgs = filter(bool, [_replace_message(error["message"]) for error in errors])
-        return {"details": next(error_msgs, "Unknown error")}
+        return next(error_msgs, "Unknown error")
 
     def _get(self, ref):
         full_query = self._build_query(ref)
         try:
-            headers = {'Content-Type': 'application/json', 'Accept': 'application/json'}
-            if self.data.get("token"):
-                headers["Authorization"] = f"Bearer {self.data['token']}"
-            elif self.data.get("user") and self.data.get("password"):
-                headers["Authorization"] = f"Basic {self.data['user']}:{self.data['password']}"
-
             response = self._session.post(
                 self._query_url,
-                headers=headers,
+                headers=_build_headers(self._token),
                 json={
                     "query": textwrap.dedent(full_query)
                 }
@@ -203,7 +205,7 @@ class PrivateProvider:
             # Raises if some HTTP error was found
             response.raise_for_status()
         except Exception as e:
-            return {"error": {"details": str(e)}}
+            raise e
 
         response_json = response.json()
         # filter the extensions key with graphql data


Test patch:
diff --git a/test/integration/command/test_audit.py b/test/integration/command/test_audit.py
index 165aaeb63..02a0d04c5 100644
--- a/test/integration/command/test_audit.py
+++ b/test/integration/command/test_audit.py
@@ -59,7 +59,8 @@ def test_conan_audit_paths():
                     ]
                 }
             }
-        }
+        },
+        "error": None
     }
 
     tc = TestClient(light=True)
@@ -86,13 +87,13 @@ def test_conan_audit_paths():
 
     # Now some common errors, like rate limited or missing lib, but it should not fail!
     with proxy_response(429, {"error": "Rate limit exceeded"}):
-        tc.run("audit list zlib/1.2.11")
+        tc.run("audit list zlib/1.2.11", assert_error=True)
         assert "You have exceeded the number of allowed requests" in tc.out
         assert "The limit will reset in 1 minute" in tc.out
 
     with proxy_response(400, {"error": "Not found"}):
         tc.run("audit list zlib/1.2.11")
-        assert "Package 'zlib/1.2.11' not found" in tc.out
+        assert "Package 'zlib/1.2.11' not scanned: Not found." in tc.stdout
 
     tc.run("audit provider add myprivate --url=foo --type=private --token=valid_token")
 
@@ -184,3 +185,27 @@ def test_audit_provider_env_credentials_with_proxy(monkeypatch):
 
     # Verify that the Authorization header uses the token from the environment variable
     assert captured_headers.get("Authorization") == "Bearer env_token_value"
+
+
+def test_audit_global_error_exception():
+    """
+    Test that a global error returned by the provider results in ConanException
+    raised by the formatter, using the 'details' field.
+    """
+    tc = TestClient(light=True)
+    tc.run("audit provider auth conancenter --token=valid_token")
+
+    mock_provider_result = {
+        "data": {},
+        "conan_error": "Fatal error."
+    }
+
+    with patch("conan.api.conan_api.AuditAPI.list", return_value=mock_provider_result):
+        tc.run("audit list zlib/1.2.11", assert_error=True)
+        assert "ERROR: Fatal error." in tc.out
+
+        tc.run("audit list zlib/1.2.11 -f json", assert_error=True)
+        assert "ERROR: Fatal error." in tc.out
+
+        tc.run("audit list zlib/1.2.11 -f html", assert_error=True)
+        assert "ERROR: Fatal error." in tc.out