Repository: conan-io/conan
Pull request #17951 (2025-03-12 10:28:21)

Add audit
Changelog: Omit
Docs: Omit

All merit goes to @AbrilRBS! I had to squash her commits because of the CLA

supersedes https://github.com/conan-io/conan/pull/17950#pullrequestreview-2677862197 that had a problem with the CLA


Patch:
diff --git a/conan/api/conan_api.py b/conan/api/conan_api.py
index 719c81fd9..de48082e5 100644
--- a/conan/api/conan_api.py
+++ b/conan/api/conan_api.py
@@ -2,6 +2,7 @@ import os
 import sys
 
 from conan.api.output import init_colorama
+from conan.api.subapi.audit import AuditAPI
 from conan.api.subapi.cache import CacheAPI
 from conan.api.subapi.command import CommandAPI
 from conan.api.subapi.local import LocalAPI
@@ -69,6 +70,7 @@ class ConanAPI:
         self.cache = CacheAPI(self)
         self.lockfile = LockfileAPI(self)
         self.local = LocalAPI(self)
+        self.audit = AuditAPI(self)
 
         _check_conan_version(self)
 
diff --git a/conan/api/subapi/audit.py b/conan/api/subapi/audit.py
new file mode 100644
index 000000000..df3d208c7
--- /dev/null
+++ b/conan/api/subapi/audit.py
@@ -0,0 +1,159 @@
+import binascii
+import json
+import os
+import base64
+
+from conan.internal.api.audit.providers import ConanCenterProvider, PrivateProvider
+from conan.errors import ConanException
+from conan.internal.api.remotes.encrypt import encode, decode
+from conan.internal.model.recipe_ref import RecipeReference
+from conans.util.files import save, load
+
+CONAN_CENTER_AUDIT_PROVIDER_NAME = "conancenter"
+CYPHER_KEY = "private"
+
+class AuditAPI:
+    """
+    This class provides the functionality to scan references for vulnerabilities.
+    """
+
+    def __init__(self, conan_api):
+        self.conan_api = conan_api
+        self._home_folder = conan_api.home_folder
+        self._providers_path = os.path.join(self._home_folder, "audit_providers.json")
+        self._provider_cls = {
+            "conan-center-proxy": ConanCenterProvider,
+            "private": PrivateProvider,
+        }
+
+    def scan(self, deps_graph, provider):
+        """
+        Scan a given recipe for vulnerabilities in its dependencies.
+        """
+        refs = sorted(set(RecipeReference.loads(f"{node.ref.name}/{node.ref.version}")
+                        for node in deps_graph.nodes[1:]), key= lambda ref: ref.name)
+        return provider.get_cves(refs)
+
+    def list(self, references, provider):
+        """
+        List the vulnerabilities of the given reference.
+        """
+        refs = [RecipeReference.loads(ref) for ref in references]
+        for ref in refs:
+            ref.validate_ref()
+        return provider.get_cves(refs)
+
+    def get_provider(self, provider_name):
+        """
+        Get the provider by name.
+        """
+        # TODO: More work remains to be done here, hardcoded for now for testing
+        providers = _load_providers(self._providers_path)
+        if provider_name not in providers:
+            add_arguments = (
+                "--url=https://audit.conan.io/ --type=conan-center-proxy"
+                if provider_name == CONAN_CENTER_AUDIT_PROVIDER_NAME
+                else "--url=<url> --type=<type>"
+            )
+
+            register_message = (
+                f"If you don't have a valid token, register at: https://audit.conan.io/register."
+                if provider_name == CONAN_CENTER_AUDIT_PROVIDER_NAME
+                else ""
+            )
+
+            raise ConanException(
+                f"Provider '{provider_name}' not found. Please specify a valid provider name or add it using: "
+                f"'conan audit provider add {provider_name} {add_arguments} --token=<token>'\n"
+                f"{register_message}"
+            )
+
+        provider_data = providers[provider_name]
+        safe_provider_name = provider_name.replace("-", "_")
+        env_token = os.getenv(f"CONAN_AUDIT_PROVIDER_TOKEN_{safe_provider_name.upper()}")
+
+        if env_token:
+            # Always override the token with the environment variable
+            provider_data["token"] = env_token
+        elif "token" in provider_data:
+            try:
+                provider_data["token"] = decode(base64.standard_b64decode(provider_data["token"]).decode(), CYPHER_KEY)
+            except binascii.Error as e:
+                raise ConanException(f"Invalid token format for provider '{provider_name}'. The token might be corrupt.")
+
+        provider_cls = self._provider_cls.get(provider_data["type"])
+
+        return provider_cls(self.conan_api, provider_name, provider_data)
+
+    def list_providers(self):
+        """
+        Get all available providers.
+        """
+        providers = _load_providers(self._providers_path)
+        result = []
+        for name, provider_data in providers.items():
+            provider_cls = self._provider_cls.get(provider_data["type"])
+            result.append(provider_cls(self.conan_api, name, provider_data))
+        return result
+
+    def add_provider(self, name, url, provider_type):
+        """
+        Add a provider.
+        """
+        providers = _load_providers(self._providers_path)
+        if name in providers:
+            raise ConanException(f"Provider '{name}' already exists")
+
+        if provider_type not in self._provider_cls:
+            raise ConanException(f"Provider type '{provider_type}' not found")
+
+        providers[name] = {
+            "name": name,
+            "url": url,
+            "type": provider_type
+        }
+
+        _save_providers(self._providers_path, providers)
+
+    def remove_provider(self, provider_name):
+        """
+        Remove a provider.
+        """
+        providers = _load_providers(self._providers_path)
+        if provider_name not in providers:
+            raise ConanException(f"Provider '{provider_name}' not found")
+
+        del providers[provider_name]
+
+        _save_providers(self._providers_path, providers)
+
+    def auth_provider(self, provider, token):
+        """
+        Authenticate a provider.
+        """
+        if not provider:
+            raise ConanException("Provider not found")
+
+        providers = _load_providers(self._providers_path)
+
+        assert provider.name in providers
+        providers[provider.name]["token"] = base64.standard_b64encode(encode(token, CYPHER_KEY).encode()).decode()
+        setattr(provider, "token", token)
+        _save_providers(self._providers_path, providers)
+
+
+def _load_providers(providers_path):
+    if not os.path.exists(providers_path):
+        default_providers = {
+            CONAN_CENTER_AUDIT_PROVIDER_NAME: {
+                "url": "https://audit.conan.io/",
+                "type": "conan-center-proxy"
+            }
+        }
+        save(providers_path, json.dumps(default_providers, indent=4))
+
+    return json.loads(load(providers_path))
+
+
+def _save_providers(providers_path, providers):
+    save(providers_path, json.dumps(providers, indent=4))
diff --git a/conan/cli/commands/audit.py b/conan/cli/commands/audit.py
new file mode 100644
index 000000000..efc7a044e
--- /dev/null
+++ b/conan/cli/commands/audit.py
@@ -0,0 +1,195 @@
+import json
+import os
+
+from conan.api.conan_api import ConanAPI
+from conan.api.input import UserInput
+from conan.api.model import MultiPackagesList
+from conan.api.output import cli_out_write, ConanOutput
+from conan.api.subapi.audit import CONAN_CENTER_AUDIT_PROVIDER_NAME
+from conan.cli import make_abs_path
+from conan.cli.args import common_graph_args, validate_common_graph_args
+from conan.cli.command import conan_command, conan_subcommand
+from conan.cli.formatters.audit.vulnerabilities import text_vuln_formatter, json_vuln_formatter, \
+    html_vuln_formatter
+from conan.cli.printers import print_profiles
+from conan.cli.printers.graph import print_graph_basic
+from conan.errors import ConanException
+
+
+def _add_provider_arg(subparser):
+    subparser.add_argument("-p", "--provider", help="Provider to use for scanning")
+
+
+@conan_subcommand(formatters={"text": text_vuln_formatter,
+                              "json": json_vuln_formatter,
+                              "html": html_vuln_formatter})
+def audit_scan(conan_api: ConanAPI, parser, subparser, *args):
+    """
+    Scan a given recipe for vulnerabilities in its dependencies.
+    """
+    common_graph_args(subparser)
+    # Needed for the validation of args, but this should usually be left as False here
+    # TODO: Do we then want to hide it in the --help?
+    subparser.add_argument("--build-require", action='store_true', default=False,
+                           help='Whether the provided reference is a build-require')
+
+    _add_provider_arg(subparser)
+    args = parser.parse_args(*args)
+
+    # This comes from install command
+
+    validate_common_graph_args(args)
+    # basic paths
+    cwd = os.getcwd()
+    path = conan_api.local.get_conanfile_path(args.path, cwd, py=None) if args.path else None
+
+    # Basic collaborators: remotes, lockfile, profiles
+    remotes = conan_api.remotes.list(args.remote) if not args.no_remote else []
+    overrides = eval(args.lockfile_overrides) if args.lockfile_overrides else None
+    lockfile = conan_api.lockfile.get_lockfile(lockfile=args.lockfile, conanfile_path=path, cwd=cwd,
+                                               partial=args.lockfile_partial, overrides=overrides)
+    profile_host, profile_build = conan_api.profiles.get_profiles_from_args(args)
+    print_profiles(profile_host, profile_build)
+
+    # Graph computation (without installation of binaries)
+    gapi = conan_api.graph
+    if path:
+        deps_graph = gapi.load_graph_consumer(path, args.name, args.version, args.user, args.channel,
+                                              profile_host, profile_build, lockfile, remotes,
+                                              # TO DISCUSS: defaulting to False the is_build_require
+                                              args.update, is_build_require=args.build_require)
+    else:
+        deps_graph = gapi.load_graph_requires(args.requires, args.tool_requires, profile_host,
+                                              profile_build, lockfile, remotes, args.update)
+    print_graph_basic(deps_graph)
+    deps_graph.report_graph_error()
+
+    if deps_graph.error:
+        return {"error": deps_graph.error}
+
+    provider = conan_api.audit.get_provider(args.provider or CONAN_CENTER_AUDIT_PROVIDER_NAME)
+
+    return conan_api.audit.scan(deps_graph, provider)
+
+
+@conan_subcommand(formatters={"text": text_vuln_formatter,
+                              "json": json_vuln_formatter,
+                              "html": html_vuln_formatter})
+def audit_list(conan_api: ConanAPI, parser, subparser, *args):
+    """
+    List the vulnerabilities of the given reference.
+    """
+    subparser.add_argument("reference", help="Reference to list vulnerabilities for", nargs="?")
+    subparser.add_argument("-l", "--list", help="pkglist file to list vulnerabilities for", default=None)
+    subparser.add_argument("-r", "--remote", help="Remote to use for listing", default=None)
+    _add_provider_arg(subparser)
+    args = parser.parse_args(*args)
+
+    if not args.reference and not args.list:
+        raise ConanException("Please specify a reference or a pkglist file")
+
+    if args.reference and args.list:
+        raise ConanException("Please specify a reference or a pkglist file, not both")
+
+    provider = conan_api.audit.get_provider(args.provider or CONAN_CENTER_AUDIT_PROVIDER_NAME)
+
+    references = []
+    if args.list:
+        listfile = make_abs_path(args.list)
+        multi_package_list = MultiPackagesList.load(listfile)
+        cache_name = "Local Cache" if not args.remote else args.remote
+        package_list = multi_package_list[cache_name]
+        refs_to_list = package_list.serialize()
+        references = list(refs_to_list.keys())
+        if not references:  # the package list might contain only refs, no revs
+            ConanOutput().warning("Nothing to list, package list do not contain recipe revisions")
+    else:
+        references = [args.reference]
+    return conan_api.audit.list(references, provider)
+
+def text_provider_formatter(providers_action):
+    providers = providers_action[0]
+    action = providers_action[1]
+
+    if action == "remove":
+        cli_out_write("Provider removed successfully.")
+    elif action == "add":
+        cli_out_write("Provider added successfully.")
+    elif action == "auth":
+        cli_out_write("Provider authentication added.")
+    elif action == "list":
+        if not providers:
+            cli_out_write("No providers found.")
+        else:
+            for provider in providers:
+                if provider:
+                    cli_out_write(f"{provider.name} (type: {provider.type}) - {provider.url}")
+
+def json_provider_formatter(providers_action):
+    ret = []
+    for provider in providers_action[0]:
+        if provider:
+            ret.append({"name": provider.name, "url": provider.url, "type": provider.type})
+    cli_out_write(json.dumps(ret, indent=4))
+
+
+@conan_subcommand(formatters={"text": text_provider_formatter, "json": json_provider_formatter})
+def audit_provider(conan_api, parser, subparser, *args):
+    """
+    Manage security providers for the 'conan audit' command.
+    """
+
+    subparser.add_argument("action", choices=["add", "list", "auth", "remove"], help="Action to perform from 'add', 'list' , 'remove' or 'auth'")
+    subparser.add_argument("name", help="Provider name", nargs="?")
+
+    subparser.add_argument("--url", help="Provider URL")
+    subparser.add_argument("--type", help="Provider type", choices=["conan-center-proxy", "private"])
+    subparser.add_argument("--token", help="Provider token")
+    args = parser.parse_args(*args)
+
+    if args.action == "add":
+        if not args.name or not args.url or not args.type:
+            raise ConanException("Name, URL and type are required to add a provider")
+        if " " in args.name:
+            raise ConanException("Name cannot contain spaces")
+        conan_api.audit.add_provider(args.name, args.url, args.type)
+
+        if not args.token:
+            user_input = UserInput(conan_api.config.get("core:non_interactive"))
+            ConanOutput().write(f"Please enter a token for {args.name} the provider: ")
+            token = user_input.get_password()
+        else:
+            token = args.token
+
+        provider = conan_api.audit.get_provider(args.name)
+        if token:
+            conan_api.audit.auth_provider(provider, token)
+
+        return [provider], args.action
+    elif args.action == "remove":
+        if not args.name:
+            raise ConanException("Name required to remove a provider")
+        conan_api.audit.remove_provider(args.name)
+        return [], args.action
+    elif args.action == "list":
+        providers = conan_api.audit.list_providers()
+        return providers, args.action
+    elif args.action == "auth":
+        if not args.name:
+            raise ConanException("Name is required to authenticate on a provider")
+        if not args.token:
+            user_input = UserInput(conan_api.config.get("core:non_interactive"))
+            ConanOutput().write(f"Please enter a token for {args.name} the provider: ")
+            token = user_input.get_password()
+        else:
+            token = args.token
+
+        provider = conan_api.audit.get_provider(args.name)
+        conan_api.audit.auth_provider(provider, token)
+        return [provider], args.action
+
+@conan_command(group="Security")
+def audit(conan_api, parser, *args):
+    """
+    Find vulnerabilities in your dependencies.
+    """
diff --git a/conan/cli/formatters/audit/__init__.py b/conan/cli/formatters/audit/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/conan/cli/formatters/audit/vulnerabilities.py b/conan/cli/formatters/audit/vulnerabilities.py
new file mode 100644
index 000000000..f0c45c46a
--- /dev/null
+++ b/conan/cli/formatters/audit/vulnerabilities.py
@@ -0,0 +1,271 @@
+import json
+
+from jinja2 import select_autoescape, Template
+
+from conan.api.output import cli_out_write, Color, ConanOutput
+
+severity_order = {
+    "Critical": 4,
+    "High": 3,
+    "Medium": 2,
+    "Low": 1
+}
+
+
+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,
+        "High": Color.RED,
+        "Medium": Color.BRIGHT_YELLOW,
+        "Low": Color.BRIGHT_CYAN
+    }
+
+    def wrap_and_indent(txt, limit=80, indent=2):
+        txt = txt.replace("\n", " ").strip()
+        if len(txt) <= limit:
+            return " " * indent + txt
+        lines = []
+        while len(txt) > limit:
+            split_index = txt.rfind(" ", 0, limit)
+            if split_index == -1:
+                split_index = limit
+            lines.append(" " * indent + txt[:split_index].strip())
+            txt = txt[split_index:].strip()
+        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():
+        edges = pkg_info.get("vulnerabilities", {}).get("edges", [])
+        count = len(edges)
+
+        border_line = "*" * (len(ref) + 4)
+        cli_out_write("\n" + border_line, fg=Color.BRIGHT_WHITE)
+        cli_out_write(f"* {ref} *", fg=Color.BRIGHT_WHITE)
+        cli_out_write(border_line, fg=Color.BRIGHT_WHITE)
+
+        if not count:
+            if not errors_in_response:
+                cli_out_write("\nNo vulnerabilities found.\n", fg=Color.BRIGHT_GREEN)
+                continue
+
+        total_vulns += count
+        summary_lines.append(
+            f"{ref} {count} {'vulnerability' if count == 1 else 'vulnerabilities'} found")
+        cli_out_write(f"\n{count} {'vulnerability' if count == 1 else 'vulnerabilities'} found:\n",
+                      fg=Color.BRIGHT_YELLOW)
+
+        sorted_vulns = sorted(edges,
+                              key=lambda v: -severity_order.get(v["node"].get("severity", "Medium"),
+                                                                2))
+
+        for vuln in sorted_vulns:
+            node = vuln["node"]
+            name = node["name"]
+            sev = node.get("severity", "Medium")
+            sev_color = severity_colors.get(sev, Color.BRIGHT_YELLOW)
+            score = node.get("cvss", {}).get("preferredBaseScore")
+            score_txt = f", CVSS: {score}" if score else ""
+            desc = node.get("description", "")
+            desc = (desc[:240] + "...") if len(desc) > 240 else desc
+            desc_wrapped = wrap_and_indent(desc)
+
+            cli_out_write(f"- {name}", fg=Color.BRIGHT_WHITE, endline="")
+            cli_out_write(f" (Severity: {sev}{score_txt})", fg=sev_color)
+            cli_out_write("\n" + desc_wrapped)
+
+            references = node.get("references")
+            if references:
+                cli_out_write(f"  url: {references[0]}", fg=Color.BRIGHT_BLUE)
+            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:
+        cli_out_write("\nSummary:\n", fg=Color.BRIGHT_WHITE)
+        for line in summary_lines:
+            cli_out_write(f"- {line}", fg=Color.BRIGHT_WHITE)
+
+        cli_out_write("\nIf you are using packages from Conan Center, some vulnerabilities may have already been mitigated "
+                      "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)
+
+
+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))
+
+
+def _render_vulns(vulns, template):
+    from conan import __version__
+    template = Template(template, autoescape=select_autoescape(['html', 'xml']))
+    return template.render(vulns=vulns, version=__version__)
+
+vuln_html = """
+<!DOCTYPE html>
+<html lang="en" class="dark">
+<head>
+  <meta charset="UTF-8">
+  <title>Conan Audit Vulnerabilities Report</title>
+  <link rel="stylesheet" href="https://cdn.datatables.net/1.13.6/css/jquery.dataTables.min.css">
+  <style>
+    body { margin: 0; padding: 0; font-family: Arial, sans-serif; background: #333; color: #ffffff; }
+    .container { width: 80%; margin: 40px auto; padding: 20px; background: #222; box-shadow: 0 2px 5px rgba(0,0,0,0.1); border-radius: 8px; }
+    h1 { text-align: center; margin-bottom: 20px; }
+    table { width: 100%; border-collapse: collapse; margin-bottom: 20px; table-layout: auto; padding-top: 10px;}
+    col.pkg-col { min-width: 100px; }
+    col.id-col { min-width: 160px; }
+    col.sev-col { min-width: 100px; }
+    col.score-col { min-width: 120px; }
+    col.desc-col { }
+    thead { background: #333; color: #fff; }
+    thead th { padding: 12px; text-align: left; }
+    tbody tr { border-bottom: 1px solid #ddd; }
+    tbody tr:hover { background: #f0f0f0; }
+    td { padding: 10px; vertical-align: top; white-space: normal; word-wrap: break-word; overflow-wrap: break-word; }
+    .severity-badge { padding: 4px 8px; border-radius: 4px; color: #fff; font-weight: bold; display: inline-block; }
+    .severity-Critical { background: #d9534f; animation: pulse 2s infinite; }
+    @keyframes pulse { 0% { box-shadow: 0 0 0 0 rgba(217,83,79,0.7); } 70% { box-shadow: 0 0 0 12px rgba(217,83,79,0); } 100% { box-shadow: 0 0 0 0 rgba(217,83,79,0); } }
+    .severity-High { background: #f0ad4e; }
+    .severity-Medium { background: #f7ecb5; color: #333; }
+    .severity-Low { background: #5cb85c; }
+    .footer { text-align: center; color: #666; margin-bottom: 10px; }
+    a { color: #007bff; text-decoration: none; }
+    a:hover { text-decoration: underline; }
+  </style>
+  <script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
+  <script src="https://cdn.datatables.net/1.13.6/js/jquery.dataTables.min.js"></script>
+  <script>
+    $(document).ready(function(){
+      $('#vuln_table').DataTable({
+        "columnDefs": [
+          { "orderable": true, "targets": [0, 1, 2] },
+          { "orderable": false, "targets": [3, 4] }
+        ],
+        "order": [[2, "desc"]],
+      });
+    });
+  </script>
+</head>
+<body>
+  <div class="container">
+    <h1>Conan Audit Vulnerabilities Report</h1>
+    <table id="vuln_table">
+      <colgroup>
+        <col class="pkg-col">
+        <col class="id-col">
+        <col class="sev-col">
+        <col class="score-col">
+        <col class="desc-col">
+      </colgroup>
+      <thead>
+        <tr>
+          <th>Package</th>
+          <th>ID</th>
+          <th>Severity</th>
+          <th>Score</th>
+          <th>Description</th>
+        </tr>
+      </thead>
+      <tbody>
+      {% for vuln in vulns %}
+        {% set parts = vuln.severity.split(' - ') %}
+        {% set severity_id = parts[0] %}
+        {% set severity_label = parts[1] if parts|length > 1 else parts[0] %}
+        <tr>
+          <td>{{ vuln.package }}</td>
+          <td>{{ vuln.vuln_id }}</td>
+          <td>
+            {% if vuln.severity not in ['N/A', ''] %}
+              <span class="severity-badge severity-{{ severity_label }}"><span style="display: none">{{ severity_id }}</span>{{ severity_label }}</span>
+            {% else %}
+              {{ vuln.severity }}
+            {% endif %}
+          </td>
+          <td>{{ vuln.score }}</td>
+          <td>
+            {{ vuln.description }}
+            {% if vuln.references %}
+              <br><br><strong>References:</strong>
+              <ul>
+                {% for ref in vuln.references %}
+                  <li><a href="{{ ref }}" target="_blank">{{ ref }}</a></li>
+                {% endfor %}
+              </ul>
+            {% endif %}
+            {% if vuln.aliases %}
+              <br><br><strong>Aliases:</strong> {{ ', '.join(vuln.aliases) }}
+            {% endif %}
+          </td>
+        </tr>
+      {% endfor %}
+      </tbody>
+    </table>
+    <div class="footer">
+      <p>Vulnerability information provided by JFrog Advanced Security. Please check <a href="https://jfrog.com/advanced-security/" target="_blank">https://jfrog.com/advanced-security/</a> for more information.</p>
+      <p>You can send questions and report issues about the returned vulnerabilities to <a href="mailto:conan-research@jfrog.com">conan-research@jfrog.com</a>.</p>
+      <p>Conan version: {{ version }}</p>
+    </div>
+  </div>
+</body>
+</html>
+"""
+
+def html_vuln_formatter(result):
+    response, errors_in_response = result
+    vulns = []
+    for ref, pkg_info in response["data"].items():
+        edges = pkg_info.get("vulnerabilities", {}).get("edges", [])
+        if not edges:
+            vulns.append({
+                "package": ref,
+                "vuln_id": "-",
+                "aliases": [],
+                "severity": "N/A",
+                "score": "-",
+                "description": "No vulnerabilities found.",
+                "references": []
+            })
+        else:
+            sorted_vulns = sorted(edges, key=lambda v: -severity_order.get(v["node"].get("severity", "Medium"), 2))
+            for vuln in sorted_vulns:
+                node = vuln["node"]
+                name = node.get("name")
+                sev = node.get("severity", "Medium")
+                sev = f"{severity_order.get(sev, 2)} - {sev}"
+                score = node.get("cvss", {}).get("preferredBaseScore")
+                score_txt = f"CVSS: {score}" if score else "-"
+                aliases = node.get("aliases", [])
+                references = node.get("references", [])
+                desc = node.get("description", "")
+                vulns.append({
+                    "package": ref,
+                    "vuln_id": name,
+                    "aliases": aliases,
+                    "severity": sev,
+                    "score": score_txt,
+                    "description": desc,
+                    "references": references,
+                })
+    if not errors_in_response or response["data"]:
+        cli_out_write(_render_vulns(vulns, vuln_html))
diff --git a/conan/internal/api/audit/__init__.py b/conan/internal/api/audit/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/conan/internal/api/audit/providers.py b/conan/internal/api/audit/providers.py
new file mode 100644
index 000000000..b4bc8aa15
--- /dev/null
+++ b/conan/internal/api/audit/providers.py
@@ -0,0 +1,208 @@
+import textwrap
+from urllib.parse import urljoin
+from conan.api.output import Color, ConanOutput
+from conan.errors import ConanException
+
+
+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._session = conan_api.remotes.requester
+        self._query_url = urljoin(self.url, "api/v1/query")
+
+    def get_cves(self, refs):
+        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()
+
+                output.write("\n")
+                output.write("Authentication required for the CVE provider: ", fg=Color.BRIGHT_RED, newline=False)
+                output.write(f"'{self.name}'\n", fg=Color.BRIGHT_WHITE)
+
+                output.write("\nTo resolve, please:\n")
+                output.write("  1. Visit: ", fg=Color.BRIGHT_WHITE, newline=False)
+                output.write("https://audit.conan.io/register\n", fg=Color.BRIGHT_BLUE)
+                output.write("  2. Register to obtain the access token and activate it.\n", fg=Color.BRIGHT_WHITE)
+                output.write("  3. Use the command below to authenticate:\n", fg=Color.BRIGHT_WHITE)
+
+                output.write(f"\n     conan audit provider auth {self.name} --token=<your_token>", fg=Color.BRIGHT_GREEN, newline=True)
+
+                output.write("\nOnce authenticated, re-run the command.\n\n")
+
+            raise ConanException("Missing authentication token. Please authenticate and retry.")
+
+
+        headers = {"Content-Type": "application/json",
+                   "Accept": "application/json",
+                   "Authorization": f"Bearer {self.token}"}
+
+        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,
+                json={
+                    "reference": str(ref),
+                },
+            )
+            if response.status_code == 200:
+                result["data"].setdefault(str(ref), {}).update(response.json()["data"]["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
+                continue
+
+            elif response.status_code == 403:
+                # TODO: How to report auth error to the user
+                ConanOutput().error(f"Authentication error ({response.status_code}).\n"
+                                    f"Your token may be invalid or not yet validated. If you recently registered, please check your email to validate your token.\n"
+                                    f" - Set a valid token using: 'conan audit provider auth --name={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
+                break
+            elif response.status_code == 429:
+                reset_seconds = int(response.headers.get("retry-after", 0))
+                reset_in_hours = reset_seconds // 3600
+                reset_in_minutes = (reset_seconds % 3600) // 60
+
+                output = ConanOutput()
+                output.write("\n")
+
+                if reset_in_hours > 0:
+                    output.write(
+                        f"You have exceeded the number of allowed requests. "
+                        f"The limit will reset in {reset_in_hours} "
+                        f"hour{'s' if reset_in_hours > 1 else ''} and {reset_in_minutes} "
+                        f"minute{'s' if reset_in_minutes > 1 else ''}.\n",
+                        fg=Color.BRIGHT_WHITE,
+                    )
+                else:
+                    output.write(
+                        f"You have exceeded the number of allowed requests. "
+                        f"The limit will reset in {reset_in_minutes} "
+                        f"minute{'s'if reset_in_minutes > 1 else ''}.\n",
+                        fg=Color.BRIGHT_WHITE,
+                    )
+                # Now link them to where we try to sell them the product
+                output.write("Visit our website to learn more about JFrog's DevSecOps solution: ", fg=Color.BRIGHT_WHITE, newline=False)
+                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
+                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
+                break
+            else:
+                ConanOutput().error(f"Error in {ref} ({response.status_code})")
+                errors_in_response = True
+                break
+        return result, errors_in_response
+
+class PrivateProvider:
+    def __init__(self, conan_api, name, provider_data):
+        self.name = name
+        self.url = provider_data["url"]
+        self.type = provider_data["type"]
+        self.data = provider_data
+        self._session = conan_api.remotes.requester
+        self._query_url = urljoin(self.url, "catalog/api/v0/public/graphql")
+
+    def get_cves(self, refs):
+        result = {"data": {}}
+        for ref in refs:
+            response = self._get(ref)
+            if "error" in response:
+                ConanOutput().error(f"Error: {response['error']['details']}")
+                result["error"] = response["error"]
+                return result, True
+            result["data"].setdefault(str(ref), {}).update(response["data"]["query"])
+        return result, False
+
+    @staticmethod
+    def _build_query(ref):
+        name, version = ref.name, ref.version
+        full_query = f"""query packageVersionDetails {{
+            query: packageVersion(name: "{name}", type: "conan", version: "{version}") {{
+                vulnerabilities(first: 100) {{
+                    totalCount
+                    edges {{
+                        node {{
+                            name
+                            description
+                            severity
+                            cvss {{
+                                preferredBaseScore
+                            }}
+                            aliases
+                            advisories {{
+                                name
+                                ...on JfrogAdvisory {{
+                                          name
+                                          shortDescription
+                                          fullDescription
+                                          url
+                                          severity
+                                     }}
+                                }}
+                            references
+                        }}
+                    }}
+                }}
+            }}
+        }}"""
+        return full_query
+
+    @staticmethod
+    def _parse_error(errors, ref):
+        """This function removes the errors array that comes from the catalog and returns a more user-friendly message
+        if we know how to parse it, or a generic one if we don't find such one"""
+
+        def _replace_message(message):
+            if "not found" in message:
+                return f"{ref} was not found in the catalog"
+            return None
+
+        error_msgs = filter(bool, [_replace_message(error["message"]) for error in errors])
+        return {"details": 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,
+                json={
+                    "query": textwrap.dedent(full_query)
+                }
+            )
+            # Raises if some HTTP error was found
+            response.raise_for_status()
+        except Exception as e:
+            return {"error": {"details": f"Something went wrong: {e}"}}
+
+        response_json = response.json()
+        # filter the extensions key with graphql data
+        response_json.pop('extensions', None)
+
+        if "errors" in response_json:
+            return {"error": self._parse_error(response_json["errors"], ref)}
+        return response_json


Test patch:
diff --git a/test/integration/command/audit.py b/test/integration/command/audit.py
new file mode 100644
index 000000000..e1509a39f
--- /dev/null
+++ b/test/integration/command/audit.py
@@ -0,0 +1,167 @@
+import sys
+from contextlib import contextmanager
+
+import pytest
+from mock.mock import patch, MagicMock
+
+from conan.test.assets.genconanfile import GenConanfile
+from conan.test.utils.env import environment_update
+from conan.test.utils.tools import TestClient
+
+
+@contextmanager
+def proxy_response(status, data):
+    with patch("conan.api.conan_api.RemotesAPI.requester") as conanRequesterMock:
+        return_status = MagicMock()
+        return_status.status_code = status
+        return_status.json = MagicMock(return_value=data)
+        return_status.headers = {"retry-after": 60}
+        conanRequesterMock.post = MagicMock(return_value=return_status)
+
+        yield conanRequesterMock, return_status
+
+
+def test_conan_audit_paths():
+    successful_response = {
+        "data": {
+            "query": {
+                "vulnerabilities": {
+                    "totalCount": 1,
+                    "edges": [
+                        {
+                            "node": {
+                                "name": "CVE-2023-45853",
+                                "description": "Zip vulnerability",
+                                "severity": "Critical",
+                                "cvss": {
+                                    "preferredBaseScore": 9.8
+                                },
+                                "aliases": [
+                                    "CVE-2023-45853",
+                                    "JFSA-2023-000272529"
+                                ],
+                                "advisories": [
+                                    {
+                                        "name": "CVE-2023-45853"
+                                    },
+                                    {
+                                        "name": "JFSA-2023-000272529"
+                                    }
+                                ],
+                                "references": [
+                                    "https://pypi.org/project/pyminizip/#history",
+                                ]
+                            }
+                        }
+                    ]
+                }
+            }
+        }
+    }
+
+    tc = TestClient(light=True)
+
+    tc.save({"conanfile.py": GenConanfile("zlib", "1.2.11")})
+    tc.run("export .")
+
+    tc.run("list '*' -f=json", redirect_stdout="pkglist.json")
+
+    tc.run("audit list zlib/1.2.11", assert_error=True)
+    assert "Authentication required for the CVE provider: 'conancenter" in tc.out
+
+    tc.run("audit provider auth conancenter --token=valid_token")
+
+    with proxy_response(200, successful_response):
+        tc.run("audit list zlib/1.2.11")
+        assert "zlib/1.2.11 1 vulnerability found" in tc.out
+
+        tc.run("audit list -l=pkglist.json")
+        assert "zlib/1.2.11 1 vulnerability found" in tc.out
+
+        tc.run("audit scan --requires=zlib/1.2.11")
+        assert "zlib/1.2.11 1 vulnerability found" in tc.out
+
+    # 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")
+        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
+
+    tc.run("audit provider add myprivate --url=foo --type=private --token=valid_token")
+
+    tc.run("audit provider list")
+    assert "(type: conan-center-proxy)" in tc.out
+    assert "(type: private)" in tc.out
+
+    tc.run("audit provider remove conancenter")
+    tc.run("audit list zlib/1.2.11", assert_error=True)
+    assert ("ERROR: Provider 'conancenter' not found. Please specify a valid provider name or add "
+            "it using: 'conan audit provider add conancenter --url=https://audit.conan.io/ "
+            "--type=conan-center-proxy --token=<token>'") in tc.out
+    assert "If you don't have a valid token, register at: https://audit.conan.io/register." in tc.out
+
+
+@pytest.mark.skipif(sys.version_info < (3, 10),
+                    reason="Strict Base64 validation introduced in Python 3.10")
+def test_conan_audit_corrupted_token():
+    tc = TestClient(light=True)
+    tc.run('audit provider auth --name=conancenter --token="corrupted_token"')
+    tc.run("audit list zlib/1.2.11", assert_error=True)
+    assert "Invalid token format for provider 'conancenter'. The token might be corrupt." in tc.out
+
+
+def test_audit_list_conflicting_args():
+    tc = TestClient(light=True)
+    tc.save({"pkglist.json": '{"Local Cache": {"zlib/1.2.11": {}}}'})
+    tc.run("audit list zlib/1.2.11 -l=pkglist.json", assert_error=True)
+    assert "Please specify a reference or a pkglist file, not both" in tc.out
+
+
+def test_audit_provider_add_missing_url():
+    tc = TestClient(light=True)
+    tc.run("audit provider add myprivate --type=private --token=valid_token", assert_error=True)
+    assert "Name, URL and type are required to add a provider" in tc.out
+
+
+def test_audit_provider_remove_nonexistent():
+    tc = TestClient(light=True)
+    tc.run("audit provider remove nonexistingprovider", assert_error=True)
+    assert "Provider 'nonexistingprovider' not found" in tc.out
+
+
+def test_audit_list_missing_arguments():
+    tc = TestClient(light=True)
+    tc.run("audit list", assert_error=True)
+    assert "Please specify a reference or a pkglist file" in tc.out
+
+
+def test_audit_provider_env_credentials_with_proxy(monkeypatch):
+    tc = TestClient(light=True)
+    # Authenticate the provider with an old token to verify that the env variable overrides it
+    tc.run("audit provider auth conancenter --token=old_token")
+
+    captured_headers = {}
+
+    def fake_post(url, headers, json):
+        # Capture the headers used in the request
+        captured_headers.update(headers)
+        response = MagicMock()
+        response.status_code = 200
+        response.json.return_value = {
+            "data": {"query": {"vulnerabilities": {"totalCount": 0, "edges": []}}}
+        }
+        response.headers = {"retry-after": 60}
+        return response
+
+    with environment_update({"CONAN_AUDIT_PROVIDER_TOKEN_CONANCENTER": "env_token_value"}):
+        with patch("conan.api.conan_api.RemotesAPI.requester",
+                   new_callable=MagicMock) as requester_mock:
+            requester_mock.post = fake_post
+            tc.run("audit list zlib/1.2.11")
+
+    # Verify that the Authorization header uses the token from the environment variable
+    assert captured_headers.get("Authorization") == "Bearer env_token_value"