Repository: conan-io/conan
Pull request: Add support for sbom and lockfiles for conan audit list
Changelog: Feature: Add support for sbom and lockfiles to `conan audit list`.
Docs: https://github.com/conan-io/docs/pull/4134

Closes #18431


Problem statement:
Look into acepting SBOMs in conan audit
The two features are aimed at improving  security, so it seems ideal that after a user has saved their sboms, that they can later query vulnerabilities from it without having to first pre-process it

Hints from issue discussion:
I think this is a good feature to add to `conan audit list`, what about reading lockfiles as well?
> I think this is a good feature to add to conan audit list, what about reading lockfiles as well?

Should be easy as well, same for package lists?

Commit message: Add support for sbom and lockfiles for conan audit list

Patch to review:
diff --git a/conan/cli/commands/audit.py b/conan/cli/commands/audit.py
index ddddad0297c..e0c2e72a3c3 100644
--- a/conan/cli/commands/audit.py
+++ b/conan/cli/commands/audit.py
@@ -3,7 +3,7 @@
 
 from conan.api.conan_api import ConanAPI
 from conan.api.input import UserInput
-from conan.api.model import MultiPackagesList
+from conan.api.model import MultiPackagesList, RecipeReference
 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
@@ -14,6 +14,7 @@
 from conan.cli.printers import print_profiles
 from conan.cli.printers.graph import print_graph_basic
 from conan.errors import ConanException
+from conan.internal.util.files import load
 
 
 def _add_provider_arg(subparser):
@@ -108,18 +109,15 @@ 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")
-    subparser.add_argument("-r", "--remote", help="Remote to use for listing")
+    input_group = subparser.add_mutually_exclusive_group(required=True)
+    input_group.add_argument("reference", help="Reference to list vulnerabilities for", nargs="?")
+    input_group.add_argument("-l", "--list", help="pkglist file to list vulnerabilities for")
+    input_group.add_argument("-s", "--sbom", help="sbom file to list vulnerabilities for")
+    input_group.add_argument("-lock", "--lockfile", help="lockfile file to list vulnerabilities for")
+    input_group.add_argument("-r", "--remote", help="Remote to use for listing")
     _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)
 
     if args.list:
@@ -131,6 +129,16 @@ def audit_list(conan_api: ConanAPI, parser, subparser, *args):
         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")
+    elif args.sbom:
+        sbom_file = make_abs_path(args.sbom)
+        sbom = json.loads(load(sbom_file))
+        purls = [component["purl"] for component in sbom["components"]]
+        references = [purl.split("pkg:conan/")[1].replace("@", "/") for purl in purls]
+    elif args.lockfile:
+        lock_file = make_abs_path(args.lockfile)
+        lockfile = conan_api.lockfile.get_lockfile(lock_file).serialize()
+        references = [str(RecipeReference.loads(ref[0] if isinstance(ref, tuple) else ref))
+                      for ref in lockfile["requires"]]
     else:
         references = [args.reference]
     return conan_api.audit.list(references, provider)
diff --git a/test/integration/command/test_audit.py b/test/integration/command/test_audit.py
index 75d5dd19bb9..5afd33015dd 100644
--- a/test/integration/command/test_audit.py
+++ b/test/integration/command/test_audit.py
@@ -11,6 +11,48 @@
 from conan.test.utils.env import environment_update
 from conan.test.utils.tools import TestClient
 
+_sbom_zlib_1_2_11 = """
+{
+  "components" : [ {
+    "author" : "<Put your name here> <And your email here>",
+    "bom-ref" : "pkg:conan/zlib@1.2.11?rref=6754320047c5dd54830baaaf9fc733c4",
+    "description" : "<Description of zlib package here>",
+    "licenses" : [ {
+      "license" : {
+        "name" : "<Put the package license here>"
+      }
+    } ],
+    "name" : "zlib",
+    "purl" : "pkg:conan/zlib@1.2.11",
+    "type" : "library",
+    "version" : "1.2.11"
+  } ],
+  "dependencies" : [ {
+    "ref" : "pkg:conan/zlib@1.2.11?rref=6754320047c5dd54830baaaf9fc733c4"
+  } ],
+  "metadata" : {
+    "component" : {
+      "author" : "<Put your name here> <And your email here>",
+      "bom-ref" : "pkg:conan/zlib@1.2.11?rref=6754320047c5dd54830baaaf9fc733c4",
+      "name" : "zlib/1.2.11",
+      "type" : "library"
+    },
+    "timestamp" : "2025-06-10T08:13:11Z",
+    "tools" : [ {
+      "externalReferences" : [ {
+        "type" : "website",
+        "url" : "https://github.com/conan-io/conan"
+      } ],
+      "name" : "Conan-io"
+    } ]
+  },
+  "serialNumber" : "urn:uuid:4f7ce240-4c6d-4a87-bfb6-78d0fbc839e3",
+  "bomFormat" : "CycloneDX",
+  "specVersion" : "1.4",
+  "version" : 1
+}
+"""
+
 
 @contextmanager
 def proxy_response(status, data, retry_after=60):
@@ -65,8 +107,9 @@ def test_conan_audit_proxy():
 
     tc = TestClient(light=True)
 
-    tc.save({"conanfile.py": GenConanfile("zlib", "1.2.11")})
-    tc.run("export .")
+    tc.save({"conanfile.py": GenConanfile("zlib", "1.2.11"),
+             "sbom.cdx.json": _sbom_zlib_1_2_11})
+    tc.run("create . --lockfile-out=conan.lock")
 
     tc.run("list '*' -f=json", redirect_stdout="pkglist.json")
 
@@ -82,6 +125,12 @@ def test_conan_audit_proxy():
         tc.run("audit list -l=pkglist.json")
         assert "zlib/1.2.11 1 vulnerability found" in tc.out
 
+        tc.run("audit list --lockfile=conan.lock")
+        assert "zlib/1.2.11 1 vulnerability found" in tc.out
+
+        tc.run("audit list --sbom=sbom.cdx.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
 
@@ -175,8 +224,9 @@ def test_conan_audit_private():
 
     tc = TestClient(light=True)
 
-    tc.save({"conanfile.py": GenConanfile("zlib", "1.2.11")})
-    tc.run("export .")
+    tc.save({"conanfile.py": GenConanfile("zlib", "1.2.11"),
+             "sbom.cdx.json": _sbom_zlib_1_2_11})
+    tc.run("create . --lockfile-out=conan.lock")
 
     tc.run("list '*' -f=json", redirect_stdout="pkglist.json")
 
@@ -199,6 +249,12 @@ def test_conan_audit_private():
         tc.run("audit list -l=pkglist.json -p=myprivate")
         assert "zlib/1.2.11 1 vulnerability found" in tc.out
 
+        tc.run("audit list --lockfile=conan.lock -p=myprivate")
+        assert "zlib/1.2.11 1 vulnerability found" in tc.out
+
+        tc.run("audit list --sbom=sbom.cdx.json -p=myprivate")
+        assert "zlib/1.2.11 1 vulnerability found" in tc.out
+
         tc.run("audit scan --requires=zlib/1.2.11  -p=myprivate")
         assert "zlib/1.2.11 1 vulnerability found" in tc.out
 
@@ -248,7 +304,7 @@ 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
+    assert "argument -l/--list: not allowed with argument reference" in tc.out
 
 
 def test_audit_provider_add_missing_url():
@@ -266,7 +322,7 @@ def test_audit_provider_remove_nonexistent():
 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
+    assert "one of the arguments reference" in tc.out
 
 
 def test_audit_provider_env_credentials_with_proxy(monkeypatch):


Code review comments:
diff_hunk: @@ -108,18 +109,15 @@ 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")
-    subparser.add_argument("-r", "--remote", help="Remote to use for listing")
+    input_group = subparser.add_mutually_exclusive_group(required=True)
+    input_group.add_argument("reference", help="Reference to list vulnerabilities for", nargs="?")
+    input_group.add_argument("-l", "--list", help="pkglist file to list vulnerabilities for")
+    input_group.add_argument("-s", "--sbom", help="sbom file to list vulnerabilities for")
+    input_group.add_argument("-lock", "--lockfile", help="lockfile file to list vulnerabilities for")
+    input_group.add_argument("-r", "--remote", help="Remote to use for listing")
path: conan/cli/commands/audit.py
text: @user1:
Why was the remote necessary? Won't be a problem to define it for the ``--list`` if in the same group?

@author:
Ahg, typo, good catch!

diff_hunk: @@ -131,6 +129,16 @@ def audit_list(conan_api: ConanAPI, parser, subparser, *args):
         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")
+    elif args.sbom:
+        sbom_file = make_abs_path(args.sbom)
+        sbom = json.loads(load(sbom_file))
+        purls = [component["purl"] for component in sbom["components"]]
path: conan/cli/commands/audit.py
text: @user1:
This is specific for cyclone, isn't it? Maybe some extra check that it is indeed a cyclone-dx, and error otherwise?