Skip to content

Commit 9f8de82

Browse files
authored
Merge pull request #109 from RedHatProductSecurity/transfer-cve
Add new subcommand for transfering CVE IDs to other CNAs
2 parents 1949eb9 + 88345c8 commit 9f8de82

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)