diff --git a/.github/workflows/Continuous-Testing.yaml b/.github/workflows/Continuous-Testing.yaml index 61ae5a95..b4279543 100644 --- a/.github/workflows/Continuous-Testing.yaml +++ b/.github/workflows/Continuous-Testing.yaml @@ -37,9 +37,10 @@ jobs: strategy: fail-fast: false matrix: ${{ fromJSON(needs.prepare-test-matrix.outputs.released-revisions-matrix) }} - uses: canonical/oci-factory/.github/workflows/Vulnerability-Scan.yaml@main + uses: ./.github/workflows/Vulnerability-Scan.yaml with: oci-image-name: "${{ matrix.source-image }}" oci-image-path: "oci/${{ matrix.name }}" date-last-scan: ${{ needs.prepare-test-matrix.outputs.last-scan }} + is-from-release: true secrets: inherit diff --git a/.github/workflows/Vulnerability-Scan.yaml b/.github/workflows/Vulnerability-Scan.yaml index 9ed19bff..31be52cc 100644 --- a/.github/workflows/Vulnerability-Scan.yaml +++ b/.github/workflows/Vulnerability-Scan.yaml @@ -16,7 +16,12 @@ on: description: "If there are new CVEs after this date, we notify" required: false type: string - default: "9999-12-31T23:59:59" + default: '9999-12-31T23:59:59' + create-issue: + description: 'If to create a GitHub issues for found vulnerabilities' + required: false + type: boolean + default: false env: VULNERABILITY_REPORT_SUFFIX: ".vulnerability-report.json" # TODO: inherit string from caller @@ -155,17 +160,25 @@ jobs: issue: name: "issue ${{ inputs.oci-image-name != '' && format('| {0}', inputs.oci-image-name) || ' '}}" runs-on: ubuntu-22.04 - needs: [parse-results] + needs: [parse-results, test-vulnerabilities] env: GITHUB_TOKEN: ${{ secrets.ROCKSBOT_TOKEN }} if: ${{ !cancelled() && github.event_name != 'pull_request' }} steps: - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.x' + + - run: pip install pydantic==2.8.2 - id: simplify-image-name run: | - img_name=$(echo "${{ inputs.oci-image-name }}" | sed -r 's|.*/([a-zA-Z0-9-]+:[0-9.-]+)_[0-9]+|\1|') - echo "img_name=$img_name" >> "$GITHUB_OUTPUT" + img_name_with_tag=$(echo "${{ inputs.oci-image-name }}" | sed -r 's|.*/([a-zA-Z0-9-]+:[0-9.-]+_[0-9])+|\1|') + img_revision=$(echo "${img_name_with_tag}" | cut -d '_' -f 2) + echo "img_revision=$img_revision" >> "$GITHUB_OUTPUT" + echo "img_name_with_tag=$img_name_with_tag" >> "$GITHUB_OUTPUT" # We assume that the sources within image.yaml are the same - name: Get image repo @@ -175,22 +188,38 @@ jobs: echo "img-repo=$img_repo" >> "$GITHUB_OUTPUT" # We have to walk through the vulnerabilities since trivy does not support outputting the results as Markdown - - name: Create Markdown Content + - name: Create markdown content id: create-markdown run: | set -x - title="Vulnerabilities found for ${{ steps.simplify-image-name.outputs.img_name }}" - echo "## $title" > issue.md - echo "| ID | Target | Severity | Package |" >> issue.md - echo "| -- | ----- | -------- | ------- |" >> issue.md - echo '${{ needs.parse-results.outputs.vulnerabilities }}' | jq -r '.[] | "| \(.VulnerabilityID) | /\(.Target) | \(.Severity) | \(.PkgName) |"' >> issue.md - echo -e "\nDetails: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" >> issue.md num_vulns=$(echo '${{ needs.parse-results.outputs.vulnerabilities }}' | jq -r 'length') - echo "issue-title=$title" >> "$GITHUB_OUTPUT" - echo "issue-body-file=issue.md" >> "$GITHUB_OUTPUT" - echo "vulnerability-exists=$([[ $num_vulns -gt 0 ]] && echo 'true' || echo 'false')" >> "$GITHUB_OUTPUT" + vulnerability_exists=$([[ $num_vulns -gt 0 ]] && echo 'true' || echo 'false') + echo "vulnerability-exists=$vulnerability_exists" >> "$GITHUB_OUTPUT" + if [[ $vulnerability_exists == 'true' ]]; then + title="Vulnerabilities found for ${{ steps.simplify-image-name.outputs.img_name_with_tag }}" + echo "## $title" > issue.md + echo "| ID | Target | Severity | Package |" >> issue.md + echo "| -- | ----- | -------- | ------- |" >> issue.md + echo '${{ needs.parse-results.outputs.vulnerabilities }}' | jq -r '.[] | "| \(.VulnerabilityID) | /\(.Target) | \(.Severity) | \(.PkgName) |"' >> issue.md + if [[ ${{ inputs.create-issue }} == 'true' ]]; then + revision_to_released_tags=$(python3 -m src.shared.release_info get_revision_to_released_tags --all-releases ${{ inputs.oci-image-path }}/_releases.json) + affected_tracks=$(echo "${revision_to_released_tags}" | jq -r '."${{ steps.simplify-image-name.outputs.img_revision }}" | map("- `\(.)`") | join("\n")') + echo -e "\n### Affected tracks:" >> issue.md + echo -e "${affected_tracks}" >> issue.md + echo -e "\nDetails: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" >> issue.md + fi + echo "issue-title=$title" >> "$GITHUB_OUTPUT" + echo "issue-body-file=issue.md" >> "$GITHUB_OUTPUT" + fi + + - name: Write to summary + if: ${{ !inputs.create-issue && steps.create-markdown.outputs.vulnerability-exists == 'true' }} + run: | + echo "# Vulnerabilities found for ${{ inputs.oci-image-name }}" >> $GITHUB_STEP_SUMMARY + cat ${{ steps.create-markdown.outputs.issue-body-file }} | tail -n +2 >> $GITHUB_STEP_SUMMARY - id: issue-exists + if: ${{ inputs.create-issue}} run: | issue_number=$(gh issue list --repo ${{ steps.get-image-repo.outputs.img-repo }} --json "number,title" \ | jq -r '.[] | select(.title == "${{ steps.create-markdown.outputs.issue-title }}") | .number') @@ -210,7 +239,7 @@ jobs: # | F | F | F | nop | - name: Notify via GitHub issue - if: ${{ steps.create-markdown.outputs.vulnerability-exists == 'true' }} + if: ${{ steps.create-markdown.outputs.vulnerability-exists == 'true' && inputs.create-issue }} run: | set -x op=nop @@ -222,11 +251,15 @@ jobs: fi if [[ $op != 'nop' ]]; then gh issue $op --repo ${{ steps.get-image-repo.outputs.img-repo }} \ - --title "Vulnerabilities found for ${{ steps.simplify-image-name.outputs.img_name }}" \ + --title "${{ steps.create-markdown.outputs.issue-title }}" \ --body-file "${{ steps.create-markdown.outputs.issue-body-file }}" fi - name: Close issue - if: ${{ needs.parse-results.result == 'success' && steps.issue-exists.outputs.issue-exists == 'true' && steps.create-markdown.outputs.vulnerability-exists == 'false' }} + if: | + needs.test-vulnerabilities.result == 'success' && + steps.issue-exists.outputs.issue-exists == 'true' && + steps.create-markdown.outputs.vulnerability-exists == 'false' && + inputs.create-issue run: | gh issue close ${{ steps.issue-exists.outputs.issue-number }} --repo ${{ steps.get-image-repo.outputs.img-repo }} diff --git a/oci/mock-rock/_releases.json b/oci/mock-rock/_releases.json index eb28c2c0..3c061c0c 100644 --- a/oci/mock-rock/_releases.json +++ b/oci/mock-rock/_releases.json @@ -35,31 +35,31 @@ "1.1-22.04": { "end-of-life": "2030-05-01T00:00:00Z", "candidate": { - "target": "853" + "target": "885" }, "beta": { - "target": "853" + "target": "885" }, "edge": { - "target": "853" + "target": "885" } }, "1-22.04": { "end-of-life": "2030-05-01T00:00:00Z", "candidate": { - "target": "853" + "target": "885" }, "beta": { - "target": "853" + "target": "885" }, "edge": { - "target": "853" + "target": "885" } }, "1.2-22.04": { "end-of-life": "2030-05-01T00:00:00Z", "beta": { - "target": "854" + "target": "886" }, "edge": { "target": "1.2-22.04_beta" diff --git a/src/shared/release_info.py b/src/shared/release_info.py index d07a06f6..a3b82820 100644 --- a/src/shared/release_info.py +++ b/src/shared/release_info.py @@ -5,7 +5,10 @@ data related to _release.json and revision tags. """ +import argparse import json +from collections import defaultdict + from ..image.utils.schema.triggers import KNOWN_RISKS_ORDERED @@ -82,3 +85,65 @@ def get_revision_to_track(all_revisions_tags: list) -> dict: revision_track[revision] = track return revision_track + + +def _find_alias_revision(tag_mapping_from_all_releases: dict, rev: str, visited: set, tag: str) -> str: + if rev in visited: + raise BadChannel( + f"Tag {tag} was caught in a circular dependency, " + "following tags that follow themselves. Cannot pin a revision." + ) + visited.add(rev) + if not rev.isdigit(): + return _find_alias_revision( + tag_mapping_from_all_releases, tag_mapping_from_all_releases[rev], visited, tag + ) + return rev + +def get_revision_to_released_tags(all_releases: dict) -> dict: + """ + Iterates over the provided dictionary with all the releases + and extracts the revision numbers and their corresponding + released tags. The resulting dictionary maps each revision + number to a list of released tags. + """ + revision_to_released_tags = defaultdict(list) + tag_mapping_from_all_releases = get_tag_mapping_from_all_releases(all_releases) + for tag, revision in tag_mapping_from_all_releases.items(): + if not revision.isdigit(): + visited = set() + revision = _find_alias_revision(tag_mapping_from_all_releases, revision, visited, tag) + revision = int(revision) + revision_to_released_tags[revision].append(tag) + + for revision, tags in revision_to_released_tags.items(): + revision_to_released_tags[revision] = sorted(tags) + + return dict(revision_to_released_tags) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument( + "function", + help="The function to run", + choices=["get_revision_to_released_tags"], + ) + + parser.add_argument( + "--all-releases", + help="Path to the _releases.json file", + ) + + args = parser.parse_args() + + if args.function == "get_revision_to_released_tags": + print( + json.dumps( + (get_revision_to_released_tags(read_json_file(args.all_releases))) + ) + ) + + +if __name__ == "__main__": + main() diff --git a/tests/unit/test_shared_release_info.py b/tests/unit/test_shared_release_info.py new file mode 100644 index 00000000..8c6da6ff --- /dev/null +++ b/tests/unit/test_shared_release_info.py @@ -0,0 +1,63 @@ +import pytest + +from src.shared.release_info import * + + +def test_get_revision_to_release_plain(): + all_releases = { + "3.8-20.04": { + "end-of-life": "2025-03-31T00:00:00Z", + "edge": {"target": "43"}, + "stable": {"target": "43"}, + "candidate": {"target": "43"}, + "beta": {"target": "43"}, + } + } + assert get_revision_to_released_tags(all_releases) == { + 43: [ + "3.8-20.04_beta", + "3.8-20.04_candidate", + "3.8-20.04_edge", + "3.8-20.04_stable", + ] + } + + +def test_get_revision_to_release_circular(): + all_releases = { + "1.19.0-22.04": { + "end-of-life": "2024-11-26T00:00:00Z", + "stable": {"target": "1"}, + "candidate": {"target": "1.19.0-22.04_beta"}, + "beta": {"target": "1.19.0-22.04_candidate"}, + "edge": {"target": "5"}, + } + } + + with pytest.raises(BadChannel, match=r"Tag .* was caught in a circular dependency, following tags that follow themselves. Cannot pin a revision."): + get_revision_to_released_tags(all_releases) + + +def test_get_revision_to_release_alias(): + all_releases = { + "1.19.0-22.04": { + "end-of-life": "2024-11-26T00:00:00Z", + "stable": {"target": "1"}, + "candidate": {"target": "5"}, + "beta": {"target": "1.19.0-22.04_candidate"}, + "edge": {"target": "5"}, + }, + "1-22.04": { + "end-of-life": "2025-05-12T00:00:00Z", + "stable": {"target": "4"}, + "candidate": {"target": "1-22.04_stable"}, + "beta": {"target": "1-22.04_candidate"}, + "edge": {"target": "1-22.04_beta"}, + }, + } + + assert get_revision_to_released_tags(all_releases) == { + 1: ["1.19.0-22.04_stable"], + 4: ["1-22.04_beta", "1-22.04_candidate", "1-22.04_edge", "1-22.04_stable"], + 5: ["1.19.0-22.04_beta", "1.19.0-22.04_candidate", "1.19.0-22.04_edge"], + }