Skip to content

Commit 88345c8

Browse files
committed
Add new subcommand for transfering CVE IDs to other CNAs
An owning CNA can transfer one of their CVE IDs to any other existing CNA using the shortname of that CNA.
1 parent 1949eb9 commit 88345c8

File tree

4 files changed

+158
-0
lines changed

4 files changed

+158
-0
lines changed

cvelib/cli.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -627,6 +627,43 @@ def undo_reject(ctx: click.Context, cve_id: str, print_raw: bool) -> None:
627627
print_cve_id(response_data["updated"])
628628

629629

630+
@cli.command()
631+
@click.argument("cve_id", callback=validate_cve)
632+
@click.option(
633+
"-n",
634+
"--new-cna",
635+
required=True,
636+
help="Shortname of the new owning CNA for the CVE ID.",
637+
)
638+
@click.option("--raw", "print_raw", default=False, is_flag=True, help="Print response JSON.")
639+
@click.pass_context
640+
@handle_cve_api_error
641+
def transfer(ctx: click.Context, cve_id: str, new_cna: str, print_raw: bool) -> None:
642+
"""Transfer ownership of a CVE ID to another CNA.
643+
644+
This command updates the owning_cna attribute of the specified CVE ID to transfer
645+
ownership to another CNA organization. CNAs can only transfer CVE IDs they own.
646+
"""
647+
if ctx.obj.interactive:
648+
click.echo("You are about to transfer ownership of ", nl=False)
649+
click.secho(cve_id, bold=True, nl=False)
650+
click.echo(" to CNA ", nl=False)
651+
click.secho(new_cna, bold=True, nl=False)
652+
click.echo(".")
653+
if not click.confirm("\nDo you want to continue?"):
654+
click.echo("Exiting...")
655+
sys.exit(0)
656+
click.echo()
657+
658+
cve_api = ctx.obj.cve_api
659+
response_data = cve_api.transfer(cve_id, new_cna)
660+
if print_raw:
661+
print_json_data(response_data)
662+
else:
663+
click.echo("Successfully transferred the following CVE:\n")
664+
print_cve_id(response_data["updated"])
665+
666+
630667
@cli.command()
631668
@click.option(
632669
"-r",

cvelib/cve_api.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,14 @@ def move_to_reserved(self, cve_id):
289289
params = {"state": self.States.RESERVED}
290290
return self._put(f"cve-id/{cve_id}", params=params).json()
291291

292+
def transfer(self, cve_id: str, new_cna: str) -> dict:
293+
"""Transfer ownership of a CVE ID to another CNA.
294+
295+
This updates the owning_cna attribute of the specified CVE ID.
296+
"""
297+
params = {"org": new_cna}
298+
return self._put(f"cve-id/{cve_id}", params=params).json()
299+
292300
def reserve(self, count: int, random: bool, year: str) -> dict:
293301
"""Reserve a set of CVE IDs.
294302

tests/test_cli.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -556,6 +556,99 @@ def test_cve_undo_reject(move_to_reserved):
556556
)
557557

558558

559+
@mock.patch("cvelib.cli.CveApi.transfer")
560+
def test_cve_transfer(transfer):
561+
cve_id = "CVE-2023-1234"
562+
response = {
563+
"updated": {
564+
"cve_id": "CVE-2023-1234",
565+
"cve_year": "2023",
566+
"owning_cna": "new-cna",
567+
"requested_by": {"cna": "old-cna", "user": "test@example.com"},
568+
"reserved": "2021-01-14T18:35:17.469Z",
569+
"state": "RESERVED",
570+
"time": {"created": "2021-01-14T18:35:17.469Z", "modified": "2021-01-14T18:35:17.469Z"},
571+
},
572+
"message": f"{cve_id} owning_cna updated",
573+
}
574+
575+
transfer.return_value = response
576+
runner = CliRunner()
577+
result = runner.invoke(cli, DEFAULT_OPTS + ["transfer", cve_id, "--new-cna", "new-cna"])
578+
assert result.exit_code == 0, result.output
579+
assert result.output == (
580+
"Successfully transferred the following CVE:\n"
581+
"\n"
582+
f"{cve_id}\n"
583+
"├─ State:\tRESERVED\n"
584+
"├─ Owning CNA:\tnew-cna\n"
585+
"├─ Reserved by:\ttest@example.com (old-cna)\n"
586+
"├─ Reserved on:\tThu Jan 14 18:35:17 2021 +0000\n"
587+
"└─ Updated on:\tThu Jan 14 18:35:17 2021 +0000\n"
588+
)
589+
transfer.assert_called_once_with(cve_id, "new-cna")
590+
591+
592+
@mock.patch("cvelib.cli.CveApi.transfer")
593+
def test_cve_transfer_raw_output(transfer):
594+
cve_id = "CVE-2023-1234"
595+
response = {
596+
"updated": {
597+
"cve_id": "CVE-2023-1234",
598+
"owning_cna": "new-cna",
599+
"state": "RESERVED",
600+
},
601+
"message": f"{cve_id} owning_cna updated",
602+
}
603+
604+
transfer.return_value = response
605+
runner = CliRunner()
606+
result = runner.invoke(
607+
cli, DEFAULT_OPTS + ["transfer", cve_id, "--new-cna", "new-cna", "--raw"]
608+
)
609+
assert result.exit_code == 0, result.output
610+
assert json.loads(result.output) == response
611+
transfer.assert_called_once_with(cve_id, "new-cna")
612+
613+
614+
@mock.patch("cvelib.cli.CveApi.transfer")
615+
def test_cve_transfer_interactive_confirm(transfer):
616+
cve_id = "CVE-2023-1234"
617+
transfer_response = {
618+
"updated": {
619+
"cve_id": "CVE-2023-1234",
620+
"owning_cna": "new-cna",
621+
"state": "RESERVED",
622+
},
623+
"message": f"{cve_id} owning_cna updated",
624+
}
625+
626+
transfer.return_value = transfer_response
627+
runner = CliRunner()
628+
result = runner.invoke(
629+
cli, DEFAULT_OPTS + ["-i", "transfer", cve_id, "--new-cna", "new-cna"], input="y\n"
630+
)
631+
assert result.exit_code == 0, result.output
632+
assert "You are about to transfer ownership of CVE-2023-1234 to CNA new-cna" in result.output
633+
assert "Do you want to continue?" in result.output
634+
transfer.assert_called_once_with(cve_id, "new-cna")
635+
636+
637+
@mock.patch("cvelib.cli.CveApi.transfer")
638+
def test_cve_transfer_interactive_abort(transfer):
639+
cve_id = "CVE-2023-1234"
640+
641+
runner = CliRunner()
642+
result = runner.invoke(
643+
cli, DEFAULT_OPTS + ["-i", "transfer", cve_id, "--new-cna", "new-cna"], input="n\n"
644+
)
645+
assert result.exit_code == 0, result.output
646+
assert "You are about to transfer ownership of CVE-2023-1234 to CNA new-cna" in result.output
647+
assert "Do you want to continue?" in result.output
648+
assert "Exiting..." in result.output
649+
transfer.assert_not_called()
650+
651+
559652
def test_quota():
560653
quota = {"id_quota": 100, "total_reserved": 10, "available": 90}
561654
with mock.patch("cvelib.cli.CveApi.quota") as get_quota:

tests/test_cve_api.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,3 +107,23 @@ def test_count_cves():
107107
count = cve_api.count_cves(state="published")
108108
get_mock.assert_called_with("cve_count", params={"state": "PUBLISHED"})
109109
assert count == {"totalCount": 42}
110+
111+
112+
def test_transfer():
113+
with mock.patch("cvelib.cve_api.CveApi._put") as put_mock:
114+
response = {
115+
"updated": "CVE-2099-1234",
116+
"message": "CVE-2099-1234 owning_cna updated",
117+
"cve_id": "CVE-2099-1234",
118+
"owning_cna": "new-cna",
119+
"state": "RESERVED",
120+
"requested_by": {"cna": "old-cna", "user": "test@example.com"},
121+
"reserved": "2021-01-14T18:35:17.469Z",
122+
"time": {"created": "2021-01-14T18:35:17.469Z", "modified": "2021-01-14T18:35:17.469Z"},
123+
}
124+
put_mock.return_value.json.return_value = response
125+
cve_api = CveApi(username="test_user", org="test_org", api_key="test_key")
126+
127+
result = cve_api.transfer("CVE-2099-1234", "new-cna")
128+
put_mock.assert_called_with("cve-id/CVE-2099-1234", params={"org": "new-cna"})
129+
assert result == response

0 commit comments

Comments
 (0)