This is an automated email from the ASF dual-hosted git repository.

sbp pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git


The following commit(s) were added to refs/heads/main by this push:
     new 25c7758  Store vulnerabilities in SBOM and read back from the report. 
Store ATR task info in SBOM as a reference.
25c7758 is described below

commit 25c7758a2b3b225b8812b6ed19cb191111218812
Author: Alastair McFarlane <[email protected]>
AuthorDate: Thu Dec 18 16:00:52 2025 +0000

    Store vulnerabilities in SBOM and read back from the report. Store ATR task 
info in SBOM as a reference.
---
 atr/get/checks.py                            |   8 +
 atr/get/sbom.py                              | 296 +++++++++++++++++++--------
 atr/models/results.py                        |  13 +-
 atr/sbom/cli.py                              |  37 +++-
 atr/sbom/models/bom.py                       |   2 +-
 atr/sbom/models/osv.py                       |  36 +++-
 atr/sbom/osv.py                              | 170 +++++++++++++--
 atr/sbom/utilities.py                        | 111 +++++++++-
 atr/tasks/sbom.py                            |  35 +++-
 atr/templates/check-selected-path-table.html |   2 +-
 docker-compose.yml                           |   2 +
 pyproject.toml                               |   1 +
 uv.lock                                      |  11 +
 13 files changed, 594 insertions(+), 130 deletions(-)

diff --git a/atr/get/checks.py b/atr/get/checks.py
index 47c2879..0e0815f 100644
--- a/atr/get/checks.py
+++ b/atr/get/checks.py
@@ -27,6 +27,7 @@ import atr.db as db
 import atr.get.download as download
 import atr.get.ignores as ignores
 import atr.get.report as report
+import atr.get.sbom as sbom
 import atr.get.vote as vote
 import atr.htm as htm
 import atr.models.sql as sql
@@ -349,6 +350,7 @@ def _render_file_row(
         version_name=release.version,
         file_path=path_str,
     )
+    sbom_url = util.as_url(sbom.report, project=release.project.name, 
version=release.version, file_path=path_str)
 
     if not has_checks_before:
         path_display = htpy.code(".text-muted")[path_str]
@@ -393,6 +395,11 @@ def _render_file_row(
         err_cell = htpy.span(".text-muted", style=num_style)["0"]
         report_btn = htpy.a(".btn.btn-sm.btn-outline-success", 
href=report_url)["Show details"]
 
+    # <a href="{{ as_url(get.sbom.report, project=project_name, 
version=version_name, file_path=path) }}"
+    # class="btn btn-sm btn-outline-secondary">Show SBOM</a>
+    sbom_btn = None
+    if path.suffixes[-2:] == [".cdx", ".json"]:
+        sbom_btn = htpy.a(".btn.btn-sm.btn-outline-secondary", 
href=sbom_url)["SBOM report"]
     download_btn = htpy.a(".btn.btn-sm.btn-outline-secondary", 
href=download_url)["Download"]
 
     tbody.tr[
@@ -403,6 +410,7 @@ def _render_file_row(
         htpy.td(".text-end.text-nowrap.py-2.pe-3")[
             htpy.div(".d-flex.justify-content-end.align-items-center.gap-2")[
                 report_btn,
+                sbom_btn,
                 download_btn,
             ],
         ],
diff --git a/atr/get/sbom.py b/atr/get/sbom.py
index fb570a7..4a588e1 100644
--- a/atr/get/sbom.py
+++ b/atr/get/sbom.py
@@ -18,9 +18,11 @@
 from __future__ import annotations
 
 import json
-from typing import TYPE_CHECKING, Any
+from typing import TYPE_CHECKING
 
 import asfquart.base as base
+import cmarkgfm
+import markupsafe
 
 import atr.blueprints.get as get
 import atr.db as db
@@ -29,6 +31,7 @@ import atr.htm as htm
 import atr.models.results as results
 import atr.models.sql as sql
 import atr.sbom as sbom
+import atr.sbom.models.osv as osv
 import atr.shared as shared
 import atr.template as template
 import atr.web as web
@@ -40,7 +43,14 @@ if TYPE_CHECKING:
 @get.committer("/sbom/report/<project>/<version>/<path:file_path>")
 async def report(session: web.Committer, project: str, version: str, 
file_path: str) -> str:
     await session.check_access(project)
-    await session.release(project, version)
+
+    # If the draft is not found, we try to get the release candidate
+    try:
+        release = await session.release(project, version, with_committee=True)
+    except base.ASFQuartException:
+        release = await session.release(project, version, 
phase=sql.ReleasePhase.RELEASE_CANDIDATE, with_committee=True)
+    is_release_candidate = release.phase == sql.ReleasePhase.RELEASE_CANDIDATE
+
     async with db.session() as data:
         via = sql.validate_instrumented_attribute
         # TODO: Abstract this code and the sbomtool.MissingAdapter validators
@@ -48,20 +58,21 @@ async def report(session: web.Committer, project: str, 
version: str, file_path:
             await data.task(
                 project_name=project,
                 version_name=version,
+                revision_number=release.latest_revision_number,
                 task_type=sql.TaskType.SBOM_TOOL_SCORE,
-                status=sql.TaskStatus.COMPLETED,
                 primary_rel_path=file_path,
             )
             .order_by(sql.sqlmodel.desc(via(sql.Task.completed)))
             .all()
         )
-
+        # Run or running scans for the current revision
         osv_tasks = (
             await data.task(
                 project_name=project,
                 version_name=version,
                 task_type=sql.TaskType.SBOM_OSV_SCAN,
                 primary_rel_path=file_path,
+                revision_number=release.latest_revision_number,
             )
             .order_by(sql.sqlmodel.desc(via(sql.Task.added)))
             .all()
@@ -70,48 +81,31 @@ async def report(session: web.Committer, project: str, 
version: str, file_path:
     block = htm.Block()
     block.h1["SBOM report"]
 
-    if not tasks:
-        # TODO: Show task if the score is being computed
-        block.p["No SBOM score found."]
-        return await template.blank("SBOM report", content=block.collect())
+    await _report_task_results(block, list(tasks))
 
     task_result = tasks[0].result
     if not isinstance(task_result, results.SBOMToolScore):
         raise base.ASFQuartException("Invalid SBOM score result", 
errorcode=500)
-    warnings = 
[sbom.models.conformance.MissingAdapter.validate_python(json.loads(w)) for w in 
task_result.warnings]
-    errors = 
[sbom.models.conformance.MissingAdapter.validate_python(json.loads(e)) for e in 
task_result.errors]
 
-    block.p[
-        """This is a report by the ATR SBOM tool, for debugging and
-        informational purposes. Please use it only as an approximate
-        guideline to the quality of your SBOM file."""
-    ]
-    block.p["This report is for revision ", 
htm.code[task_result.revision_number], "."]
+    _report_header(block, is_release_candidate, release, task_result)
 
-    # TODO: Show the status if the task to augment the SBOM is still running
-    # TODO: Add a field to the SBOM to show that it's been augmented
-    # And then don't allow it to be augmented again
-    form.render_block(
-        block,
-        model_cls=shared.sbom.AugmentSBOMForm,
-        submit_label="Augment SBOM",
-        empty=True,
-    )
+    if not is_release_candidate:
+        _augment_section(block, release, task_result)
 
-    if warnings:
-        block.h2["Warnings"]
-        _missing_table(block, warnings)
+    _conformance_section(block, task_result)
 
-    if errors:
-        block.h2["Errors"]
-        _missing_table(block, errors)
+    block.h2["Vulnerabilities"]
 
-    if not (warnings or errors):
-        block.h2["Conformance report"]
-        block.p["No NTIA 2021 minimum data field conformance warnings or 
errors found."]
+    if task_result.vulnerabilities is not None:
+        vulnerabilities = [
+            sbom.models.osv.CdxVulnAdapter.validate_python(json.loads(e)) for 
e in task_result.vulnerabilities
+        ]
+    else:
+        vulnerabilities = []
 
-    block.h2["Vulnerability scan"]
-    _vulnerability_scan_section(block, project, version, file_path, 
task_result.revision_number, osv_tasks)
+    _vulnerability_scan_section(
+        block, project, version, file_path, task_result, vulnerabilities, 
osv_tasks, is_release_candidate
+    )
 
     block.h2["Outdated tool"]
     outdated = None
@@ -133,22 +127,102 @@ async def report(session: web.Committer, project: str, 
version: str, file_path:
     else:
         block.p["No outdated tool found."]
 
+    _cyclonedx_cli_errors(block, task_result)
+
+    return await template.blank("SBOM report", content=block.collect())
+
+
+def _conformance_section(block: htm.Block, task_result: results.SBOMToolScore) 
-> None:
+    warnings = 
[sbom.models.conformance.MissingAdapter.validate_python(json.loads(w)) for w in 
task_result.warnings]
+    errors = 
[sbom.models.conformance.MissingAdapter.validate_python(json.loads(e)) for e in 
task_result.errors]
+    if warnings:
+        block.h2["Warnings"]
+        _missing_table(block, warnings)
+
+    if errors:
+        block.h2["Errors"]
+        _missing_table(block, errors)
+
+    if not (warnings or errors):
+        block.h2["Conformance report"]
+        block.p["No NTIA 2021 minimum data field conformance warnings or 
errors found."]
+
+
+def _report_header(
+    block: htm.Block, is_release_candidate: bool, release: sql.Release, 
task_result: results.SBOMToolScore
+) -> None:
+    block.p[
+        """This is a report by the ATR SBOM tool, for debugging and
+        informational purposes. Please use it only as an approximate
+        guideline to the quality of your SBOM file."""
+    ]
+    if not is_release_candidate:
+        block.p[
+            "This report is for revision ", 
htm.code[task_result.revision_number], "."
+        ]  # TODO: Mark if a subsequent score has failed
+    elif release.phase == sql.ReleasePhase.RELEASE_CANDIDATE:
+        block.p[f"This report is for the latest {release.version} release 
candidate."]
+
+
+async def _report_task_results(block: htm.Block, tasks: list[sql.Task]):
+    if not tasks:
+        block.p["No SBOM score found."]
+        return await template.blank("SBOM report", content=block.collect())
+
+    task_status = tasks[0].status
+    task_error = tasks[0].error
+    if task_status == sql.TaskStatus.QUEUED:
+        block.p["SBOM score is being computed."]
+        return await template.blank("SBOM report", content=block.collect())
+
+    if task_status == sql.TaskStatus.FAILED:
+        block.p[f"SBOM score task failed: {task_error}"]
+        return await template.blank("SBOM report", content=block.collect())
+
+
+def _augment_section(block: htm.Block, release: sql.Release, task_result: 
results.SBOMToolScore):
+    # TODO: Show the status if the task to augment the SBOM is still running
+    # And then don't allow it to be augmented again
+    augments = []
+    if task_result.atr_props is not None:
+        augments = [t.get("value", "") for t in task_result.atr_props if 
t.get("name", "") == "asf:atr:augment"]
+    if len(augments) == 0:
+        block.p["We can attempt to augment this SBOM with additional data."]
+        form.render_block(
+            block,
+            model_cls=shared.sbom.AugmentSBOMForm,
+            submit_label="Augment SBOM",
+            empty=True,
+        )
+    else:
+        if release.latest_revision_number in augments:
+            block.p["This SBOM was augmented by ATR."]
+        else:
+            block.p["This SBOM was augmented by ATR at revision ", 
htm.code[augments[-1]], "."]
+            block.p["We can perform augmentation again to check for additional 
new data."]
+            form.render_block(
+                block,
+                model_cls=shared.sbom.AugmentSBOMForm,
+                submit_label="Re-augment SBOM",
+                empty=True,
+            )
+
+
+def _cyclonedx_cli_errors(block: htm.Block, task_result: 
results.SBOMToolScore):
     block.h2["CycloneDX CLI validation errors"]
     if task_result.cli_errors:
         block.pre["\n".join(task_result.cli_errors)]
     else:
         block.p["No CycloneDX CLI validation errors found."]
 
-    return await template.blank("SBOM report", content=block.collect())
-
 
-def _extract_vulnerability_severity(vuln: dict[str, Any]) -> str:
+def _extract_vulnerability_severity(vuln: osv.VulnerabilityDetails) -> str:
     """Extract severity information from vulnerability data."""
-    db_specific = vuln.get("database_specific", {})
-    if "severity" in db_specific:
-        return db_specific["severity"]
+    data = vuln.database_specific or {}
+    if "severity" in data:
+        return data["severity"]
 
-    severity_data = vuln.get("severity", [])
+    severity_data = vuln.severity
     if severity_data and isinstance(severity_data, list):
         first_severity = severity_data[0]
         if isinstance(first_severity, dict) and ("type" in first_severity):
@@ -198,7 +272,7 @@ def _missing_tally(items: 
list[sbom.models.conformance.Missing]) -> list[tuple[s
     )
 
 
-def _vulnerability_component_details(block: htm.Block, component: 
results.OSVComponent) -> None:
+def _vulnerability_component_details_osv(block: htm.Block, component: 
results.OSVComponent) -> None:
     details_content = []
     summary_element = htm.summary[
         
htm.span(".badge.bg-danger.me-2.font-monospace")[str(len(component.vulnerabilities))],
@@ -207,17 +281,20 @@ def _vulnerability_component_details(block: htm.Block, 
component: results.OSVCom
     details_content.append(summary_element)
 
     for vuln in component.vulnerabilities:
-        vuln_id = vuln.get("id", "Unknown")
-        vuln_summary = vuln.get("summary", "No summary available")
-        vuln_refs = [r for r in vuln.get("references", []) if r.get("type", 
"") == "WEB"]
+        vuln_id = vuln.id or "Unknown"
+        vuln_summary = vuln.summary
+        vuln_refs = []
+        if vuln.references is not None:
+            vuln_refs = [r for r in vuln.references if r.get("type", "") == 
"WEB"]
         vuln_primary_ref = vuln_refs[0] if (len(vuln_refs) > 0) else {}
-        vuln_modified = vuln.get("modified", "Unknown")
+        vuln_modified = vuln.modified or "Unknown"
         vuln_severity = _extract_vulnerability_severity(vuln)
 
         vuln_header = [htm.a(href=vuln_primary_ref.get("url", ""), 
target="_blank")[htm.strong(".me-2")[vuln_id]]]
         if vuln_severity != "Unknown":
             
vuln_header.append(htm.span(".badge.bg-warning.text-dark")[vuln_severity])
 
+        details = 
markupsafe.Markup(cmarkgfm.github_flavored_markdown_to_html(vuln.details))
         vuln_div = 
htm.div(".ms-3.mb-3.border-start.border-warning.border-3.ps-3")[
             htm.div(".d-flex.align-items-center.mb-2")[*vuln_header],
             htm.p(".mb-1")[vuln_summary],
@@ -225,15 +302,15 @@ def _vulnerability_component_details(block: htm.Block, 
component: results.OSVCom
                 "Last modified: ",
                 vuln_modified,
             ],
-            htm.div(".mt-2.text-muted")[vuln.get("details", "No additional 
details available.")],
+            htm.div(".mt-2.text-muted")[details or "No additional details 
available."],
         ]
         details_content.append(vuln_div)
 
     block.append(htm.details(".mb-3.rounded")[*details_content])
 
 
-def _vulnerability_scan_button(block: htm.Block, project: str, version: str, 
file_path: str) -> None:
-    block.p["No vulnerability scan has been performed for this revision."]
+def _vulnerability_scan_button(block: htm.Block) -> None:
+    block.p["No new vulnerability scan has been performed for this revision."]
 
     form.render_block(
         block,
@@ -266,33 +343,78 @@ def _vulnerability_scan_find_in_progress_task(
     return None
 
 
-def _vulnerability_scan_results(block: htm.Block, task: sql.Task) -> None:
-    task_result = task.result
-    if not isinstance(task_result, results.SBOMOSVScan):
-        block.p["Invalid scan result format."]
-        return
+def _vulnerability_scan_results(
+    block: htm.Block, vulns: list[osv.CdxVulnerabilityDetail], scans: 
list[str], task: sql.Task | None
+) -> None:
+    if task is not None:
+        task_result = task.result
+        if not isinstance(task_result, results.SBOMOSVScan):
+            block.p["Invalid scan result format."]
+            return
+
+        components = task_result.components
+        ignored = task_result.ignored
+        ignored_count = len(ignored)
+
+        if not components:
+            block.p["No vulnerabilities found."]
+            if ignored_count > 0:
+                component_word = "component was" if (ignored_count == 1) else 
"components were"
+                block.p[f"{ignored_count} {component_word} ignored due to 
missing PURL or version information:"]
+                block.p[f"{','.join(ignored)}"]
+            return
+
+        block.p[f"Scan found vulnerabilities in {len(components)} components:"]
 
-    components = task_result.components
-    ignored = task_result.ignored
-    ignored_count = len(ignored)
+        for component in components:
+            _vulnerability_component_details_osv(block, component)
 
-    if not components:
-        block.p["No vulnerabilities found."]
         if ignored_count > 0:
             component_word = "component was" if (ignored_count == 1) else 
"components were"
             block.p[f"{ignored_count} {component_word} ignored due to missing 
PURL or version information:"]
             block.p[f"{','.join(ignored)}"]
-        return
-
-    block.p[f"Found vulnerabilities in {len(components)} components:"]
+    else:
+        if len(vulns) == 0:
+            block.p["No vulnerabilities listed in this SBOM."]
+            return
+        components = {a.get("ref", "") for v in vulns if v.affects is not None 
for a in v.affects}
+
+        if len(scans) > 0:
+            block.p["This SBOM was scanned for vulnerabilities at revision ", 
htm.code[scans[-1]], "."]
+
+        block.p[f"Vulnerabilities found in {len(components)} components:"]
+
+        for component in components:
+            _vulnerability_component_details_osv(
+                block,
+                results.OSVComponent(
+                    purl=component,
+                    vulnerabilities=[
+                        _cdx_to_osv(v)
+                        for v in vulns
+                        if v.affects is not None and component in 
[a.get("ref") for a in v.affects]
+                    ],
+                ),
+            )
 
-    for component in components:
-        _vulnerability_component_details(block, component)
 
-    if ignored_count > 0:
-        component_word = "component was" if (ignored_count == 1) else 
"components were"
-        block.p[f"{ignored_count} {component_word} ignored due to missing PURL 
or version information:"]
-        block.p[f"{','.join(ignored)}"]
+def _cdx_to_osv(cdx: osv.CdxVulnerabilityDetail) -> osv.VulnerabilityDetails:
+    score = []
+    severity = ""
+    if cdx.ratings is not None:
+        severity, score = sbom.utilities.cdx_severity_to_osv(cdx.ratings)
+    return osv.VulnerabilityDetails(
+        id=cdx.id,
+        summary=cdx.description,
+        details=cdx.detail,
+        modified=cdx.updated or "",
+        published=cdx.published,
+        severity=score,
+        database_specific={"severity": severity},
+        references=[{"type": "WEB", "url": a.get("url", "")} for a in 
cdx.advisories]
+        if cdx.advisories is not None
+        else [],
+    )
 
 
 def _vulnerability_scan_section(
@@ -300,31 +422,29 @@ def _vulnerability_scan_section(
     project: str,
     version: str,
     file_path: str,
-    revision_number: str,
+    task_result: results.SBOMToolScore,
+    vulnerabilities: list[osv.CdxVulnerabilityDetail],
     osv_tasks: collections.abc.Sequence[sql.Task],
+    is_release_candidate: bool,
 ) -> None:
     """Display the vulnerability scan section based on task status."""
-    completed_task = _vulnerability_scan_find_completed_task(osv_tasks, 
revision_number)
+    completed_task = _vulnerability_scan_find_completed_task(osv_tasks, 
task_result.revision_number)
 
-    if completed_task is not None:
-        _vulnerability_scan_results(block, completed_task)
-        return
+    in_progress_task = _vulnerability_scan_find_in_progress_task(osv_tasks, 
task_result.revision_number)
 
-    in_progress_task = _vulnerability_scan_find_in_progress_task(osv_tasks, 
revision_number)
+    scans = []
+    if task_result.atr_props is not None:
+        scans = [t.get("value", "") for t in task_result.atr_props if 
t.get("name", "") == "asf:atr:osv-scan"]
+    _vulnerability_scan_results(block, vulnerabilities, scans, completed_task)
 
-    if in_progress_task is not None:
-        _vulnerability_scan_status(block, in_progress_task, project, version, 
file_path)
-    else:
-        _vulnerability_scan_button(block, project, version, file_path)
+    if not is_release_candidate:
+        if in_progress_task is not None:
+            _vulnerability_scan_status(block, in_progress_task, project, 
version, file_path)
+        else:
+            _vulnerability_scan_button(block)
 
 
-def _vulnerability_scan_status(
-    block: htm.Block,
-    task: sql.Task,
-    project: str,
-    version: str,
-    file_path: str,
-) -> None:
+def _vulnerability_scan_status(block: htm.Block, task: sql.Task, project: str, 
version: str, file_path: str) -> None:
     status_text = task.status.value.replace("_", " ").capitalize()
     block.p[f"Vulnerability scan is currently {status_text.lower()}."]
     block.p["Task ID: ", htm.code[str(task.id)]]
@@ -334,4 +454,4 @@ def _vulnerability_scan_status(
             htm.code[task.error],
             ". Additional details are unavailable from ATR.",
         ]
-        _vulnerability_scan_button(block, project, version, file_path)
+        _vulnerability_scan_button(block)
diff --git a/atr/models/results.py b/atr/models/results.py
index 9db1b55..166a4ed 100644
--- a/atr/models/results.py
+++ b/atr/models/results.py
@@ -15,10 +15,12 @@
 # specific language governing permissions and limitations
 # under the License.
 
-from typing import Annotated, Any, Literal
+from typing import Annotated, Literal
 
 import pydantic
 
+import atr.sbom.models.osv as osv
+
 from . import schema
 
 
@@ -48,7 +50,7 @@ class SBOMGenerateCycloneDX(schema.Strict):
 
 class OSVComponent(schema.Strict):
     purl: str = schema.description("Package URL")
-    vulnerabilities: list[dict[str, Any]] = 
schema.description("Vulnerabilities found")
+    vulnerabilities: list[osv.VulnerabilityDetails] = 
schema.description("Vulnerabilities found")
 
 
 class SBOMOSVScan(schema.Strict):
@@ -57,6 +59,7 @@ class SBOMOSVScan(schema.Strict):
     version_name: str = schema.description("Version name")
     revision_number: str = schema.description("Revision number")
     file_path: str = schema.description("Relative path to the scanned SBOM 
file")
+    new_file_path: str = schema.Field(default="", strict=False, 
description="Relative path to the updated SBOM file")
     components: list[OSVComponent] = schema.description("Components with 
vulnerabilities")
     ignored: list[str] = schema.description("Components ignored")
 
@@ -120,6 +123,12 @@ class SBOMToolScore(schema.Strict):
     warnings: list[str] = schema.description("Warnings from the SBOM tool")
     errors: list[str] = schema.description("Errors from the SBOM tool")
     outdated: str | None = schema.description("Outdated tool from the SBOM 
tool")
+    vulnerabilities: list[str] | None = schema.Field(
+        default=None, strict=False, description="Vulnerabilities found in the 
SBOM"
+    )
+    atr_props: list[dict[str, str]] | None = schema.Field(
+        default=None, strict=False, description="ATR properties found in the 
SBOM"
+    )
     cli_errors: list[str] | None = schema.description("Errors from the 
CycloneDX CLI")
 
 
diff --git a/atr/sbom/cli.py b/atr/sbom/cli.py
index 4ef3bec..edc93fd 100644
--- a/atr/sbom/cli.py
+++ b/atr/sbom/cli.py
@@ -27,7 +27,7 @@ from .cyclonedx import validate_cli, validate_py
 from .licenses import check
 from .maven import plugin_outdated_version
 from .sbomqs import total_score
-from .utilities import bundle_to_patch, patch_to_data, path_to_bundle
+from .utilities import bundle_to_ntia_patch, bundle_to_vuln_patch, 
patch_to_data, path_to_bundle
 
 
 def command_license(bundle: models.bundle.Bundle) -> None:
@@ -54,7 +54,7 @@ def command_license(bundle: models.bundle.Bundle) -> None:
 
 
 def command_merge(bundle: models.bundle.Bundle) -> None:
-    patch_ops = asyncio.run(bundle_to_patch(bundle))
+    patch_ops = asyncio.run(bundle_to_ntia_patch(bundle))
     if patch_ops:
         patch_data = patch_to_data(patch_ops)
         merged = bundle.doc.patch(yyjson.Document(patch_data))
@@ -75,11 +75,11 @@ def command_osv(bundle: models.bundle.Bundle) -> None:
     if ignored_count > 0:
         print(f"Warning: {ignored_count} components ignored (missing purl or 
version)")
     for component_result in results:
-        print(component_result.purl)
+        print(component_result.ref)
         for vuln in component_result.vulnerabilities:
-            vuln_id = vuln.get("id", "unknown")
-            modified = vuln.get("modified", "")
-            summary = vuln.get("summary", "(no summary)")
+            vuln_id = vuln.id
+            modified = vuln.modified
+            summary = vuln.summary
             print(f"  {vuln_id} {modified} {summary}")
 
 
@@ -91,8 +91,18 @@ def command_outdated(bundle: models.bundle.Bundle) -> None:
         print("no outdated tool found")
 
 
-def command_patch(bundle: models.bundle.Bundle) -> None:
-    patch_ops = asyncio.run(bundle_to_patch(bundle))
+def command_patch_ntia(bundle: models.bundle.Bundle) -> None:
+    patch_ops = asyncio.run(bundle_to_ntia_patch(bundle))
+    if patch_ops:
+        patch_data = patch_to_data(patch_ops)
+        print(yyjson.Document(patch_data).dumps())
+    else:
+        print("no patch needed")
+
+
+def command_patch_vuln(bundle: models.bundle.Bundle) -> None:
+    results, _ = asyncio.run(osv.scan_bundle(bundle))
+    patch_ops = asyncio.run(bundle_to_vuln_patch(bundle, results))
     if patch_ops:
         patch_data = patch_to_data(patch_ops)
         print(yyjson.Document(patch_data).dumps())
@@ -101,7 +111,7 @@ def command_patch(bundle: models.bundle.Bundle) -> None:
 
 
 def command_scores(bundle: models.bundle.Bundle) -> None:
-    patch_ops = asyncio.run(bundle_to_patch(bundle))
+    patch_ops = asyncio.run(bundle_to_ntia_patch(bundle))
     if patch_ops:
         patch_data = patch_to_data(patch_ops)
         merged = bundle.doc.patch(yyjson.Document(patch_data))
@@ -153,6 +163,9 @@ def command_where(bundle: models.bundle.Bundle) -> None:
 
 
 def main() -> None:  # noqa: C901
+    if len(sys.argv) < 3:
+        print("Usage: python -m atr.sbom <command> <sbom-path>")
+        sys.exit(1)
     path = pathlib.Path(sys.argv[2])
     bundle = path_to_bundle(path)
     match sys.argv[1]:
@@ -166,8 +179,10 @@ def main() -> None:  # noqa: C901
             command_osv(bundle)
         case "outdated":
             command_outdated(bundle)
-        case "patch":
-            command_patch(bundle)
+        case "patch-ntia":
+            command_patch_ntia(bundle)
+        case "patch-vuln":
+            command_patch_vuln(bundle)
         case "scores":
             command_scores(bundle)
         case "validate-cli":
diff --git a/atr/sbom/models/bom.py b/atr/sbom/models/bom.py
index 60c872f..3bc65f9 100644
--- a/atr/sbom/models/bom.py
+++ b/atr/sbom/models/bom.py
@@ -28,7 +28,7 @@ class Swid(Lax):
 
 class Supplier(Lax):
     name: str | None = None
-    url: str | None = None
+    url: list[str] | None = None
 
 
 class License(Lax):
diff --git a/atr/sbom/models/osv.py b/atr/sbom/models/osv.py
index 30cf8aa..ab5c6e3 100644
--- a/atr/sbom/models/osv.py
+++ b/atr/sbom/models/osv.py
@@ -19,14 +19,44 @@ from __future__ import annotations
 
 from typing import Any
 
+import pydantic
+
 from .base import Lax
 
 
 class QueryResult(Lax):
-    vulns: list[dict[str, Any]] | None = None
+    vulns: list[VulnerabilityDetails] | None = None
     next_page_token: str | None = None
 
 
 class ComponentVulnerabilities(Lax):
-    purl: str
-    vulnerabilities: list[dict[str, Any]]
+    ref: str
+    vulnerabilities: list[VulnerabilityDetails]
+
+
+class VulnerabilityDetails(Lax):
+    id: str
+    summary: str | None = None
+    details: str | None = None
+    references: list[dict[str, Any]] | None = None
+    severity: list[dict[str, Any]] | None = None
+    published: str | None = None
+    modified: str
+    database_specific: dict[str, Any] = pydantic.Field(default={})
+
+
+class CdxVulnerabilityDetail(Lax):
+    bom_ref: str | None = pydantic.Field(default=None, alias="bom-ref")
+    id: str
+    source: dict[str, str] | None = None
+    description: str | None = None
+    detail: str | None = None
+    advisories: list[dict[str, str]] | None = None
+    cwes: list[int] | None = None
+    published: str | None = None
+    updated: str | None = None
+    affects: list[dict[str, str]] | None = None
+    ratings: list[dict[str, str | float]] | None = None
+
+
+CdxVulnAdapter = pydantic.TypeAdapter(CdxVulnerabilityDetail)
diff --git a/atr/sbom/osv.py b/atr/sbom/osv.py
index fc84892..7008ef9 100644
--- a/atr/sbom/osv.py
+++ b/atr/sbom/osv.py
@@ -18,14 +18,66 @@
 from __future__ import annotations
 
 import os
-from typing import Any
+from typing import TYPE_CHECKING, Any
 
 import aiohttp
 
 from . import models
+from .utilities import get_pointer, osv_severity_to_cdx
+
+if TYPE_CHECKING:
+    import yyjson
 
 _DEBUG: bool = os.environ.get("DEBUG_SBOM_TOOL") == "1"
 _OSV_API_BASE: str = "https://api.osv.dev/v1";
+_SOURCE_DATABASE_NAMES = {
+    "ASB": "Android Security Bulletin",
+    "PUB": "Android Security Bulletin",
+    "ALSA": "AlmaLinux Security Advisory",
+    "ALBA": "AlmaLinux Security Advisory",
+    "ALEA": "AlmaLinux Security Advisory",
+    "ALPINE": "Alpine Security Advisory",
+    "BELL": "BellSoft Security Advisory",
+    "BIT": "Bitnami Vulnerability Database",
+    "CGA": "Chainguard Security Notices",
+    "CURL": "Curl CVEs",
+    "CVE": "National Vulnerability Database",
+    "DEBIAN": "Debian Security Tracker",
+    "DSA": "Debian Security Advisory",
+    "DLA": "Debian Security Advisory",
+    "DTSA": "Debian Security Advisory",
+    "ECHO": "Echo Security Advisory",
+    "EEF": "Erlang Ecosystem Foundation CNA Vulnerabilities",
+    "ELA": "Debian Extended LTS Security Advisory",
+    "GHSA": "GitHub Security Advisory",
+    "GO": "Go Vulnerability Database",
+    "GSD": "Global Security Database",
+    "HSEC": "Haskell Security Advisory",
+    "JLSEC": "Julia Security Advisory",
+    "KUBE": "Kubernetes Official CVE Feed",
+    "LBSEC": "LoopBack Advisory Database",
+    "LSN": "Livepatch Security Notices",
+    "MGASA": "Mageia Security Advisory",
+    "MAL": "Malicious Packages Repository",
+    "MINI": "Minimus Security Notices",
+    "OESA": "openEuler Security Advisory",
+    "OSV": "OSV Advisory",
+    "PHSA": "VMWare Photon Security Advisory",
+    "PSF": "Python Software Foundation Vulnerability Database",
+    "PYSEC": "PyPI Vulnerability Database",
+    "RHSA": "Red Hat Security Data",
+    "RHBA": "Red Hat Security Data",
+    "RHEA": "Red Hat Security Data",
+    "RLSA": "Rocky Linux Security Advisory",
+    "RXSA": "Rocky Linux Security Advisory",
+    "RSEC": "RConsortium Advisory Database",
+    "RUSTSEC": "RustSec Advisory Database",
+    "SUSE": "SUSE Security Landing Page",
+    "openSUSE": "SUSE Security Landing Page",
+    "UBUNTU": "Ubuntu CVE Reports",
+    "USN": "Ubuntu Security Notices",
+    "V8": "V8/Chromium Time-Based Policy",
+}
 
 
 async def scan_bundle(bundle: models.bundle.Bundle) -> 
tuple[list[models.osv.ComponentVulnerabilities], list[str]]:
@@ -42,11 +94,76 @@ async def scan_bundle(bundle: models.bundle.Bundle) -> 
tuple[list[models.osv.Com
             print(f"[DEBUG] Total components with vulnerabilities: 
{len(component_vulns_map)}")
         await _scan_bundle_populate_vulnerabilities(session, 
component_vulns_map)
     result: list[models.osv.ComponentVulnerabilities] = []
-    for purl, vulns in component_vulns_map.items():
-        result.append(models.osv.ComponentVulnerabilities(purl=purl, 
vulnerabilities=vulns))
+    for ref, vulns in component_vulns_map.items():
+        result.append(models.osv.ComponentVulnerabilities(ref=ref, 
vulnerabilities=vulns))
     return result, ignored
 
 
+def vulns_from_bundle(bundle: models.bundle.Bundle) -> 
list[models.osv.CdxVulnerabilityDetail]:
+    vulns = get_pointer(bundle.doc, "/vulnerabilities")
+    if vulns is None:
+        return []
+    print(vulns)
+    return [models.osv.CdxVulnerabilityDetail.model_validate(v) for v in vulns]
+
+
+async def vuln_patch(
+    session: aiohttp.ClientSession,
+    doc: yyjson.Document,
+    components: list[models.osv.ComponentVulnerabilities],
+) -> models.patch.Patch:
+    patch_ops: models.patch.Patch = []
+    _assemble_vulnerabilities(doc, patch_ops)
+    ix = 0
+    for c in components:
+        for vuln in c.vulnerabilities:
+            _assemble_component_vulnerability(doc, patch_ops, c.ref, vuln, ix)
+            ix += 1
+    return patch_ops
+
+
+def _assemble_vulnerabilities(doc: yyjson.Document, patch_ops: 
models.patch.Patch) -> None:
+    if get_pointer(doc, "/vulnerabilities") is not None:
+        patch_ops.append(models.patch.RemoveOp(op="remove", 
path="/vulnerabilities"))
+    patch_ops.append(
+        models.patch.AddOp(
+            op="add",
+            path="/vulnerabilities",
+            value=[],
+        )
+    )
+
+
+def _assemble_component_vulnerability(
+    doc: yyjson.Document, patch_ops: models.patch.Patch, ref: str, vuln: 
models.osv.VulnerabilityDetails, index: int
+) -> None:
+    vulnerability = {
+        "bom-ref": f"vuln:{ref}/{vuln.id}",
+        "id": vuln.id,
+        "source": _get_source(vuln),
+        "description": vuln.summary,
+        "detail": vuln.details,
+        "cwes": [int(r.replace("CWE-", "")) for r in 
vuln.database_specific.get("cwe_ids", [])],
+        "published": vuln.published,
+        "updated": vuln.modified,
+        "affects": [{"ref": ref}],
+        "ratings": osv_severity_to_cdx(vuln.severity, 
vuln.database_specific.get("severity", "")),
+    }
+    if vuln.references is not None:
+        vulnerability["advisories"] = [
+            {"url": r["url"]}
+            for r in vuln.references
+            if (r.get("type", "") == "WEB" and "advisories" in r.get("url", 
"")) or r.get("type", "") == "ADVISORY"
+        ]
+    patch_ops.append(
+        models.patch.AddOp(
+            op="add",
+            path=f"/vulnerabilities/{index!s}",
+            value=vulnerability,
+        )
+    )
+
+
 def _component_purl_with_version(component: models.bom.Component) -> str | 
None:
     if component.purl is None:
         return None
@@ -90,7 +207,7 @@ async def _fetch_vulnerabilities_for_batch(
 async def _fetch_vulnerability_details(
     session: aiohttp.ClientSession,
     vuln_id: str,
-) -> dict[str, Any]:
+) -> models.osv.VulnerabilityDetails:
     if _DEBUG:
         print(f"[DEBUG] Fetching details for {vuln_id}")
     async with session.get(f"{_OSV_API_BASE}/vulns/{vuln_id}") as response:
@@ -98,12 +215,24 @@ async def _fetch_vulnerability_details(
         return await response.json()
 
 
+def _get_source(vuln: models.osv.VulnerabilityDetails) -> dict[str, str]:
+    db = vuln.id.split("-")[0]
+    web_refs = list(filter(lambda v: v.get("type", "") == "WEB", 
vuln.references)) if vuln.references else []
+    first_ref = web_refs[0] if len(web_refs) > 0 else None
+
+    name = _SOURCE_DATABASE_NAMES.get(db, "Unknown Database")
+    source = {"name": name}
+    if first_ref is not None:
+        source["url"] = first_ref.get("url", "")
+    return source
+
+
 async def _paginate_query(
     session: aiohttp.ClientSession,
     query: dict[str, Any],
     page_token: str,
-) -> list[dict[str, Any]]:
-    all_vulns: list[dict[str, Any]] = []
+) -> list[models.osv.VulnerabilityDetails]:
+    all_vulns: list[models.osv.VulnerabilityDetails] = []
     current_query = query.copy()
     current_query["page_token"] = page_token
     page = 0
@@ -135,7 +264,8 @@ def _scan_bundle_build_queries(
             ignored.append(component.name)
             continue
         query = {"package": {"purl": purl_with_version}}
-        queries.append((purl_with_version, query))
+        if component.bom_ref is not None:
+            queries.append((component.bom_ref, query))
     return queries, ignored
 
 
@@ -143,31 +273,31 @@ async def _scan_bundle_fetch_vulnerabilities(
     session: aiohttp.ClientSession,
     queries: list[tuple[str, dict[str, Any]]],
     batch_size: int,
-) -> dict[str, list[dict[str, Any]]]:
-    component_vulns_map: dict[str, list[dict[str, Any]]] = {}
+) -> dict[str, list[models.osv.VulnerabilityDetails]]:
+    component_vulns_map: dict[str, list[models.osv.VulnerabilityDetails]] = {}
     for batch_start in range(0, len(queries), batch_size):
         batch_end = min(batch_start + batch_size, len(queries))
         batch = queries[batch_start:batch_end]
         if _DEBUG:
             batch_num = batch_start // batch_size + 1
             print(f"[DEBUG] Processing batch {batch_num} ({batch_start + 
1}-{batch_end}/{len(queries)})")
-        batch_queries = [query for _purl, query in batch]
+        batch_queries = [query for _ref, query in batch]
         batch_results = await _fetch_vulnerabilities_for_batch(session, 
batch_queries)
         if _DEBUG and (len(batch_results) != len(batch)):
             print(f"[DEBUG] count mismatch (expected {len(batch)}, got 
{len(batch_results)})")
-        for i, (purl, query) in enumerate(batch):
+        for i, (ref, query) in enumerate(batch):
             if i >= len(batch_results):
                 break
             query_result = batch_results[i]
             if query_result.vulns:
-                existing_vulns = component_vulns_map.setdefault(purl, [])
+                existing_vulns = component_vulns_map.setdefault(ref, [])
                 existing_vulns.extend(query_result.vulns)
                 if _DEBUG:
-                    print(f"[DEBUG] {purl}: {len(query_result.vulns)} 
vulnerabilities")
+                    print(f"[DEBUG] {ref}: {len(query_result.vulns)} 
vulnerabilities")
             if query_result.next_page_token:
                 if _DEBUG:
-                    print(f"[DEBUG] {purl}: has pagination, fetching remaining 
pages")
-                existing_vulns = component_vulns_map.setdefault(purl, [])
+                    print(f"[DEBUG] {ref}: has pagination, fetching remaining 
pages")
+                existing_vulns = component_vulns_map.setdefault(ref, [])
                 paginated = await _paginate_query(session, query, 
query_result.next_page_token)
                 existing_vulns.extend(paginated)
     return component_vulns_map
@@ -175,19 +305,19 @@ async def _scan_bundle_fetch_vulnerabilities(
 
 async def _scan_bundle_populate_vulnerabilities(
     session: aiohttp.ClientSession,
-    component_vulns_map: dict[str, list[dict[str, Any]]],
+    component_vulns_map: dict[str, list[models.osv.VulnerabilityDetails]],
 ) -> None:
-    details_cache: dict[str, dict[str, Any]] = {}
+    details_cache: dict[str, models.osv.VulnerabilityDetails] = {}
     for vulns in component_vulns_map.values():
         for vuln in vulns:
-            vuln_id = vuln.get("id")
+            vuln_id = vuln.id
             if not vuln_id:
                 continue
             details = details_cache.get(vuln_id)
             if details is None:
                 details = await _fetch_vulnerability_details(session, vuln_id)
                 details_cache[vuln_id] = details
-            vuln.clear()
-            vuln.update(details)
+            vuln.__dict__.clear()
+            vuln.__dict__.update(details)
     if _DEBUG:
         print(f"[DEBUG] Fetched details for {len(details_cache)} unique 
vulnerabilities")
diff --git a/atr/sbom/utilities.py b/atr/sbom/utilities.py
index 496deb2..36a2cf5 100644
--- a/atr/sbom/utilities.py
+++ b/atr/sbom/utilities.py
@@ -17,18 +17,27 @@
 
 from __future__ import annotations
 
+import re
 from typing import TYPE_CHECKING, Any
 
+import cvss
+
 if TYPE_CHECKING:
     import pathlib
 
+    import atr.sbom.models.osv as osv
+
 import aiohttp
 import yyjson
 
 from . import models
 
+_SCORING_METHODS_OSV = {"CVSS_V2": "CVSSv2", "CVSS_V3": "CVSSv3", "CVSS_V4": 
"CVSSv4"}
+_SCORING_METHODS_CDX = {"CVSSv2": "CVSS_V2", "CVSSv3": "CVSS_V3", "CVSSv4": 
"CVSS_V4", "other": "Other"}
+_CDX_SEVERITIES = ["critical", "high", "medium", "low", "info", "none", 
"unknown"]
+
 
-async def bundle_to_patch(bundle_value: models.bundle.Bundle) -> 
models.patch.Patch:
+async def bundle_to_ntia_patch(bundle_value: models.bundle.Bundle) -> 
models.patch.Patch:
     from .conformance import ntia_2021_issues, ntia_2021_patch
 
     _warnings, errors = ntia_2021_issues(bundle_value.bom)
@@ -37,6 +46,17 @@ async def bundle_to_patch(bundle_value: 
models.bundle.Bundle) -> models.patch.Pa
     return patch_ops
 
 
+async def bundle_to_vuln_patch(
+    bundle_value: models.bundle.Bundle, vulnerabilities: 
list[osv.ComponentVulnerabilities]
+) -> models.patch.Patch:
+    from .osv import vuln_patch
+
+    # TODO: May not need session (copied from ntia patch)
+    async with aiohttp.ClientSession() as session:
+        patch_ops = await vuln_patch(session, bundle_value.doc, 
vulnerabilities)
+    return patch_ops
+
+
 def get_pointer(doc: yyjson.Document, path: str) -> Any | None:
     try:
         return doc.get_pointer(path)
@@ -47,6 +67,13 @@ def get_pointer(doc: yyjson.Document, path: str) -> Any | 
None:
         raise
 
 
+def get_atr_props_from_bundle(bundle_value: models.bundle.Bundle) -> 
list[dict[str, str]]:
+    properties: list[dict[str, str]] | None = get_pointer(bundle_value.doc, 
"/properties")
+    if properties is None:
+        return []
+    return [p for p in properties if "asf:atr:" in p.get("name", "")]
+
+
 def patch_to_data(patch_ops: models.patch.Patch) -> list[dict[str, Any]]:
     return [op.model_dump(by_alias=True, exclude_none=True) for op in 
patch_ops]
 
@@ -55,3 +82,85 @@ def path_to_bundle(path: pathlib.Path) -> 
models.bundle.Bundle:
     text = path.read_text(encoding="utf-8")
     bom = models.bom.Bom.model_validate_json(text)
     return models.bundle.Bundle(doc=yyjson.Document(text), bom=bom, path=path, 
text=text)
+
+
+def record_task(task: str, revision: str, doc: yyjson.Document, patch_ops: 
models.patch.Patch) -> models.patch.Patch:
+    properties: list[dict[str, str]] | None = get_pointer(doc, "/properties")
+    operation = {"name": f"asf:atr:{task}", "value": revision}
+    if properties is None:
+        patch_ops.append(models.patch.AddOp(op="add", path="/properties", 
value=[operation]))
+    else:
+        properties.append(operation)
+        patch_ops.append(models.patch.ReplaceOp(op="replace", 
path="/properties", value=properties))
+    return patch_ops
+
+
+def osv_severity_to_cdx(severity: list[dict[str, Any]] | None, textual: str) 
-> list[dict[str, str | float]] | None:
+    if severity is not None:
+        return [
+            {
+                "severity": _map_severity(textual),
+                "method": _SCORING_METHODS_OSV.get(s.get("type", ""), "other"),
+                **_extract_cdx_score(_SCORING_METHODS_OSV.get(s.get("type", 
""), "other"), s.get("score", "")),
+            }
+            for s in severity
+        ]
+    return None
+
+
+def cdx_severity_to_osv(severity: list[dict[str, str | float]]) -> tuple[str | 
None, list[dict[str, str]]]:
+    severities = [
+        {
+            "score": str(s.get("score", str(s.get("vector", "")))),
+            "type": _SCORING_METHODS_CDX.get(str(s.get("method", "other"))),
+        }
+        for s in severity
+    ]
+    textual = severity[0].get("severity")
+    return str(textual), severities
+
+
+def _extract_cdx_score(type: str, score_str: str) -> dict[str, str | float]:
+    if "CVSS" in score_str or "CVSS" in type:
+        components = re.match(r"CVSS:(?P<version>\d+\.?\d*)/.+", score_str)
+        parsed = None
+        vector = score_str
+        if components is None:
+            # CVSS2 doesn't include the version in the string, but we know 
this is a CVSS vector
+            parsed = cvss.CVSS2(vector)
+        else:
+            version = components.group("version")
+            if "3" in version or "V3" in type:
+                parsed = cvss.CVSS3(vector)
+            elif "4" in version or "V4" in type:
+                parsed = cvss.CVSS4(vector)
+        if parsed is not None:
+            # Pull a different score depending on which sections are filled out
+            scores = parsed.scores()
+            severities = parsed.severities()
+            score: float | None = next((s for s in reversed(scores) if s is 
not None), None)
+            severity = next((s for s in reversed(severities) if s is not 
None), "unknown")
+            result: dict[str, str | float] = {"vector": vector, "severity": 
_map_severity(severity)}
+            if score is not None:
+                result["score"] = score
+            return result
+        # Some vector that failed to parse
+        return {"vector": score_str}
+    else:
+        try:
+            # Maybe the score is just a numeric score
+            return {"score": float(score_str)}
+        except ValueError:
+            # If not, it must just be a string (eg. Ubuntu scoring system)
+            return {"severity": score_str}
+
+
+def _map_severity(severity: str) -> str:
+    sev = severity.lower()
+    if sev in _CDX_SEVERITIES:
+        return sev
+    else:
+        # Map known github values
+        if sev == "moderate":
+            return "medium"
+    return "unknown"
diff --git a/atr/tasks/sbom.py b/atr/tasks/sbom.py
index e837a87..d07dd0d 100644
--- a/atr/tasks/sbom.py
+++ b/atr/tasks/sbom.py
@@ -85,9 +85,10 @@ async def augment(args: FileArgs) -> results.Results | None:
         raise SBOMScoringError("SBOM file does not exist", {"file_path": 
args.file_path})
     # Read from the old revision
     bundle = sbom.utilities.path_to_bundle(pathlib.Path(full_path))
-    patch_ops = await sbom.utilities.bundle_to_patch(bundle)
+    patch_ops = await sbom.utilities.bundle_to_ntia_patch(bundle)
     new_full_path: str | None = None
     if patch_ops:
+        sbom.utilities.record_task("augment", args.revision_number, 
bundle.doc, patch_ops)
         patch_data = sbom.utilities.patch_to_data(patch_ops)
         merged = bundle.doc.patch(yyjson.Document(patch_data))
         description = "SBOM augmentation through web interface"
@@ -140,13 +141,37 @@ async def osv_scan(args: FileArgs) -> results.Results | 
None:
         raise SBOMScanningError("SBOM file does not exist", {"file_path": 
args.file_path})
     bundle = sbom.utilities.path_to_bundle(pathlib.Path(full_path))
     vulnerabilities, ignored = await sbom.osv.scan_bundle(bundle)
-    components = [results.OSVComponent(purl=v.purl, 
vulnerabilities=v.vulnerabilities) for v in vulnerabilities]
+    patch_ops = await sbom.utilities.bundle_to_vuln_patch(bundle, 
vulnerabilities)
+    components = [results.OSVComponent(purl=v.ref, 
vulnerabilities=v.vulnerabilities) for v in vulnerabilities]
+
+    new_full_path: str | None = None
+    if patch_ops:
+        sbom.utilities.record_task("osv-scan", args.revision_number, 
bundle.doc, patch_ops)
+        patch_data = sbom.utilities.patch_to_data(patch_ops)
+        merged = bundle.doc.patch(yyjson.Document(patch_data))
+        description = "SBOM vulnerability scan through web interface"
+        async with storage.write(args.asf_uid) as write:
+            wacp = await 
write.as_project_committee_participant(args.project_name)
+            async with wacp.revision.create_and_manage(
+                args.project_name, args.version_name, args.asf_uid or 
"unknown", description=description
+            ) as creating:
+                new_full_path = os.path.join(str(creating.interim_path), 
args.file_path)
+                # Write to the new revision
+                log.info(f"Writing updated SBOM to {new_full_path}")
+                await aiofiles.os.remove(new_full_path)
+                async with aiofiles.open(new_full_path, "w", encoding="utf-8") 
as f:
+                    await f.write(merged.dumps())
+
+            if creating.new is None:
+                raise RuntimeError("Internal error: New revision not found")
+
     return results.SBOMOSVScan(
         kind="sbom_osv_scan",
         project_name=args.project_name,
         version_name=args.version_name,
         revision_number=args.revision_number,
-        file_path=args.file_path,
+        file_path=full_path,
+        new_file_path=new_full_path or full_path,
         components=components,
         ignored=ignored,
     )
@@ -196,8 +221,10 @@ async def score_tool(args: FileArgs) -> results.Results | 
None:
     if not (full_path.endswith(".cdx.json") and os.path.isfile(full_path)):
         raise SBOMScoringError("SBOM file does not exist", {"file_path": 
args.file_path})
     bundle = sbom.utilities.path_to_bundle(pathlib.Path(full_path))
+    properties = sbom.utilities.get_atr_props_from_bundle(bundle)
     warnings, errors = sbom.conformance.ntia_2021_issues(bundle.bom)
     outdated = sbom.maven.plugin_outdated_version(bundle.bom)
+    vulnerabilities = sbom.osv.vulns_from_bundle(bundle)
     cli_errors = sbom.cyclonedx.validate_cli(bundle)
     return results.SBOMToolScore(
         kind="sbom_tool_score",
@@ -208,6 +235,8 @@ async def score_tool(args: FileArgs) -> results.Results | 
None:
         warnings=[w.model_dump_json() for w in warnings],
         errors=[e.model_dump_json() for e in errors],
         outdated=outdated.model_dump_json() if outdated else None,
+        vulnerabilities=[v.model_dump_json() for v in vulnerabilities],
+        atr_props=properties,
         cli_errors=cli_errors,
     )
 
diff --git a/atr/templates/check-selected-path-table.html 
b/atr/templates/check-selected-path-table.html
index 1c8a81d..0c8b101 100644
--- a/atr/templates/check-selected-path-table.html
+++ b/atr/templates/check-selected-path-table.html
@@ -67,7 +67,7 @@
               {% endif %}
               {% if path.suffixes[-2:] == [".cdx", ".json"] %}
                 <a href="{{ as_url(get.sbom.report, project=project_name, 
version=version_name, file_path=path) }}"
-                   class="btn btn-sm btn-outline-secondary">Show SBOM</a>
+                   class="btn btn-sm btn-outline-secondary">SBOM report</a>
               {% endif %}
               {% if has_errors %}
                 <a href="{{ as_url(get.report.selected_path, 
project_name=project_name, version_name=version_name, rel_path=path) }}"
diff --git a/docker-compose.yml b/docker-compose.yml
index 6baa6f5..64e7da5 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -9,6 +9,8 @@ services:
       - BIND=0.0.0.0:8080
       - SECRET_KEY=insecure-test-key
       - SSH_HOST=0.0.0.0
+      - LDAP_BIND_DN=$LDAP_BIND_DN
+      - LDAP_BIND_PASSWORD=$LDAP_BIND_PASSWORD
     networks:
       - atr-network
     volumes:
diff --git a/pyproject.toml b/pyproject.toml
index 686190b..6c41378 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -21,6 +21,7 @@ dependencies = [
   "blockbuster>=1.5.23,<2.0.0",
   "cmarkgfm>=2024.11.20",
   "cryptography~=44.0",
+  "cvss~=3.6",
   "cyclonedx-python-lib[json-validation]>=11.0.0",
   # "dkimpy @ git+https://github.com/sbp/dkimpy.git@main";,
   "dnspython>=2.7.0,<3.0.0",
diff --git a/uv.lock b/uv.lock
index 1ee4d34..be40bd1 100644
--- a/uv.lock
+++ b/uv.lock
@@ -395,6 +395,15 @@ wheels = [
     { url = 
"https://files.pythonhosted.org/packages/63/51/ef6c5628e46092f0a54c7cee69acc827adc6b6aab57b55d344fefbdf28f1/cssbeautifier-1.15.4-py3-none-any.whl";,
 hash = 
"sha256:78c84d5e5378df7d08622bbd0477a1abdbd209680e95480bf22f12d5701efc98", size 
= 123667, upload-time = "2025-02-27T17:53:43.594Z" },
 ]
 
+[[package]]
+name = "cvss"
+version = "3.6"
+source = { registry = "https://pypi.org/simple"; }
+sdist = { url = 
"https://files.pythonhosted.org/packages/a5/f6/1f7d315de23f39bbc32b94bb6b33a1b6124856037bfaa3f8bdb1a49584fa/cvss-3.6.tar.gz";,
 hash = 
"sha256:f21d18224efcd3c01b44ff1b37dec2e3208d29a6d0ce6c87a599c73c21ee1a99", size 
= 30047, upload-time = "2025-08-04T10:50:13.753Z" }
+wheels = [
+    { url = 
"https://files.pythonhosted.org/packages/63/6d/fe2c65b94a28ae0481dc254e8cd664b82390069003bea945076d8a445f2b/cvss-3.6-py2.py3-none-any.whl";,
 hash = 
"sha256:e342c6ad9c7eb69d2aebbbc2768a03cabd57eb947c806e145de5b936219833ea", size 
= 31154, upload-time = "2025-08-04T10:50:12.328Z" },
+]
+
 [[package]]
 name = "cyclonedx-python-lib"
 version = "11.6.0"
@@ -1763,6 +1772,7 @@ dependencies = [
     { name = "blockbuster" },
     { name = "cmarkgfm" },
     { name = "cryptography" },
+    { name = "cvss" },
     { name = "cyclonedx-python-lib", extra = ["json-validation"] },
     { name = "dnspython" },
     { name = "dunamai" },
@@ -1819,6 +1829,7 @@ requires-dist = [
     { name = "blockbuster", specifier = ">=1.5.23,<2.0.0" },
     { name = "cmarkgfm", specifier = ">=2024.11.20" },
     { name = "cryptography", specifier = "~=44.0" },
+    { name = "cvss", specifier = "~=3.6" },
     { name = "cyclonedx-python-lib", extras = ["json-validation"], specifier = 
">=11.0.0" },
     { name = "dnspython", specifier = ">=2.7.0,<3.0.0" },
     { name = "dunamai", specifier = ">=1.23.0" },


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to