diff --git a/.github/workflows/license-db.yaml b/.github/workflows/license-db.yaml index a67adf9..d4fa5bc 100644 --- a/.github/workflows/license-db.yaml +++ b/.github/workflows/license-db.yaml @@ -79,6 +79,13 @@ jobs: version: "22.04" - distro: ubuntu version: "24.04" + # Debian (DEB) + - distro: debian + version: "11" + - distro: debian + version: "12" + - distro: debian + version: "13" steps: - name: Checkout code @@ -93,16 +100,11 @@ jobs: run: uv sync --locked - name: Generate ${{ matrix.distro }}-${{ matrix.version }} database - # License database generation involves downloading and parsing package metadata - # from distro repositories. Some distros (e.g., Ubuntu, Fedora) have very large - # package sets that can take 30-60+ minutes to process. The 120-minute timeout - # provides headroom for slower CI runners and network conditions. run: | uv run sbomify-license-db \ --distro ${{ matrix.distro }} \ --version ${{ matrix.version }} \ --output ${{ matrix.distro }}-${{ matrix.version }}.json.gz - timeout-minutes: 120 - name: Upload artifact uses: actions/upload-artifact@v4 @@ -111,40 +113,22 @@ jobs: path: ${{ matrix.distro }}-${{ matrix.version }}.json.gz retention-days: 7 - upload-to-release: - name: Upload to Release - runs-on: ubuntu-latest - needs: generate-databases - if: github.event_name == 'release' - permissions: - contents: write - steps: - - name: Download all artifacts - uses: actions/download-artifact@v4 - with: - path: databases - pattern: license-db-* - merge-multiple: true - - - name: List downloaded files - run: ls -la databases/ + - name: Get release tag + id: get_tag + run: | + if [ "${{ github.event_name }}" = "release" ]; then + echo "tag=${{ github.event.release.tag_name }}" >> $GITHUB_OUTPUT + else + # For workflow_dispatch, get the latest release tag + LATEST_TAG=$(curl -s https://api.github.com/repos/${{ github.repository }}/releases/latest | jq -r .tag_name) + echo "tag=$LATEST_TAG" >> $GITHUB_OUTPUT + fi + echo "Uploading to release: $(cat $GITHUB_OUTPUT | grep tag | cut -d= -f2)" - name: Upload to release uses: softprops/action-gh-release@v2 with: - files: databases/*.json.gz - tag_name: ${{ github.event.release.tag_name }} + files: ${{ matrix.distro }}-${{ matrix.version }}.json.gz + tag_name: ${{ steps.get_tag.outputs.tag }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Summary - run: | - echo "## License Databases Uploaded" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "The following license databases were uploaded to release ${{ github.event.release.tag_name }}:" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - for f in databases/*.json.gz; do - size=$(du -h "$f" | cut -f1) - name=$(basename "$f") - echo "- \`$name\` ($size)" >> $GITHUB_STEP_SUMMARY - done diff --git a/.github/workflows/sbomify.yaml b/.github/workflows/sbomify.yaml index 6222a94..5f107c7 100644 --- a/.github/workflows/sbomify.yaml +++ b/.github/workflows/sbomify.yaml @@ -291,58 +291,36 @@ jobs: exit 1 fi - - name: Verify CLE lifecycle events (CycloneDX) + - name: Verify OS component CLE lifecycle (CycloneDX) run: | - echo "=== Verifying CLE Lifecycle Events in CycloneDX ===" - - # Check for CLE properties (ECMA-428 standard) - # See: https://sbomify.com/compliance/cle/ - CLE_EOS_COUNT=$(jq '[.components[].properties[]? | select(.name == "cle:eos")] | length' alpine-enriched.cdx.json) - CLE_EOL_COUNT=$(jq '[.components[].properties[]? | select(.name == "cle:eol")] | length' alpine-enriched.cdx.json) - - echo "CycloneDX components with cle:eos: $CLE_EOS_COUNT" - echo "CycloneDX components with cle:eol: $CLE_EOL_COUNT" - - # Alpine 3.20 should have CLE dates set - if [ "$CLE_EOS_COUNT" -ge 10 ]; then - echo "✅ CycloneDX CLE end-of-support dates are being set" + echo "=== Verifying OS Component CLE Lifecycle in CycloneDX ===" + + # CLE properties are now only added to: + # 1. OS components (type: operating-system) - like the Alpine base image + # 2. Tracked runtimes/frameworks (Python, PHP, Go, etc.) - not present in plain Alpine + # + # Arbitrary OS packages (curl, busybox, etc.) do NOT get distro lifecycle + # because the PURL doesn't reliably indicate distro version. + + # Check OS component has CLE properties + OS_CLE=$(jq '.components[] | select(.type == "operating-system") | .properties[]? | select(.name | startswith("cle:"))' alpine-enriched.cdx.json) + + if [ -n "$OS_CLE" ]; then + echo "✅ OS component has CLE lifecycle properties" + echo "OS component CLE:" + jq '.components[] | select(.type == "operating-system") | {name, version, cle: [.properties[]? | select(.name | startswith("cle:"))]}' alpine-enriched.cdx.json else - echo "❌ Expected CLE end-of-support dates on CycloneDX components" + echo "❌ Expected CLE properties on OS component" exit 1 fi - if [ "$CLE_EOL_COUNT" -ge 10 ]; then - echo "✅ CycloneDX CLE end-of-life dates are being set" + # Verify specific CLE values for Alpine 3.20 + CLE_EOL=$(jq -r '.components[] | select(.type == "operating-system") | .properties[]? | select(.name == "cle:eol") | .value' alpine-enriched.cdx.json) + if [ "$CLE_EOL" = "2026-04-01" ]; then + echo "✅ Alpine 3.20 EOL date is correct: $CLE_EOL" else - echo "❌ Expected CLE end-of-life dates on CycloneDX components" - exit 1 - fi - - # Show sample CLE data - echo "Sample CycloneDX CLE properties:" - jq '.components[0].properties[]? | select(.name | startswith("cle:"))' alpine-enriched.cdx.json || true - - - name: Verify CLE lifecycle events (SPDX) - run: | - echo "=== Verifying CLE Lifecycle Events in SPDX ===" - - # For SPDX, CLE data is added to the package comment field - # Check for packages with CLE lifecycle info in comments - CLE_COMMENT_COUNT=$(jq '[.packages[] | select(.comment != null and (.comment | contains("CLE lifecycle")))] | length' alpine-enriched.spdx.json) - - echo "SPDX packages with CLE lifecycle comments: $CLE_COMMENT_COUNT" - - # Alpine 3.20 should have CLE dates in comments - if [ "$CLE_COMMENT_COUNT" -ge 10 ]; then - echo "✅ SPDX CLE lifecycle data is being set in comments" - else - echo "❌ Expected CLE lifecycle data in SPDX package comments" - exit 1 + echo "⚠️ Alpine 3.20 EOL date: $CLE_EOL (expected 2026-04-01)" fi - - # Show sample CLE data - echo "Sample SPDX CLE comment:" - jq -r '.packages[] | select(.comment != null and (.comment | contains("CLE"))) | .comment' alpine-enriched.spdx.json | head -3 || true - name: Verify enrichment adds publisher (CycloneDX) run: | @@ -405,6 +383,121 @@ jobs: alpine-enriched.spdx.json retention-days: 7 + cle-integration-test: + name: CLE Lifecycle Integration Test + runs-on: ubuntu-latest + steps: + - name: Code Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Install UV + run: | + curl -LsSf https://astral.sh/uv/install.sh | sh + echo "$HOME/.local/bin" >> $GITHUB_PATH + + - name: Install dependencies + run: uv sync --locked --dev + + - name: Install Trivy + run: | + curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin + + - name: Generate and enrich Debian SBOM (CycloneDX) + run: | + # Use Debian image to test OS component CLE enrichment + uv run sbomify-action + env: + TOKEN: placeholder + COMPONENT_ID: placeholder + DOCKER_IMAGE: debian:12-slim + SBOM_FORMAT: cyclonedx + OUTPUT_FILE: debian-enriched.cdx.json + ENRICH: "true" + UPLOAD: "false" + + - name: Verify OS component CLE (Debian 12) + run: | + echo "=== Verifying OS Component CLE ===" + + # Verify OS component has CLE properties + OS_COMPONENT=$(jq '.components[] | select(.type == "operating-system")' debian-enriched.cdx.json) + + if [ -n "$OS_COMPONENT" ]; then + echo "OS component found:" + jq '.components[] | select(.type == "operating-system") | {name, version, publisher}' debian-enriched.cdx.json + + # Check for CLE properties + CLE_EOL=$(jq -r '.components[] | select(.type == "operating-system") | .properties[]? | select(.name == "cle:eol") | .value' debian-enriched.cdx.json) + CLE_EOS=$(jq -r '.components[] | select(.type == "operating-system") | .properties[]? | select(.name == "cle:eos") | .value' debian-enriched.cdx.json) + CLE_RELEASE=$(jq -r '.components[] | select(.type == "operating-system") | .properties[]? | select(.name == "cle:releaseDate") | .value' debian-enriched.cdx.json) + + echo "CLE Release Date: $CLE_RELEASE" + echo "CLE End of Support: $CLE_EOS" + echo "CLE End of Life: $CLE_EOL" + + if [ -n "$CLE_EOL" ] && [ -n "$CLE_EOS" ]; then + echo "✅ OS component has CLE lifecycle properties" + else + echo "❌ OS component missing CLE properties" + exit 1 + fi + else + echo "❌ No OS component found" + exit 1 + fi + + - name: Verify Debian 12 CLE values + run: | + echo "=== Verifying Debian 12 CLE Values ===" + + # Debian 12 EOL should be 2028-06-30 (LTS end) + CLE_EOL=$(jq -r '.components[] | select(.type == "operating-system") | .properties[]? | select(.name == "cle:eol") | .value' debian-enriched.cdx.json) + + if [ "$CLE_EOL" = "2028-06-30" ]; then + echo "✅ Debian 12 EOL date is correct: $CLE_EOL" + else + echo "❌ Debian 12 EOL date incorrect: $CLE_EOL (expected 2028-06-30)" + exit 1 + fi + + # Debian 12 EOS should be 2026-06-10 (regular support end) + CLE_EOS=$(jq -r '.components[] | select(.type == "operating-system") | .properties[]? | select(.name == "cle:eos") | .value' debian-enriched.cdx.json) + + if [ "$CLE_EOS" = "2026-06-10" ]; then + echo "✅ Debian 12 EOS date is correct: $CLE_EOS" + else + echo "❌ Debian 12 EOS date incorrect: $CLE_EOS (expected 2026-06-10)" + exit 1 + fi + + - name: Verify publisher enrichment + run: | + echo "=== Verifying Publisher Enrichment ===" + + PUBLISHER=$(jq -r '.components[] | select(.type == "operating-system") | .publisher' debian-enriched.cdx.json) + + if [ "$PUBLISHER" = "Debian Project" ]; then + echo "✅ OS component publisher is correct: $PUBLISHER" + else + echo "❌ OS component publisher incorrect: $PUBLISHER (expected 'Debian Project')" + exit 1 + fi + + # Show all CLE-enriched components summary + echo "" + echo "=== All CLE-enriched components ===" + jq -r '.components[] | select(.properties[]?.name | startswith("cle:")) | "\(.type // "library"): \(.name) v\(.version)"' debian-enriched.cdx.json | sort -u + + - name: Upload enriched SBOM artifact + uses: actions/upload-artifact@v4 + if: always() + with: + name: debian-cle-enriched-sbom + path: debian-enriched.cdx.json + retention-days: 7 + augmentation-integration-test: name: Augmentation Integration Test runs-on: ubuntu-latest @@ -917,6 +1010,7 @@ jobs: - integration-tests - container-tests - license-db-integration-test + - cle-integration-test - augmentation-integration-test if: github.event_name == 'push' permissions: @@ -987,264 +1081,256 @@ jobs: echo "Use $DOCKER_HUB_IMAGE_ID:$VERSION outside of GitHub" >> ${GITHUB_STEP_SUMMARY} echo \`\`\` >> ${GITHUB_STEP_SUMMARY} - upload-sbom: - name: Upload SBOM - needs: push-images + # ========================================================================= + # Generate Source SBOMs - runs in parallel with push-images + # (action and javascript components don't need the Docker image) + # ========================================================================= + generate-source-sboms: + name: Generate SBOMs (${{ matrix.component }}-${{ matrix.format }}-${{ matrix.target }}) + needs: + - sanity-checks + - integration-tests + - container-tests + - license-db-integration-test + - cle-integration-test + - augmentation-integration-test if: github.event_name == 'push' permissions: id-token: write contents: read attestations: write runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + # =========================================== + # Staging - master branch only + # =========================================== + - target: staging + component: action + format: cyclonedx + component_id: 'XNsX40tonzvv' + component_name: 'sbomify Action' + lock_file: 'uv.lock' + output_file: 'github-action.cdx.json' + product_release_id: 'Engh9j4XTTwD' + has_product_release: true + - target: staging + component: action + format: spdx + component_id: 'XNsX40tonzvv' + component_name: 'sbomify Action' + lock_file: 'uv.lock' + output_file: 'github-action.spdx.json' + has_product_release: false + - target: staging + component: javascript + format: cyclonedx + component_id: 'PGyKXNbQd5eq' + component_name: 'JavaScript Dependencies' + lock_file: 'bun.lock' + output_file: 'github-action-frontend.cdx.json' + product_release_id: 'Engh9j4XTTwD' + has_product_release: true + - target: staging + component: javascript + format: spdx + component_id: 'PGyKXNbQd5eq' + component_name: 'JavaScript Dependencies' + lock_file: 'bun.lock' + output_file: 'github-action-frontend.spdx.json' + has_product_release: false + # =========================================== + # Production - tags only + # =========================================== + - target: production + component: action + format: cyclonedx + component_id: 'Gu9wem8mkX' + component_name: 'sbomify Action' + lock_file: 'uv.lock' + output_file: 'github-action.cdx.json' + product_release_id: 'IeIn1dGJXULh' + has_product_release: true + - target: production + component: action + format: spdx + component_id: 'Gu9wem8mkX' + component_name: 'sbomify Action' + lock_file: 'uv.lock' + output_file: 'github-action.spdx.json' + has_product_release: false + - target: production + component: javascript + format: cyclonedx + component_id: 'IxwrYSb9rGql' + component_name: 'JavaScript Dependencies' + lock_file: 'bun.lock' + output_file: 'github-action-frontend.cdx.json' + product_release_id: 'IeIn1dGJXULh' + has_product_release: true + - target: production + component: javascript + format: spdx + component_id: 'IxwrYSb9rGql' + component_name: 'JavaScript Dependencies' + lock_file: 'bun.lock' + output_file: 'github-action-frontend.spdx.json' + has_product_release: false steps: + - name: Check if job should run + id: should_run + run: | + if [[ "${{ matrix.target }}" == "staging" && "${{ github.ref }}" == "refs/heads/master" ]]; then + echo "run=true" >> $GITHUB_OUTPUT + elif [[ "${{ matrix.target }}" == "production" && "${{ github.ref }}" == refs/tags/* ]]; then + echo "run=true" >> $GITHUB_OUTPUT + else + echo "run=false" >> $GITHUB_OUTPUT + fi + - name: Checkout code + if: steps.should_run.outputs.run == 'true' uses: actions/checkout@v4 - - name: Generate additional packages from Dockerfile - run: ./scripts/generate_additional_packages.sh > container_additional_packages.txt - - name: Determine version + if: steps.should_run.outputs.run == 'true' id: version run: | SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7) - echo "short_sha=${SHORT_SHA}" >> $GITHUB_OUTPUT - - - name: Upload SBOM to Staging (CycloneDX) - if: github.ref == 'refs/heads/master' - uses: sbomify/github-action@master - env: - TOKEN: ${{ secrets.SBOMIFY_STAGE_TOKEN }} - API_BASE_URL: https://stage.sbomify.com - COMPONENT_ID: 'XNsX40tonzvv' - LOCK_FILE: 'uv.lock' - COMPONENT_NAME: 'sbomify Action' - COMPONENT_VERSION: ${{ steps.version.outputs.short_sha }} - COMPONENT_PURL: 'pkg:docker/sbomifyhub/sbomify-action@${{ steps.version.outputs.short_sha }}' - PRODUCT_RELEASE: '["Engh9j4XTTwD:${{ steps.version.outputs.short_sha }}"]' - SBOM_FORMAT: cyclonedx - AUGMENT: true - ENRICH: true - UPLOAD: true - OUTPUT_FILE: github-action.cdx.json - - - name: Upload SBOM to Staging (SPDX) - if: github.ref == 'refs/heads/master' - uses: sbomify/github-action@master - env: - TOKEN: ${{ secrets.SBOMIFY_STAGE_TOKEN }} - API_BASE_URL: https://stage.sbomify.com - COMPONENT_ID: 'XNsX40tonzvv' - LOCK_FILE: 'uv.lock' - COMPONENT_NAME: 'sbomify Action' - COMPONENT_VERSION: ${{ steps.version.outputs.short_sha }} - COMPONENT_PURL: 'pkg:docker/sbomifyhub/sbomify-action@${{ steps.version.outputs.short_sha }}' - SBOM_FORMAT: spdx - AUGMENT: true - ENRICH: true - UPLOAD: true - OUTPUT_FILE: github-action.spdx.json - - - name: Upload Container SBOM to Staging (CycloneDX) - if: github.ref == 'refs/heads/master' - uses: sbomify/github-action@master - env: - TOKEN: ${{ secrets.SBOMIFY_STAGE_TOKEN }} - API_BASE_URL: https://stage.sbomify.com - COMPONENT_ID: 'gco2pG10whmy' - DOCKER_IMAGE: 'sbomifyhub/sbomify-action:latest' - COMPONENT_NAME: 'sbomify Action Container' - COMPONENT_VERSION: ${{ steps.version.outputs.short_sha }} - COMPONENT_PURL: 'pkg:docker/sbomifyhub/sbomify-action@${{ steps.version.outputs.short_sha }}' - PRODUCT_RELEASE: '["Engh9j4XTTwD:${{ steps.version.outputs.short_sha }}"]' - ADDITIONAL_PACKAGES_FILE: container_additional_packages.txt - SBOM_FORMAT: cyclonedx - AUGMENT: true - ENRICH: true - UPLOAD: true - OUTPUT_FILE: github-action-container.cdx.json + if [[ "${{ matrix.target }}" == "staging" ]]; then + echo "version=${SHORT_SHA}" >> $GITHUB_OUTPUT + else + echo "version=${{ github.ref_name }}" >> $GITHUB_OUTPUT + fi - - name: Upload Container SBOM to Staging (SPDX) - if: github.ref == 'refs/heads/master' + - name: Generate and Upload SBOM + if: steps.should_run.outputs.run == 'true' uses: sbomify/github-action@master env: - TOKEN: ${{ secrets.SBOMIFY_STAGE_TOKEN }} - API_BASE_URL: https://stage.sbomify.com - COMPONENT_ID: 'gco2pG10whmy' - DOCKER_IMAGE: 'sbomifyhub/sbomify-action:latest' - COMPONENT_NAME: 'sbomify Action Container' - COMPONENT_VERSION: ${{ steps.version.outputs.short_sha }} - COMPONENT_PURL: 'pkg:docker/sbomifyhub/sbomify-action@${{ steps.version.outputs.short_sha }}' - ADDITIONAL_PACKAGES_FILE: container_additional_packages.txt - SBOM_FORMAT: spdx + TOKEN: ${{ matrix.target == 'staging' && secrets.SBOMIFY_STAGE_TOKEN || secrets.SBOMIFY_TOKEN }} + API_BASE_URL: ${{ matrix.target == 'staging' && 'https://stage.sbomify.com' || '' }} + COMPONENT_ID: ${{ matrix.component_id }} + COMPONENT_NAME: ${{ matrix.component_name }} + COMPONENT_VERSION: ${{ steps.version.outputs.version }} + COMPONENT_PURL: ${{ matrix.component == 'action' && format('pkg:docker/sbomifyhub/sbomify-action@{0}', steps.version.outputs.version) || '' }} + LOCK_FILE: ${{ matrix.lock_file }} + PRODUCT_RELEASE: ${{ matrix.has_product_release && format('["{0}:{1}"]', matrix.product_release_id, steps.version.outputs.version) || '' }} + SBOM_FORMAT: ${{ matrix.format }} AUGMENT: true ENRICH: true UPLOAD: true - OUTPUT_FILE: github-action-container.spdx.json + OUTPUT_FILE: ${{ matrix.output_file }} - - name: Upload JavaScript Dependencies SBOM to Staging (CycloneDX) - if: github.ref == 'refs/heads/master' - uses: sbomify/github-action@master - env: - TOKEN: ${{ secrets.SBOMIFY_STAGE_TOKEN }} - API_BASE_URL: https://stage.sbomify.com - COMPONENT_ID: 'PGyKXNbQd5eq' - LOCK_FILE: 'bun.lock' - COMPONENT_NAME: 'JavaScript Dependencies' - COMPONENT_VERSION: ${{ steps.version.outputs.short_sha }} - PRODUCT_RELEASE: '["Engh9j4XTTwD:${{ steps.version.outputs.short_sha }}"]' - SBOM_FORMAT: cyclonedx - AUGMENT: true - ENRICH: true - UPLOAD: true - OUTPUT_FILE: github-action-frontend.cdx.json + - name: Attest SBOM + if: steps.should_run.outputs.run == 'true' + uses: actions/attest-build-provenance@v1 + with: + subject-path: '${{ github.workspace }}/${{ matrix.output_file }}' - - name: Upload JavaScript Dependencies SBOM to Staging (SPDX) - if: github.ref == 'refs/heads/master' - uses: sbomify/github-action@master - env: - TOKEN: ${{ secrets.SBOMIFY_STAGE_TOKEN }} - API_BASE_URL: https://stage.sbomify.com - COMPONENT_ID: 'PGyKXNbQd5eq' - LOCK_FILE: 'bun.lock' - COMPONENT_NAME: 'JavaScript Dependencies' - COMPONENT_VERSION: ${{ steps.version.outputs.short_sha }} - SBOM_FORMAT: spdx - AUGMENT: true - ENRICH: true - UPLOAD: true - OUTPUT_FILE: github-action-frontend.spdx.json + # ========================================================================= + # Generate Container SBOMs - needs push-images (requires Docker image) + # ========================================================================= + generate-container-sboms: + name: Generate SBOMs (container-${{ matrix.format }}-${{ matrix.target }}) + needs: push-images + if: github.event_name == 'push' + permissions: + id-token: write + contents: read + attestations: write + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + # =========================================== + # Staging - master branch only + # =========================================== + - target: staging + format: cyclonedx + component_id: 'gco2pG10whmy' + component_name: 'sbomify Action Container' + output_file: 'github-action-container.cdx.json' + product_release_id: 'Engh9j4XTTwD' + has_product_release: true + - target: staging + format: spdx + component_id: 'gco2pG10whmy' + component_name: 'sbomify Action Container' + output_file: 'github-action-container.spdx.json' + has_product_release: false + # =========================================== + # Production - tags only + # =========================================== + - target: production + format: cyclonedx + component_id: 'ryD0Hcu4vq6y' + component_name: 'sbomify Action Container' + output_file: 'github-action-container.cdx.json' + product_release_id: 'IeIn1dGJXULh' + has_product_release: true + - target: production + format: spdx + component_id: 'ryD0Hcu4vq6y' + component_name: 'sbomify Action Container' + output_file: 'github-action-container.spdx.json' + has_product_release: false + steps: + - name: Check if job should run + id: should_run + run: | + if [[ "${{ matrix.target }}" == "staging" && "${{ github.ref }}" == "refs/heads/master" ]]; then + echo "run=true" >> $GITHUB_OUTPUT + elif [[ "${{ matrix.target }}" == "production" && "${{ github.ref }}" == refs/tags/* ]]; then + echo "run=true" >> $GITHUB_OUTPUT + else + echo "run=false" >> $GITHUB_OUTPUT + fi - - name: Upload SBOM to Production (CycloneDX) - if: startsWith(github.ref, 'refs/tags/') - uses: sbomify/github-action@master - env: - TOKEN: ${{ secrets.SBOMIFY_TOKEN }} - COMPONENT_ID: 'Gu9wem8mkX' - LOCK_FILE: 'uv.lock' - COMPONENT_NAME: 'sbomify Action' - COMPONENT_VERSION: ${{ github.ref_name }} - COMPONENT_PURL: 'pkg:docker/sbomifyhub/sbomify-action@${{ github.ref_name }}' - PRODUCT_RELEASE: '["IeIn1dGJXULh:${{ github.ref_name }}"]' - SBOM_FORMAT: cyclonedx - AUGMENT: true - ENRICH: true - UPLOAD: true - OUTPUT_FILE: github-action.cdx.json + - name: Checkout code + if: steps.should_run.outputs.run == 'true' + uses: actions/checkout@v4 - - name: Upload SBOM to Production (SPDX) - if: startsWith(github.ref, 'refs/tags/') - uses: sbomify/github-action@master - env: - TOKEN: ${{ secrets.SBOMIFY_TOKEN }} - COMPONENT_ID: 'Gu9wem8mkX' - LOCK_FILE: 'uv.lock' - COMPONENT_NAME: 'sbomify Action' - COMPONENT_VERSION: ${{ github.ref_name }} - COMPONENT_PURL: 'pkg:docker/sbomifyhub/sbomify-action@${{ github.ref_name }}' - SBOM_FORMAT: spdx - AUGMENT: true - ENRICH: true - UPLOAD: true - OUTPUT_FILE: github-action.spdx.json + - name: Generate additional packages from Dockerfile + if: steps.should_run.outputs.run == 'true' + run: ./scripts/generate_additional_packages.sh > container_additional_packages.txt - - name: Upload Container SBOM to Production (CycloneDX) - if: startsWith(github.ref, 'refs/tags/') - uses: sbomify/github-action@master - env: - TOKEN: ${{ secrets.SBOMIFY_TOKEN }} - COMPONENT_ID: 'ryD0Hcu4vq6y' - DOCKER_IMAGE: 'sbomifyhub/sbomify-action:${{ github.ref_name }}' - COMPONENT_NAME: 'sbomify Action Container' - COMPONENT_VERSION: ${{ github.ref_name }} - COMPONENT_PURL: 'pkg:docker/sbomifyhub/sbomify-action@${{ github.ref_name }}' - PRODUCT_RELEASE: '["IeIn1dGJXULh:${{ github.ref_name }}"]' - ADDITIONAL_PACKAGES_FILE: container_additional_packages.txt - SBOM_FORMAT: cyclonedx - AUGMENT: true - ENRICH: true - UPLOAD: true - OUTPUT_FILE: github-action-container.cdx.json + - name: Determine version + if: steps.should_run.outputs.run == 'true' + id: version + run: | + SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7) + if [[ "${{ matrix.target }}" == "staging" ]]; then + echo "version=${SHORT_SHA}" >> $GITHUB_OUTPUT + echo "docker_tag=latest" >> $GITHUB_OUTPUT + else + echo "version=${{ github.ref_name }}" >> $GITHUB_OUTPUT + echo "docker_tag=${{ github.ref_name }}" >> $GITHUB_OUTPUT + fi - - name: Upload Container SBOM to Production (SPDX) - if: startsWith(github.ref, 'refs/tags/') + - name: Generate and Upload SBOM + if: steps.should_run.outputs.run == 'true' uses: sbomify/github-action@master env: - TOKEN: ${{ secrets.SBOMIFY_TOKEN }} - COMPONENT_ID: 'ryD0Hcu4vq6y' - DOCKER_IMAGE: 'sbomifyhub/sbomify-action:${{ github.ref_name }}' - COMPONENT_NAME: 'sbomify Action Container' - COMPONENT_VERSION: ${{ github.ref_name }} - COMPONENT_PURL: 'pkg:docker/sbomifyhub/sbomify-action@${{ github.ref_name }}' + TOKEN: ${{ matrix.target == 'staging' && secrets.SBOMIFY_STAGE_TOKEN || secrets.SBOMIFY_TOKEN }} + API_BASE_URL: ${{ matrix.target == 'staging' && 'https://stage.sbomify.com' || '' }} + COMPONENT_ID: ${{ matrix.component_id }} + COMPONENT_NAME: ${{ matrix.component_name }} + COMPONENT_VERSION: ${{ steps.version.outputs.version }} + COMPONENT_PURL: pkg:docker/sbomifyhub/sbomify-action@${{ steps.version.outputs.version }} + DOCKER_IMAGE: sbomifyhub/sbomify-action:${{ steps.version.outputs.docker_tag }} ADDITIONAL_PACKAGES_FILE: container_additional_packages.txt - SBOM_FORMAT: spdx + PRODUCT_RELEASE: ${{ matrix.has_product_release && format('["{0}:{1}"]', matrix.product_release_id, steps.version.outputs.version) || '' }} + SBOM_FORMAT: ${{ matrix.format }} AUGMENT: true ENRICH: true UPLOAD: true - OUTPUT_FILE: github-action-container.spdx.json - - - name: Upload JavaScript Dependencies SBOM to Production (CycloneDX) - if: startsWith(github.ref, 'refs/tags/') - uses: sbomify/github-action@master - env: - TOKEN: ${{ secrets.SBOMIFY_TOKEN }} - COMPONENT_ID: 'IxwrYSb9rGql' - LOCK_FILE: 'bun.lock' - COMPONENT_NAME: 'JavaScript Dependencies' - COMPONENT_VERSION: ${{ github.ref_name }} - PRODUCT_RELEASE: '["IeIn1dGJXULh:${{ github.ref_name }}"]' - SBOM_FORMAT: cyclonedx - AUGMENT: true - ENRICH: true - UPLOAD: true - OUTPUT_FILE: github-action-frontend.cdx.json - - - name: Upload JavaScript Dependencies SBOM to Production (SPDX) - if: startsWith(github.ref, 'refs/tags/') - uses: sbomify/github-action@master - env: - TOKEN: ${{ secrets.SBOMIFY_TOKEN }} - COMPONENT_ID: 'IxwrYSb9rGql' - LOCK_FILE: 'bun.lock' - COMPONENT_NAME: 'JavaScript Dependencies' - COMPONENT_VERSION: ${{ github.ref_name }} - SBOM_FORMAT: spdx - AUGMENT: true - ENRICH: true - UPLOAD: true - OUTPUT_FILE: github-action-frontend.spdx.json - - - name: Attest Source SBOM (CycloneDX) - if: github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags/') - uses: actions/attest-build-provenance@v1 - with: - subject-path: '${{ github.workspace }}/github-action.cdx.json' - - - name: Attest Source SBOM (SPDX) - if: github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags/') - uses: actions/attest-build-provenance@v1 - with: - subject-path: '${{ github.workspace }}/github-action.spdx.json' - - - name: Attest Container SBOM (CycloneDX) - if: github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags/') - uses: actions/attest-build-provenance@v1 - with: - subject-path: '${{ github.workspace }}/github-action-container.cdx.json' - - - name: Attest Container SBOM (SPDX) - if: github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags/') - uses: actions/attest-build-provenance@v1 - with: - subject-path: '${{ github.workspace }}/github-action-container.spdx.json' - - - name: Attest JavaScript Dependencies SBOM (CycloneDX) - if: github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags/') - uses: actions/attest-build-provenance@v1 - with: - subject-path: '${{ github.workspace }}/github-action-frontend.cdx.json' + OUTPUT_FILE: ${{ matrix.output_file }} - - name: Attest JavaScript Dependencies SBOM (SPDX) - if: github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags/') + - name: Attest SBOM + if: steps.should_run.outputs.run == 'true' uses: actions/attest-build-provenance@v1 with: - subject-path: '${{ github.workspace }}/github-action-frontend.spdx.json' + subject-path: '${{ github.workspace }}/${{ matrix.output_file }}' diff --git a/README.md b/README.md index c118642..5928fb7 100644 --- a/README.md +++ b/README.md @@ -470,39 +470,39 @@ env: ### Enrichment Data Sources -| Source | Package Types | Data | -| -------------- | ---------------------------------------------------------------- | --------------------------------------------------------- | -| License DB | Alpine, Wolfi, Ubuntu, Rocky, Alma, CentOS, Fedora, Amazon Linux | License, description, supplier, homepage, maintainer, CLE | -| PyPI | Python | License, author, homepage | -| pub.dev | Dart | License, author, homepage, repo | -| crates.io | Rust/Cargo | License, author, homepage, repo, description | -| Debian Sources | Debian packages | Maintainer, description, homepage | -| deps.dev | Python, npm, Maven, Go, Ruby, NuGet (+ Rust fallback) | License, homepage, repo | -| ecosyste.ms | All major ecosystems | License, description, maintainer | -| Repology | Linux distros | License, homepage | +| Source | Package Types | Data | +| -------------- | ---------------------------------------------------------------- | ----------------------------------------------- | +| License DB | Alpine, Wolfi, Ubuntu, Rocky, Alma, CentOS, Fedora, Amazon Linux | License, description, supplier, homepage | +| Lifecycle | Python, PHP, Go, Rust, Django, Rails, Laravel, React, Vue | CLE (release date, end-of-support, end-of-life) | +| PyPI | Python | License, author, homepage | +| pub.dev | Dart | License, author, homepage, repo | +| crates.io | Rust/Cargo | License, author, homepage, repo, description | +| Debian Sources | Debian packages | Maintainer, description, homepage | +| deps.dev | Python, npm, Maven, Go, Ruby, NuGet (+ Rust fallback) | License, homepage, repo | +| ecosyste.ms | All major ecosystems | License, description, maintainer | +| Repology | Linux distros | License, homepage | ### License Database For Linux distro packages, sbomify uses pre-computed databases that provide comprehensive package metadata. The databases are built by pulling data directly from official distro sources (Alpine APKINDEX, Ubuntu/Debian apt repositories, RPM repos) and normalizing it into a consistent format with validated SPDX license expressions. - **Generated automatically** on each release from official distro repositories -- **Downloaded on-demand** from GitHub Releases during enrichment +- **Downloaded on-demand** from GitHub Releases during enrichment (checks up to 5 recent releases) - **Cached locally** (~/.cache/sbomify/license-db/) for faster subsequent runs - **Normalized** — vendor-specific license strings converted to valid SPDX expressions **Data provided:** -| Field | Description | -| --------------- | ---------------------------------------------- | -| License | SPDX-validated license expression | -| Description | Package summary | -| Supplier | Package maintainer/vendor | -| Homepage | Project website URL | -| Download URL | Package download location | -| Maintainer | Name and email | -| CLE (lifecycle) | End-of-support, end-of-life, and release dates | +| Field | Description | +| ------------ | --------------------------------- | +| License | SPDX-validated license expression | +| Description | Package summary | +| Supplier | Package maintainer/vendor | +| Homepage | Project website URL | +| Download URL | Package download location | +| Maintainer | Name and email | -[CLE (Common Lifecycle Enumeration)](https://sbomify.com/compliance/cle/) provides distro-level lifecycle dates, enabling automated end-of-life tracking for OS packages. +> **Note**: CLE (lifecycle) data is now provided by the dedicated Lifecycle enrichment source. See [Lifecycle Enrichment](#lifecycle-enrichment) below. **Supported distros:** @@ -510,6 +510,7 @@ For Linux distro packages, sbomify uses pre-computed databases that provide comp | ------------ | ------------------- | | Alpine | 3.13–3.21 | | Wolfi | rolling | +| Debian | 11, 12, 13 | | Ubuntu | 20.04, 22.04, 24.04 | | Rocky Linux | 8, 9 | | AlmaLinux | 8, 9 | @@ -525,7 +526,66 @@ The license database is the **primary source** for Linux distro packages, taking sbomify-license-db --distro alpine --version 3.20 --output alpine-3.20.json.gz ``` -Set `SBOMIFY_DISABLE_LICENSE_DB_GENERATION=true` to disable automatic local generation fallback. +> **Note**: Local generation fallback is disabled by default (Ubuntu/Debian can take hours to generate). Set `SBOMIFY_ENABLE_LICENSE_DB_GENERATION=true` to enable it. + +### Lifecycle Enrichment + +sbomify provides [CLE (Common Lifecycle Enumeration)](https://sbomify.com/compliance/cle/) data including release dates, end-of-support, and end-of-life dates. This enables automated tracking of outdated or unsupported components. + +**Supported operating systems:** + +| OS | Tracked Versions | +| ------------ | ------------------- | +| Debian | 10, 11, 12 | +| Ubuntu | 20.04, 22.04, 24.04 | +| Alpine | 3.13–3.21 | +| Rocky Linux | 8, 9 | +| AlmaLinux | 8, 9 | +| CentOS | Stream 8, Stream 9 | +| Fedora | 39–42 | +| Amazon Linux | 2, 2023 | + +Operating system components (CycloneDX `type: operating-system`) are enriched with lifecycle data based on their name and version. + +**Supported runtimes and frameworks:** + +| Package | Tracked Versions | PURL Matching | +| ------- | ------------------------------ | ----------------------------------- | +| Python | 2.7, 3.10–3.14 | All types (pypi, deb, rpm, apk) | +| PHP | 7.4, 8.0–8.5 | All types (composer, deb, rpm, apk) | +| Go | 1.22–1.25 | All types (golang, deb, rpm, apk) | +| Rust | 1.90–1.92 | All types (cargo, deb, rpm, apk) | +| Django | 4.2, 5.2, 6.0 | PyPI only | +| Rails | 7.0–8.1 (+ all component gems) | RubyGems only | +| Laravel | 10–13 | Composer only | +| React | 17–19 | npm only | +| Vue | 2, 3 | npm only | + +**How it works:** + +- **OS components**: Detected by CycloneDX `type: operating-system`, matched by name/version +- **Runtimes/frameworks**: Matched by name pattern across all package managers +- Version cycle extracted from full version (e.g., `3.12.7` → `3.12`, `12.12` → `12`) +- CLE properties added: `cle:releaseDate`, `cle:eos`, `cle:eol` + +> **Note**: Arbitrary OS packages (curl, nginx, openssl, etc.) do not receive lifecycle data. Only the operating system itself and explicitly tracked runtimes/frameworks get CLE data. + +**Example enriched OS component:** + +```json +{ + "type": "operating-system", + "name": "debian", + "version": "12.12", + "properties": [ + {"name": "cle:releaseDate", "value": "2023-06-10"}, + {"name": "cle:eos", "value": "2026-06-10"}, + {"name": "cle:eol", "value": "2028-06-30"} + ] +} +``` + +This allows downstream tools to identify components running on unsupported operating systems or runtimes. ## SBOM Quality Improvement diff --git a/pyproject.toml b/pyproject.toml index f3de15e..358d942 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ description = "Generate, augment, enrich, and manage SBOMs in your CI/CD pipelin authors = [{ name = "sbomify", email = "hello@sbomify.com" }] requires-python = ">=3.10" readme = "README.md" -license = { text = "Apache-2.0" } +license = "Apache-2.0" keywords = ["sbom", "cyclonedx", "spdx", "supply-chain", "security", "bom", "software-composition-analysis"] classifiers = [ "Development Status :: 4 - Beta", @@ -52,6 +52,7 @@ dev = [ "ruff>=0.12.0,<0.13", "pre-commit>=4.2.0,<5", "jsonschema>=4.25.1", + "twine>=6.2.0", ] [tool.hatch.build.targets.sdist] @@ -59,9 +60,11 @@ include = [ "sbomify_action/**/*.py", "sbomify_action/**/*.json", ] +core-metadata-version = "2.3" [tool.hatch.build.targets.wheel] packages = ["sbomify_action"] +core-metadata-version = "2.3" [tool.hatch.build] include = [ diff --git a/sbomify_action/_enrichment/enricher.py b/sbomify_action/_enrichment/enricher.py index b099670..cd06771 100644 --- a/sbomify_action/_enrichment/enricher.py +++ b/sbomify_action/_enrichment/enricher.py @@ -25,6 +25,7 @@ DepsDevSource, EcosystemsSource, LicenseDBSource, + LifecycleSource, PubDevSource, PURLSource, PyPISource, @@ -42,6 +43,8 @@ def create_default_registry() -> SourceRegistry: - LicenseDBSource (1) - pre-computed license DB with validated SPDX licenses and full metadata for Alpine, Wolfi, Ubuntu, Rocky, Alma, CentOS, Fedora, Amazon Linux packages. Top priority as it provides fast, accurate data. + - LifecycleSource (5) - local lifecycle data (CLE) for language runtimes + and frameworks (Python, Django, Rails, Laravel, React, Vue) Tier 1 - Native Sources (10-19): - PyPISource (10) - direct from PyPI for Python packages @@ -67,6 +70,7 @@ def create_default_registry() -> SourceRegistry: """ registry = SourceRegistry() registry.register(LicenseDBSource()) + registry.register(LifecycleSource()) registry.register(PyPISource()) registry.register(PubDevSource()) registry.register(CratesIOSource()) @@ -252,11 +256,13 @@ def clear_all_caches() -> None: from .sources.depsdev import clear_cache as clear_depsdev from .sources.ecosystems import clear_cache as clear_ecosystems from .sources.license_db import clear_cache as clear_license_db + from .sources.lifecycle import clear_cache as clear_lifecycle from .sources.pubdev import clear_cache as clear_pubdev from .sources.pypi import clear_cache as clear_pypi from .sources.repology import clear_cache as clear_repology clear_license_db() + clear_lifecycle() clear_pypi() clear_pubdev() clear_cratesio() diff --git a/sbomify_action/_enrichment/license_db_generator.py b/sbomify_action/_enrichment/license_db_generator.py index 05e5ada..b527476 100644 --- a/sbomify_action/_enrichment/license_db_generator.py +++ b/sbomify_action/_enrichment/license_db_generator.py @@ -9,6 +9,7 @@ - Wolfi (Chainguard) - rolling release - Amazon Linux (2, 2023) - CentOS Stream (8, 9) +- Debian (11, 12, 13) - Ubuntu (20.04, 22.04, 24.04) - Rocky Linux (8, 9) - AlmaLinux (8, 9) @@ -17,6 +18,7 @@ Usage: sbomify-license-db --distro alpine --version 3.20 --output alpine-3.20.json.gz sbomify-license-db --distro wolfi --version rolling --output wolfi-rolling.json.gz + sbomify-license-db --distro debian --version 12 --output debian-12.json.gz sbomify-license-db --distro ubuntu --version 24.04 --output ubuntu-24.04.json.gz sbomify-license-db --distro rocky --version 9 --output rocky-9.json.gz """ @@ -25,16 +27,19 @@ import gzip import io import json +import lzma +import os import re import subprocess import sys import tarfile import tempfile import xml.etree.ElementTree as ET +from concurrent.futures import ThreadPoolExecutor, as_completed from dataclasses import asdict, dataclass from datetime import datetime, timezone from pathlib import Path -from typing import Any, Dict, Iterator, Optional +from typing import Any, Dict, Iterator, Optional, Tuple from urllib.parse import urljoin import requests @@ -47,6 +52,7 @@ normalize_rpm_license, validate_spdx_expression, ) +from .lifecycle_data import DISTRO_LIFECYCLE # Initialize logger logger = setup_logging(level="INFO", use_rich=True) @@ -105,212 +111,7 @@ class DatabaseMetadata: end_of_life: Optional[str] = None # ISO 8601 date when all support ends -# CLE (Common Lifecycle Enumeration) data for supported distros -# -# Schema: -# release_date: ISO-8601 date (YYYY-MM-DD) or YYYY-MM when only month is known -# end_of_support: When standard/active updates end (or same as EOL when upstream publishes only one date) -# end_of_life: When all updates end (security support end) -# -# Sources and calculation methodology documented per-distro below. -# For rolling releases, all dates are None. - -DISTRO_LIFECYCLE = { - # ------------------------------------------------------------------------- - # Wolfi (Chainguard) - Rolling Release - # Source: https://docs.chainguard.dev/open-source/wolfi/ - # Note: Wolfi is a rolling-release distribution; lifecycle is not expressed - # as fixed version EOL dates. All fields are None. - # ------------------------------------------------------------------------- - "wolfi": { - "rolling": { - "release_date": None, - "end_of_support": None, - "end_of_life": None, - }, - }, - # ------------------------------------------------------------------------- - # Alpine Linux - # Source: https://alpinelinux.org/releases/ - # Note: Alpine publishes a single per-branch end date. Alpine does not - # separately publish EOS vs EOL for the branch, so the published end date - # is used as both end_of_support and end_of_life. - # ------------------------------------------------------------------------- - "alpine": { - "3.13": { - "release_date": "2021-01-14", - "end_of_support": "2022-11-01", - "end_of_life": "2022-11-01", - }, - "3.14": { - "release_date": "2021-06-15", - "end_of_support": "2023-05-01", - "end_of_life": "2023-05-01", - }, - "3.15": { - "release_date": "2021-11-24", - "end_of_support": "2023-11-01", - "end_of_life": "2023-11-01", - }, - "3.16": { - "release_date": "2022-05-23", - "end_of_support": "2024-05-23", - "end_of_life": "2024-05-23", - }, - "3.17": { - "release_date": "2022-11-22", - "end_of_support": "2024-11-22", - "end_of_life": "2024-11-22", - }, - "3.18": { - "release_date": "2023-05-09", - "end_of_support": "2025-05-09", - "end_of_life": "2025-05-09", - }, - "3.19": { - "release_date": "2023-12-07", - "end_of_support": "2025-11-01", - "end_of_life": "2025-11-01", - }, - "3.20": { - "release_date": "2024-05-22", - "end_of_support": "2026-04-01", - "end_of_life": "2026-04-01", - }, - "3.21": { - "release_date": "2024-12-05", - "end_of_support": "2026-11-01", - "end_of_life": "2026-11-01", - }, - }, - # ------------------------------------------------------------------------- - # Rocky Linux - # Source: https://docs.rockylinux.org/ - # Note: Rocky publishes both 'general support until' (EOS) and 'security - # support through' (EOL) dates. - # ------------------------------------------------------------------------- - "rocky": { - "8": { - "release_date": "2021-06-21", - "end_of_support": "2024-05-01", # General support end - "end_of_life": "2029-05-01", # Security support end - }, - "9": { - "release_date": "2022-07-14", - "end_of_support": "2027-05-31", # General support end - "end_of_life": "2032-05-31", # Security support end - }, - }, - # ------------------------------------------------------------------------- - # AlmaLinux - # Source: https://wiki.almalinux.org/release-notes/ - # Note: AlmaLinux publishes 'active support until' (EOS) and 'security - # support until' (EOL) dates. - # ------------------------------------------------------------------------- - "almalinux": { - "8": { - "release_date": "2021-03-30", - "end_of_support": "2024-05-31", # Active support end - "end_of_life": "2029-05-31", # Security support end - }, - "9": { - "release_date": "2022-05-26", - "end_of_support": "2027-05-31", # Active support end - "end_of_life": "2032-05-31", # Security support end - }, - }, - # ------------------------------------------------------------------------- - # Amazon Linux - # Source: https://aws.amazon.com/amazon-linux-2/faqs/ - # Note: AWS publishes an explicit end-of-support date but does not publish - # separate EOS vs EOL semantics, so the published date is used for both. - # AL2023 only specifies month ("until June 2029"). - # ------------------------------------------------------------------------- - "amazonlinux": { - "2": { - "release_date": "2017-12-19", # AWS announcement date - "end_of_support": "2026-06-30", - "end_of_life": "2026-06-30", - }, - "2023": { - "release_date": None, # Not explicitly published - "end_of_support": "2029-06", # Month precision only - "end_of_life": "2029-06", - }, - }, - # ------------------------------------------------------------------------- - # CentOS Stream - # Source: https://www.centos.org/cl-vs-cs/ - # Note: CentOS publishes an 'expected end of life (EOL)' date. No separate - # EOS date is published, so EOL is used for both. - # ------------------------------------------------------------------------- - "centos": { - "stream8": { - "release_date": None, # Not explicitly published - "end_of_support": "2024-05-31", - "end_of_life": "2024-05-31", - }, - "stream9": { - "release_date": None, # Not explicitly published - "end_of_support": "2027-05-31", - "end_of_life": "2027-05-31", - }, - }, - # ------------------------------------------------------------------------- - # Fedora - # Source: https://fedorapeople.org/groups/schedule/ - # Note: Fedora schedules publish explicit EOL dates. Fedora publishes only - # one end date per release, so it's used for both EOS and EOL. - # Release dates are from 'Current Final Target date' in the schedule. - # ------------------------------------------------------------------------- - "fedora": { - "39": { - "release_date": None, # Not captured - "end_of_support": "2024-11-26", - "end_of_life": "2024-11-26", - }, - "40": { - "release_date": None, # Not captured - "end_of_support": "2025-05-13", - "end_of_life": "2025-05-13", - }, - "41": { - "release_date": "2024-10-29", - "end_of_support": "2025-12-15", - "end_of_life": "2025-12-15", - }, - "42": { - "release_date": "2025-04-15", - # EOL date not yet captured from Fedora sources - # See: https://fedorapeople.org/groups/schedule/f-42/f-42-key-tasks.html - "end_of_support": None, - "end_of_life": None, - }, - }, - # ------------------------------------------------------------------------- - # Ubuntu - # Source: https://ubuntu.com/about/release-cycle - # Note: Ubuntu publishes 'Standard security maintenance' (EOS) and - # 'Expanded security maintenance' (EOL) dates at month precision. - # ------------------------------------------------------------------------- - "ubuntu": { - "20.04": { - "release_date": "2020-04", # Month precision - "end_of_support": "2025-05", # Standard security maintenance end - "end_of_life": "2030-04", # Expanded security maintenance end - }, - "22.04": { - "release_date": "2022-04", - "end_of_support": "2027-06", - "end_of_life": "2032-04", - }, - "24.04": { - "release_date": "2024-04", - "end_of_support": "2029-05", - "end_of_life": "2034-04", - }, - }, -} +# Note: DISTRO_LIFECYCLE is now imported from lifecycle_data.py # ============================================================================= @@ -653,6 +454,16 @@ def process_wolfi_package(pkg_info: ApkPackageInfo) -> Optional[PackageMetadata] UBUNTU_COMPONENTS = ["main", "universe", "restricted", "multiverse"] UBUNTU_POCKETS = ["-security", "-updates", ""] +# Debian archive configuration +DEBIAN_ARCHIVE_BASE = "https://deb.debian.org/debian/" +DEBIAN_CODENAMES = { + "11": "bullseye", + "12": "bookworm", + "13": "trixie", +} +DEBIAN_COMPONENTS = ["main", "contrib", "non-free", "non-free-firmware"] +DEBIAN_POCKETS = ["-updates", ""] # No -security, that's in security.debian.org + def parse_deb822(text: str) -> Iterator[Dict[str, str]]: """Parse Debian control-style stanzas.""" @@ -710,9 +521,63 @@ def fetch_ubuntu_packages( logger.warning(f"Failed to fetch {url}: {e}") -def download_and_extract_deb(filename: str, package_name: str) -> Optional[str]: - """Download a .deb file and extract the copyright file.""" - url = urljoin(UBUNTU_ARCHIVE_BASE, filename) +def fetch_copyright_http(filename: str, distro: str = "ubuntu") -> Optional[str]: + """Fetch copyright file directly via HTTP (no .deb download needed). + + URL patterns: + - Ubuntu: https://changelogs.ubuntu.com/changelogs/{filename_without_arch}/copyright + - Debian: https://metadata.ftp-master.debian.org/changelogs/{section}/{prefix}/{src}/{src}_{ver}_copyright + """ + # filename is like: pool/main/a/apt/apt_2.4.11_amd64.deb + # We need: pool/main/a/apt/apt_2.4.11 (strip arch and .deb) + + # Remove .deb extension and architecture suffix + base = filename.rsplit(".deb", 1)[0] # pool/main/a/apt/apt_2.4.11_amd64 + # Remove architecture (last underscore-separated part) + parts = base.rsplit("_", 1) + if len(parts) == 2: + base = parts[0] # pool/main/a/apt/apt_2.4.11 + + if distro == "ubuntu": + url = f"https://changelogs.ubuntu.com/changelogs/{base}/copyright" + else: + # Debian uses a different format: section/prefix/source/source_version_copyright + # From pool/main/a/apt/apt_2.4.11 extract components + path_parts = base.split("/") + if len(path_parts) >= 4: + section = path_parts[1] # main + prefix = path_parts[2] # a + rest = "/".join(path_parts[3:]) # apt/apt_2.4.11 + pkg_ver = rest.split("/")[-1] # apt_2.4.11 + url = f"https://metadata.ftp-master.debian.org/changelogs/{section}/{prefix}/{rest.split('/')[0]}/{pkg_ver}_copyright" + else: + return None + + try: + response = SESSION.get(url, timeout=DEFAULT_TIMEOUT) + if response.status_code == 200: + return response.text + except Exception: + pass + + return None + + +def download_and_extract_deb( + filename: str, package_name: str, archive_base: str = UBUNTU_ARCHIVE_BASE, distro: str = "ubuntu" +) -> Optional[str]: + """Get copyright file, trying HTTP first, then .deb extraction as fallback. + + The HTTP method uses zero disk space. Fallback extracts only the copyright file. + """ + # Try HTTP first (fast, no disk usage) + copyright_text = fetch_copyright_http(filename, distro) + if copyright_text: + return copyright_text + + # Fallback: download and extract from .deb + url = urljoin(archive_base, filename) + base_name = package_name.split(":")[0] try: with tempfile.TemporaryDirectory() as tmpdir: @@ -725,46 +590,36 @@ def download_and_extract_deb(filename: str, package_name: str) -> Optional[str]: for chunk in response.iter_content(chunk_size=8192): f.write(chunk) - extract_dir = Path(tmpdir) / "extracted" - extract_dir.mkdir() + subprocess.run( + ["ar", "x", str(deb_path)], + cwd=tmpdir, + check=True, + capture_output=True, + timeout=30, + ) - # Use dpkg-deb if available, otherwise ar + tar - try: - subprocess.run( - ["dpkg-deb", "-x", str(deb_path), str(extract_dir)], - check=True, - capture_output=True, - timeout=30, - ) - except (subprocess.CalledProcessError, FileNotFoundError): - subprocess.run( - ["ar", "x", str(deb_path)], - cwd=tmpdir, - check=True, - capture_output=True, - timeout=30, - ) - for data_tar in Path(tmpdir).glob("data.tar.*"): - subprocess.run( - ["tar", "xf", str(data_tar), "-C", str(extract_dir)], - check=True, - capture_output=True, - timeout=60, - ) + data_tar = None + for matches in [list(Path(tmpdir).glob("data.tar.*"))]: + if matches: + data_tar = matches[0] break - # Look for copyright file - copyright_paths = [ - extract_dir / "usr" / "share" / "doc" / package_name / "copyright", - extract_dir / "usr" / "share" / "doc" / package_name.split(":")[0] / "copyright", - ] + if not data_tar: + return None - for cp_path in copyright_paths: - if cp_path.exists(): - return cp_path.read_text(errors="replace") - - for cp_path in extract_dir.rglob("copyright"): - return cp_path.read_text(errors="replace") + # Extract copyright directly to stdout + for cp_path in [f"./usr/share/doc/{package_name}/copyright", f"./usr/share/doc/{base_name}/copyright"]: + try: + result = subprocess.run( + ["tar", "xf", str(data_tar), "-O", cp_path], + cwd=tmpdir, + capture_output=True, + timeout=30, + ) + if result.returncode == 0 and result.stdout: + return result.stdout.decode("utf-8", errors="replace") + except subprocess.TimeoutExpired: + pass except Exception as e: logger.debug(f"Failed to extract copyright from {package_name}: {e}") @@ -848,6 +703,110 @@ def process_ubuntu_package( ) +def fetch_debian_packages( + codename: str, + component: str = "main", + pocket: str = "", + arch: str = "amd64", +) -> Iterator[Dict[str, str]]: + """Fetch and parse Debian Packages index (.gz or .xz).""" + suite = f"{codename}{pocket}" + base_url = f"dists/{suite}/{component}/binary-{arch}/Packages" + + # Try .gz first, then .xz (some pockets like -updates only have .xz) + for ext in [".gz", ".xz"]: + url = urljoin(DEBIAN_ARCHIVE_BASE, f"{base_url}{ext}") + logger.info(f"Fetching {url}") + + try: + response = SESSION.get(url, timeout=DOWNLOAD_TIMEOUT) + response.raise_for_status() + + if ext == ".gz": + with gzip.GzipFile(fileobj=io.BytesIO(response.content)) as gz: + text = gz.read().decode("utf-8", errors="replace") + else: # .xz + text = lzma.decompress(response.content).decode("utf-8", errors="replace") + + yield from parse_deb822(text) + return # Success, don't try other formats + except requests.exceptions.HTTPError as e: + if e.response is not None and e.response.status_code == 404: + logger.debug(f"Not found: {url}, trying next format...") + continue + logger.warning(f"Failed to fetch {url}: {e}") + return + except Exception as e: + logger.warning(f"Failed to fetch {url}: {e}") + return + + logger.warning(f"No Packages index found for {suite}/{component}") + + +def process_debian_package( + pkg_info: Dict[str, str], + distro_version: str, + codename: str, +) -> Optional[PackageMetadata]: + """Process a single Debian package and extract all metadata.""" + name = pkg_info.get("Package") + version = pkg_info.get("Version") + filename = pkg_info.get("Filename") + + if not name or not version: + return None + + # Extract license from copyright file + spdx = None + if filename: + copyright_text = download_and_extract_deb(filename, name, DEBIAN_ARCHIVE_BASE, distro="debian") + if copyright_text: + spdx = extract_dep5_license(copyright_text) + + # We still require a valid license for inclusion + if not spdx: + return None + + # Extract other metadata from Packages.gz + description = pkg_info.get("Description") + if description: + # Take first line only (rest is long description) + description = description.split("\n")[0].strip() + + maintainer = pkg_info.get("Maintainer") + maintainer_name, maintainer_email = parse_maintainer(maintainer) + + homepage = pkg_info.get("Homepage") + + # Construct download URL + download_url = None + if filename: + download_url = urljoin(DEBIAN_ARCHIVE_BASE, filename) + + purl = make_deb_purl( + name=name, + version=version, + distro="debian", + distro_version=distro_version, + ) + + return PackageMetadata( + purl=purl, + name=name, + version=version, + spdx=spdx, + license_raw=spdx, + description=description, + supplier=maintainer, + maintainer_name=maintainer_name, + maintainer_email=maintainer_email, + homepage=homepage, + download_url=download_url, + confidence="high", + source="deb_metadata", + ) + + # ============================================================================= # RPM Package Processing # ============================================================================= @@ -1122,45 +1081,50 @@ def generate_alpine_db( """Generate license database for Alpine Linux.""" logger.info(f"Generating license database for Alpine {distro_version}") - packages: Dict[str, Dict[str, Any]] = {} + # Collect all packages first to know total count + all_packages = [] seen_names: set = set() - count = 0 - skipped = 0 - for repo in ALPINE_REPOS: for pkg_info in fetch_alpine_packages(distro_version, repo): - if pkg_info.name in seen_names: - continue + if pkg_info.name not in seen_names: + seen_names.add(pkg_info.name) + all_packages.append(pkg_info) - seen_names.add(pkg_info.name) + total = len(all_packages) + if max_packages: + all_packages = all_packages[:max_packages] + total = len(all_packages) - if max_packages and count >= max_packages: - break + logger.info(f"Found {total} unique packages to process") - result = process_alpine_package(pkg_info, distro_version) - if result: - packages[result.purl] = { - "name": result.name, - "version": result.version, - "spdx": result.spdx, - "license_raw": result.license_raw, - "description": result.description, - "supplier": result.supplier, - "maintainer_name": result.maintainer_name, - "maintainer_email": result.maintainer_email, - "homepage": result.homepage, - "download_url": result.download_url, - "confidence": result.confidence, - "source": result.source, - } - count += 1 - if count % 500 == 0: - logger.info(f"Processed {count} packages with valid licenses...") - else: - skipped += 1 + packages: Dict[str, Dict[str, Any]] = {} + count = 0 + skipped = 0 + + for idx, pkg_info in enumerate(all_packages, 1): + result = process_alpine_package(pkg_info, distro_version) + if result: + packages[result.purl] = { + "name": result.name, + "version": result.version, + "spdx": result.spdx, + "license_raw": result.license_raw, + "description": result.description, + "supplier": result.supplier, + "maintainer_name": result.maintainer_name, + "maintainer_email": result.maintainer_email, + "homepage": result.homepage, + "download_url": result.download_url, + "confidence": result.confidence, + "source": result.source, + } + count += 1 + else: + skipped += 1 - if max_packages and count >= max_packages: - break + if idx % 500 == 0 or idx == total: + pct = (idx / total) * 100 + logger.info(f"Processed {idx}/{total} ({pct:.1f}%) - {count} valid licenses...") # Get CLE lifecycle data lifecycle = DISTRO_LIFECYCLE.get("alpine", {}).get(distro_version, {}) @@ -1186,7 +1150,7 @@ def generate_alpine_db( logger.info(f"Wrote {len(packages)} packages to {output_path}") logger.info(f"Skipped: {skipped} (license not validated)") - logger.info(f"Total: {len(seen_names)}, Success rate: {len(packages) / max(len(seen_names), 1) * 100:.1f}%") + logger.info(f"Total: {total}, Success rate: {len(packages) / max(total, 1) * 100:.1f}%") def generate_wolfi_db( @@ -1196,20 +1160,26 @@ def generate_wolfi_db( """Generate license database for Wolfi (Chainguard).""" logger.info("Generating license database for Wolfi (rolling release)") - packages: Dict[str, Dict[str, Any]] = {} + # Collect all packages first to know total count + all_packages = [] seen_names: set = set() - count = 0 - skipped = 0 - for pkg_info in fetch_wolfi_packages(): - if pkg_info.name in seen_names: - continue + if pkg_info.name not in seen_names: + seen_names.add(pkg_info.name) + all_packages.append(pkg_info) + + total = len(all_packages) + if max_packages: + all_packages = all_packages[:max_packages] + total = len(all_packages) - seen_names.add(pkg_info.name) + logger.info(f"Found {total} unique packages to process") - if max_packages and count >= max_packages: - break + packages: Dict[str, Dict[str, Any]] = {} + count = 0 + skipped = 0 + for idx, pkg_info in enumerate(all_packages, 1): result = process_wolfi_package(pkg_info) if result: packages[result.purl] = { @@ -1227,11 +1197,13 @@ def generate_wolfi_db( "source": result.source, } count += 1 - if count % 500 == 0: - logger.info(f"Processed {count} packages with valid licenses...") else: skipped += 1 + if idx % 500 == 0 or idx == total: + pct = (idx / total) * 100 + logger.info(f"Processed {idx}/{total} ({pct:.1f}%) - {count} valid licenses...") + # Get CLE lifecycle data (rolling release - dates may be null) lifecycle = DISTRO_LIFECYCLE.get("wolfi", {}).get("rolling", {}) @@ -1256,7 +1228,7 @@ def generate_wolfi_db( logger.info(f"Wrote {len(packages)} packages to {output_path}") logger.info(f"Skipped: {skipped} (license not validated)") - logger.info(f"Total: {len(seen_names)}, Success rate: {len(packages) / max(len(seen_names), 1) * 100:.1f}%") + logger.info(f"Total: {total}, Success rate: {len(packages) / max(total, 1) * 100:.1f}%") def generate_ubuntu_db( @@ -1272,49 +1244,73 @@ def generate_ubuntu_db( logger.info(f"Generating license database for Ubuntu {distro_version} ({codename})") - packages: Dict[str, Dict[str, Any]] = {} + # Collect all packages first to know total count + all_packages = [] seen_names: set = set() - count = 0 - skipped = 0 - for component in UBUNTU_COMPONENTS: for pocket in UBUNTU_POCKETS: for pkg_info in fetch_ubuntu_packages(codename, component, pocket): name = pkg_info.get("Package") - if not name or name in seen_names: - continue + if name and name not in seen_names: + seen_names.add(name) + all_packages.append(pkg_info) - seen_names.add(name) + total = len(all_packages) + if max_packages: + all_packages = all_packages[:max_packages] + total = len(all_packages) - if max_packages and count >= max_packages: - break + logger.info(f"Found {total} unique packages to process") + + packages: Dict[str, Dict[str, Any]] = {} + count = 0 + skipped = 0 + processed = 0 + + # Use parallel processing for faster downloads + # Default to 5 workers to limit disk usage (each .deb can be 10-100MB) + max_workers = int(os.environ.get("SBOMIFY_LICENSE_DB_WORKERS", "5")) + logger.info(f"Using {max_workers} parallel workers") + + def process_one(pkg_info: Dict[str, str]) -> Optional[Tuple[str, Dict[str, Any]]]: + """Process a single package and return (purl, data) or None.""" + result = process_ubuntu_package(pkg_info, distro_version, codename) + if result: + return ( + result.purl, + { + "name": result.name, + "version": result.version, + "spdx": result.spdx, + "license_raw": result.license_raw, + "description": result.description, + "supplier": result.supplier, + "maintainer_name": result.maintainer_name, + "maintainer_email": result.maintainer_email, + "homepage": result.homepage, + "download_url": result.download_url, + "confidence": result.confidence, + "source": result.source, + }, + ) + return None + + with ThreadPoolExecutor(max_workers=max_workers) as executor: + futures = {executor.submit(process_one, pkg): pkg for pkg in all_packages} - result = process_ubuntu_package(pkg_info, distro_version, codename) - if result: - packages[result.purl] = { - "name": result.name, - "version": result.version, - "spdx": result.spdx, - "license_raw": result.license_raw, - "description": result.description, - "supplier": result.supplier, - "maintainer_name": result.maintainer_name, - "maintainer_email": result.maintainer_email, - "homepage": result.homepage, - "download_url": result.download_url, - "confidence": result.confidence, - "source": result.source, - } - count += 1 - if count % 100 == 0: - logger.info(f"Processed {count} packages with valid licenses...") - else: - skipped += 1 - - if max_packages and count >= max_packages: - break - if max_packages and count >= max_packages: - break + for future in as_completed(futures): + processed += 1 + result = future.result() + if result: + purl, data = result + packages[purl] = data + count += 1 + else: + skipped += 1 + + if processed % 100 == 0 or processed == total: + pct = (processed / total) * 100 + logger.info(f"Processed {processed}/{total} ({pct:.1f}%) - {count} valid licenses...") # Get CLE lifecycle data lifecycle = DISTRO_LIFECYCLE.get("ubuntu", {}).get(distro_version, {}) @@ -1340,7 +1336,115 @@ def generate_ubuntu_db( logger.info(f"Wrote {len(packages)} packages to {output_path}") logger.info(f"Skipped: {skipped} (license not validated)") - logger.info(f"Total: {len(seen_names)}, Success rate: {len(packages) / max(len(seen_names), 1) * 100:.1f}%") + logger.info(f"Total: {total}, Success rate: {len(packages) / max(total, 1) * 100:.1f}%") + + +def generate_debian_db( + distro_version: str, + output_path: Path, + max_packages: Optional[int] = None, +) -> None: + """Generate license database for Debian.""" + codename = DEBIAN_CODENAMES.get(distro_version) + if not codename: + logger.error(f"Unknown Debian version: {distro_version}") + sys.exit(1) + + logger.info(f"Generating license database for Debian {distro_version} ({codename})") + + # Collect all packages first to know total count + all_packages = [] + seen_names: set = set() + for component in DEBIAN_COMPONENTS: + for pocket in DEBIAN_POCKETS: + for pkg_info in fetch_debian_packages(codename, component, pocket): + name = pkg_info.get("Package") + if name and name not in seen_names: + seen_names.add(name) + all_packages.append(pkg_info) + + total = len(all_packages) + if max_packages: + all_packages = all_packages[:max_packages] + total = len(all_packages) + + logger.info(f"Found {total} unique packages to process") + + packages: Dict[str, Dict[str, Any]] = {} + count = 0 + skipped = 0 + processed = 0 + + # Use parallel processing for faster downloads + # Default to 5 workers to limit disk usage (each .deb can be 10-100MB) + max_workers = int(os.environ.get("SBOMIFY_LICENSE_DB_WORKERS", "5")) + logger.info(f"Using {max_workers} parallel workers") + + def process_one(pkg_info: Dict[str, str]) -> Optional[Tuple[str, Dict[str, Any]]]: + """Process a single package and return (purl, data) or None.""" + result = process_debian_package(pkg_info, distro_version, codename) + if result: + return ( + result.purl, + { + "name": result.name, + "version": result.version, + "spdx": result.spdx, + "license_raw": result.license_raw, + "description": result.description, + "supplier": result.supplier, + "maintainer_name": result.maintainer_name, + "maintainer_email": result.maintainer_email, + "homepage": result.homepage, + "download_url": result.download_url, + "confidence": result.confidence, + "source": result.source, + }, + ) + return None + + with ThreadPoolExecutor(max_workers=max_workers) as executor: + futures = {executor.submit(process_one, pkg): pkg for pkg in all_packages} + + for future in as_completed(futures): + processed += 1 + result = future.result() + if result: + purl, data = result + packages[purl] = data + count += 1 + else: + skipped += 1 + + if processed % 100 == 0 or processed == total: + pct = (processed / total) * 100 + logger.info(f"Processed {processed}/{total} ({pct:.1f}%) - {count} valid licenses...") + + # Get CLE lifecycle data + lifecycle = DISTRO_LIFECYCLE.get("debian", {}).get(distro_version, {}) + + db = { + "metadata": asdict( + DatabaseMetadata( + distro="debian", + version=distro_version, + codename=codename, + generated_at=datetime.now(timezone.utc).isoformat(), + package_count=len(packages), + release_date=lifecycle.get("release_date"), + end_of_support=lifecycle.get("end_of_support"), + end_of_life=lifecycle.get("end_of_life"), + ) + ), + "packages": packages, + } + + with gzip.open(output_path, "wt", encoding="utf-8") as f: + json.dump(db, f, separators=(",", ":")) + + logger.info(f"Wrote {len(packages)} packages to {output_path}") + logger.info(f"Skipped: {skipped} (license not validated)") + logger.info(f"Total: {total}, Success rate: {len(packages) / max(total, 1) * 100:.1f}%") def resolve_mirror_url(mirror_list_url: str) -> Optional[str]: @@ -1395,45 +1499,51 @@ def generate_rpm_db( logger.info(f"Generating license database for {distro} {distro_version}") - packages: Dict[str, Dict[str, Any]] = {} + # Collect all packages first to know total count + # Store as (pkg_info, repo_url) tuples since we need repo_url for processing + all_packages = [] seen_names: set = set() - count = 0 - skipped = 0 - for repo_url in repos: for pkg_info in fetch_rpm_packages(repo_url): - if pkg_info.name in seen_names: - continue + if pkg_info.name not in seen_names: + seen_names.add(pkg_info.name) + all_packages.append((pkg_info, repo_url)) - seen_names.add(pkg_info.name) + total = len(all_packages) + if max_packages: + all_packages = all_packages[:max_packages] + total = len(all_packages) - if max_packages and count >= max_packages: - break + logger.info(f"Found {total} unique packages to process") - result = process_rpm_package(pkg_info, distro, distro_version, repo_url) - if result: - packages[result.purl] = { - "name": result.name, - "version": result.version, - "spdx": result.spdx, - "license_raw": result.license_raw, - "description": result.description, - "supplier": result.supplier, - "maintainer_name": result.maintainer_name, - "maintainer_email": result.maintainer_email, - "homepage": result.homepage, - "download_url": result.download_url, - "confidence": result.confidence, - "source": result.source, - } - count += 1 - if count % 500 == 0: - logger.info(f"Processed {count} packages with valid licenses...") - else: - skipped += 1 + packages: Dict[str, Dict[str, Any]] = {} + count = 0 + skipped = 0 + + for idx, (pkg_info, repo_url) in enumerate(all_packages, 1): + result = process_rpm_package(pkg_info, distro, distro_version, repo_url) + if result: + packages[result.purl] = { + "name": result.name, + "version": result.version, + "spdx": result.spdx, + "license_raw": result.license_raw, + "description": result.description, + "supplier": result.supplier, + "maintainer_name": result.maintainer_name, + "maintainer_email": result.maintainer_email, + "homepage": result.homepage, + "download_url": result.download_url, + "confidence": result.confidence, + "source": result.source, + } + count += 1 + else: + skipped += 1 - if max_packages and count >= max_packages: - break + if idx % 500 == 0 or idx == total: + pct = (idx / total) * 100 + logger.info(f"Processed {idx}/{total} ({pct:.1f}%) - {count} valid licenses...") # Get CLE lifecycle data lifecycle = DISTRO_LIFECYCLE.get(distro, {}).get(distro_version, {}) @@ -1459,7 +1569,7 @@ def generate_rpm_db( logger.info(f"Wrote {len(packages)} packages to {output_path}") logger.info(f"Skipped: {skipped} (license not validated)") - logger.info(f"Total: {len(seen_names)}, Success rate: {len(packages) / max(len(seen_names), 1) * 100:.1f}%") + logger.info(f"Total: {total}, Success rate: {len(packages) / max(total, 1) * 100:.1f}%") # ============================================================================= @@ -1476,7 +1586,7 @@ def main() -> None: parser.add_argument( "--distro", required=True, - choices=["alpine", "amazonlinux", "centos", "ubuntu", "rocky", "almalinux", "fedora", "wolfi"], + choices=["alpine", "amazonlinux", "centos", "debian", "ubuntu", "rocky", "almalinux", "fedora", "wolfi"], help="Distribution name", ) parser.add_argument( @@ -1508,6 +1618,8 @@ def main() -> None: elif args.distro == "wolfi": # Wolfi is rolling release, version is ignored generate_wolfi_db(output_path, args.max_packages) + elif args.distro == "debian": + generate_debian_db(args.version, output_path, args.max_packages) elif args.distro == "ubuntu": generate_ubuntu_db(args.version, output_path, args.max_packages) else: diff --git a/sbomify_action/_enrichment/lifecycle_data.py b/sbomify_action/_enrichment/lifecycle_data.py new file mode 100644 index 0000000..eeaf644 --- /dev/null +++ b/sbomify_action/_enrichment/lifecycle_data.py @@ -0,0 +1,940 @@ +"""Lifecycle data for SBOM enrichment with CLE (Common Lifecycle Enumeration) fields. + +This module centralizes lifecycle data for: +1. Linux distributions (used by license_db_generator and license_db source) +2. Language runtimes and frameworks (used by lifecycle enrichment source) + +CLE fields follow ECMA-428 specification: +- release_date: First public stable release date for the cycle +- end_of_support: End of active/mainstream/bugfix support +- end_of_life: End of security support / extended support + +See: https://sbomify.com/compliance/cle/ + +Data last updated: 2026-01-18 +""" + +from typing import Dict, List, Optional, TypedDict + + +class LifecycleDates(TypedDict, total=False): + """Lifecycle dates for a single version/cycle.""" + + release_date: Optional[str] # ISO 8601 date or quarter string (e.g., "2026-Q1") + end_of_support: Optional[str] # ISO 8601 date or quarter string + end_of_life: Optional[str] # ISO 8601 date or quarter string + + +class PackageLifecycleEntry(TypedDict, total=False): + """Lifecycle configuration for a package type.""" + + name_patterns: List[str] # Package name patterns to match (glob-style) + purl_types: Optional[List[str]] # PURL types to match, None = all types + cycles: Dict[str, LifecycleDates] # version cycle -> lifecycle dates + version_extract: Optional[str] # "major" or "major.minor" (default: major.minor) + references: Optional[List[str]] # Documentation references + + +# ============================================================================= +# DISTRO_LIFECYCLE - Linux Distribution Lifecycle Data +# ============================================================================= +# +# Schema: +# release_date: ISO-8601 date (YYYY-MM-DD) or YYYY-MM when only month is known +# end_of_support: When standard/active updates end (or same as EOL when upstream +# publishes only one date) +# end_of_life: When all updates end (security support end) +# +# Sources and calculation methodology documented per-distro below. +# For rolling releases, all dates are None. + +DISTRO_LIFECYCLE: Dict[str, Dict[str, LifecycleDates]] = { + # ------------------------------------------------------------------------- + # Wolfi (Chainguard) - Rolling Release + # Source: https://docs.chainguard.dev/open-source/wolfi/ + # Note: Wolfi is a rolling-release distribution; lifecycle is not expressed + # as fixed version EOL dates. All fields are None. + # ------------------------------------------------------------------------- + "wolfi": { + "rolling": { + "release_date": None, + "end_of_support": None, + "end_of_life": None, + }, + }, + # ------------------------------------------------------------------------- + # Alpine Linux + # Source: https://alpinelinux.org/releases/ + # Note: Alpine publishes a single per-branch end date. Alpine does not + # separately publish EOS vs EOL for the branch, so the published end date + # is used as both end_of_support and end_of_life. + # ------------------------------------------------------------------------- + "alpine": { + "3.13": { + "release_date": "2021-01-14", + "end_of_support": "2022-11-01", + "end_of_life": "2022-11-01", + }, + "3.14": { + "release_date": "2021-06-15", + "end_of_support": "2023-05-01", + "end_of_life": "2023-05-01", + }, + "3.15": { + "release_date": "2021-11-24", + "end_of_support": "2023-11-01", + "end_of_life": "2023-11-01", + }, + "3.16": { + "release_date": "2022-05-23", + "end_of_support": "2024-05-23", + "end_of_life": "2024-05-23", + }, + "3.17": { + "release_date": "2022-11-22", + "end_of_support": "2024-11-22", + "end_of_life": "2024-11-22", + }, + "3.18": { + "release_date": "2023-05-09", + "end_of_support": "2025-05-09", + "end_of_life": "2025-05-09", + }, + "3.19": { + "release_date": "2023-12-07", + "end_of_support": "2025-11-01", + "end_of_life": "2025-11-01", + }, + "3.20": { + "release_date": "2024-05-22", + "end_of_support": "2026-04-01", + "end_of_life": "2026-04-01", + }, + "3.21": { + "release_date": "2024-12-05", + "end_of_support": "2026-11-01", + "end_of_life": "2026-11-01", + }, + }, + # ------------------------------------------------------------------------- + # Rocky Linux + # Source: https://docs.rockylinux.org/ + # Note: Rocky publishes both 'general support until' (EOS) and 'security + # support through' (EOL) dates. + # ------------------------------------------------------------------------- + "rocky": { + "8": { + "release_date": "2021-06-21", + "end_of_support": "2024-05-01", # General support end + "end_of_life": "2029-05-01", # Security support end + }, + "9": { + "release_date": "2022-07-14", + "end_of_support": "2027-05-31", # General support end + "end_of_life": "2032-05-31", # Security support end + }, + }, + # ------------------------------------------------------------------------- + # AlmaLinux + # Source: https://wiki.almalinux.org/release-notes/ + # Note: AlmaLinux publishes 'active support until' (EOS) and 'security + # support until' (EOL) dates. + # ------------------------------------------------------------------------- + "almalinux": { + "8": { + "release_date": "2021-03-30", + "end_of_support": "2024-05-31", # Active support end + "end_of_life": "2029-05-31", # Security support end + }, + "9": { + "release_date": "2022-05-26", + "end_of_support": "2027-05-31", # Active support end + "end_of_life": "2032-05-31", # Security support end + }, + }, + # ------------------------------------------------------------------------- + # Amazon Linux + # Source: https://aws.amazon.com/amazon-linux-2/faqs/ + # Note: AWS publishes an explicit end-of-support date but does not publish + # separate EOS vs EOL semantics, so the published date is used for both. + # AL2023 only specifies month ("until June 2029"). + # ------------------------------------------------------------------------- + "amazonlinux": { + "2": { + "release_date": "2017-12-19", # AWS announcement date + "end_of_support": "2026-06-30", + "end_of_life": "2026-06-30", + }, + "2023": { + "release_date": None, # Not explicitly published + "end_of_support": "2029-06", # Month precision only + "end_of_life": "2029-06", + }, + }, + # ------------------------------------------------------------------------- + # CentOS Stream + # Source: https://www.centos.org/cl-vs-cs/ + # Note: CentOS publishes an 'expected end of life (EOL)' date. No separate + # EOS date is published, so EOL is used for both. + # ------------------------------------------------------------------------- + "centos": { + "stream8": { + "release_date": None, # Not explicitly published + "end_of_support": "2024-05-31", + "end_of_life": "2024-05-31", + }, + "stream9": { + "release_date": None, # Not explicitly published + "end_of_support": "2027-05-31", + "end_of_life": "2027-05-31", + }, + }, + # ------------------------------------------------------------------------- + # Fedora + # Source: https://fedorapeople.org/groups/schedule/ + # Note: Fedora schedules publish explicit EOL dates. Fedora publishes only + # one end date per release, so it's used for both EOS and EOL. + # Release dates are from 'Current Final Target date' in the schedule. + # ------------------------------------------------------------------------- + "fedora": { + "39": { + "release_date": None, # Not captured + "end_of_support": "2024-11-26", + "end_of_life": "2024-11-26", + }, + "40": { + "release_date": None, # Not captured + "end_of_support": "2025-05-13", + "end_of_life": "2025-05-13", + }, + "41": { + "release_date": "2024-10-29", + "end_of_support": "2025-12-15", + "end_of_life": "2025-12-15", + }, + "42": { + "release_date": "2025-04-15", + # EOL date not yet captured from Fedora sources + # See: https://fedorapeople.org/groups/schedule/f-42/f-42-key-tasks.html + "end_of_support": None, + "end_of_life": None, + }, + }, + # ------------------------------------------------------------------------- + # Ubuntu + # Source: https://ubuntu.com/about/release-cycle + # Note: Ubuntu publishes 'Standard security maintenance' (EOS) and + # 'Expanded security maintenance' (EOL) dates at month precision. + # ------------------------------------------------------------------------- + "ubuntu": { + "20.04": { + "release_date": "2020-04", # Month precision + "end_of_support": "2025-05", # Standard security maintenance end + "end_of_life": "2030-04", # Expanded security maintenance end + }, + "22.04": { + "release_date": "2022-04", + "end_of_support": "2027-06", + "end_of_life": "2032-04", + }, + "24.04": { + "release_date": "2024-04", + "end_of_support": "2029-05", + "end_of_life": "2034-04", + }, + }, + # ------------------------------------------------------------------------- + # Debian + # Source: https://wiki.debian.org/LTS + # Note: Debian publishes 'Regular security support' (EOS) and 'Long Term + # Support' (EOL/LTS) dates. + # ------------------------------------------------------------------------- + "debian": { + "10": { + "release_date": "2019-07-06", + "end_of_support": "2022-09-10", # Regular security support end + "end_of_life": "2024-06-30", # LTS end + }, + "11": { + "release_date": "2021-08-14", + "end_of_support": "2024-08-14", # Regular security support end + "end_of_life": "2026-08-31", # LTS end + }, + "12": { + "release_date": "2023-06-10", + "end_of_support": "2026-06-10", # Regular security support end + "end_of_life": "2028-06-30", # LTS end + }, + "13": { + "release_date": "2025-08-09", + "end_of_support": "2028-08-09", # Full Debian support end + "end_of_life": "2030-06-30", # LTS end + }, + }, +} + + +# ============================================================================= +# PACKAGE_LIFECYCLE - Language Runtime and Framework Lifecycle Data +# ============================================================================= +# +# Schema per package: +# name_patterns: List of package name patterns to match (case-insensitive) +# Supports glob patterns: "python3.*" matches "python3.12" +# purl_types: Optional list of PURL types to match (e.g., ["pypi", "deb"]) +# None means match all PURL types +# cycles: Dict mapping version cycle to lifecycle dates +# version_extract: How to extract cycle from version ("major" or "major.minor") +# Default is "major.minor" +# references: List of documentation URLs +# +# Definitions: +# release_date: First public stable release date for the cycle when available; +# otherwise null or quarter string (e.g., "2026-Q1") +# end_of_support: End of active/mainstream/bugfix support, when the project +# stops providing regular bugfix releases (may still receive +# security fixes) +# end_of_life: End of security support / extended support; after this, upstream +# no longer provides security fixes +# +# Data as of: 2026-01-18 + +PACKAGE_LIFECYCLE: Dict[str, PackageLifecycleEntry] = { + # ------------------------------------------------------------------------- + # Python + # Source: https://devguide.python.org/versions/ + # https://peps.python.org/pep-0373/ (Python 2.7) + # Note: Python provides ~18-24 months of bugfix support after release, + # then security-only fixes until EOL. Starting with 3.13, bugfix support + # is 24 months. + # + # Common PURLs across ecosystems: + # PyPI: pkg:pypi/python@3.12.1, pkg:pypi/cpython@3.12.1 + # Alpine: pkg:apk/alpine/python3@3.12.1, pkg:apk/alpine/python3.12@3.12.1 + # Debian: pkg:deb/debian/python3@3.12.1, pkg:deb/debian/python3.12@3.12.1 + # pkg:deb/debian/python3.12-minimal@3.12.1 + # pkg:deb/debian/libpython3.12-stdlib@3.12.1 + # Ubuntu: pkg:deb/ubuntu/python3@3.12.1, pkg:deb/ubuntu/python3.12@3.12.1 + # Fedora: pkg:rpm/fedora/python3@3.12.1 + # Docker: python:3.12, python:3.12-slim, python:3.12-alpine + # ------------------------------------------------------------------------- + "python": { + "name_patterns": [ + "python", + "python2", + "python2.*", + "python3", + "python3.*", + "cpython", + "libpython*", # Debian stdlib packages + ], + "purl_types": None, # Match all types (pypi, deb, rpm, apk, etc.) + "version_extract": "major.minor", + "references": [ + "https://devguide.python.org/versions/", + "https://peps.python.org/pep-0373/", + ], + "cycles": { + "2.7": { + "release_date": None, + "end_of_support": "2020-01-01", + "end_of_life": "2020-04-20", + }, + "3.10": { + "release_date": "2021-10-04", + "end_of_support": "2023-04-04", + "end_of_life": "2026-10-31", + }, + "3.11": { + "release_date": "2022-10-24", + "end_of_support": "2024-04-24", + "end_of_life": "2027-10-31", + }, + "3.12": { + "release_date": "2023-10-02", + "end_of_support": "2025-04-02", + "end_of_life": "2028-10-31", + }, + "3.13": { + "release_date": "2024-10-07", + "end_of_support": "2026-10-07", + "end_of_life": "2029-10-31", + }, + "3.14": { + "release_date": "2025-10-07", + "end_of_support": "2027-10-07", + "end_of_life": "2030-10-31", + }, + }, + }, + # ------------------------------------------------------------------------- + # Django + # Source: https://www.djangoproject.com/download/ + # Note: Django provides bugfix support until EOS, then security-only + # until EOL. LTS releases have extended support windows. + # ------------------------------------------------------------------------- + "django": { + "name_patterns": ["django", "Django"], + "purl_types": ["pypi"], + "version_extract": "major.minor", + "references": [ + "https://www.djangoproject.com/download/", + ], + "cycles": { + "4.2": { + "release_date": None, + "end_of_support": "2023-12-04", + "end_of_life": "2026-04-30", + }, + "5.2": { + "release_date": None, + "end_of_support": "2025-12-03", + "end_of_life": "2028-04-30", + }, + "6.0": { + "release_date": None, + "end_of_support": "2026-08-31", + "end_of_life": "2027-04-30", + }, + }, + }, + # ------------------------------------------------------------------------- + # Ruby on Rails + # Source: https://rubyonrails.org/2025/10/29/new-rails-releases-and-end-of-support-announcement + # Note: Rails provides bugfix support for ~12 months, then security-only + # for another ~6-12 months typically. + # + # Common PURLs across ecosystems: + # RubyGems: pkg:gem/rails@8.0.1, pkg:gem/railties@8.0.1 + # pkg:gem/actionpack@8.0.1, pkg:gem/activerecord@8.0.1 + # pkg:gem/activesupport@8.0.1, pkg:gem/actionmailer@8.0.1 + # pkg:gem/actioncable@8.0.1, pkg:gem/activestorage@8.0.1 + # pkg:gem/actionview@8.0.1, pkg:gem/activejob@8.0.1 + # Debian: pkg:deb/debian/rails@8.0.1, pkg:deb/debian/ruby-rails@8.0.1 + # ------------------------------------------------------------------------- + "rails": { + "name_patterns": [ + "rails", + "railties", + "actionpack", + "activerecord", + "activesupport", + "actionmailer", + "actioncable", + "activestorage", + "actionview", + "activejob", + "actionmailbox", + "actiontext", + "activemodel", + "ruby-rails", + ], + "purl_types": ["gem"], + "version_extract": "major.minor", + "references": [ + "https://rubyonrails.org/2025/10/29/new-rails-releases-and-end-of-support-announcement", + ], + "cycles": { + "7.0": { + "release_date": "2021-12-15", + "end_of_support": "2025-10-29", + "end_of_life": "2025-10-29", + }, + "7.1": { + "release_date": "2023-10-05", + "end_of_support": "2025-10-29", + "end_of_life": "2025-10-29", + }, + "7.2": { + "release_date": None, + "end_of_support": None, + "end_of_life": "2026-08-09", + }, + "8.0": { + "release_date": "2024-11-07", + "end_of_support": "2026-05-07", + "end_of_life": "2026-11-07", + }, + "8.1": { + "release_date": "2025-10-22", + "end_of_support": "2026-10-10", + "end_of_life": "2027-10-10", + }, + }, + }, + # ------------------------------------------------------------------------- + # Laravel + # Source: https://laravel.com/docs/12.x/releases + # Note: Laravel provides ~6 months bugfix support, ~12 months security. + # Quarter strings preserved for future releases. + # ------------------------------------------------------------------------- + "laravel": { + "name_patterns": ["laravel/framework", "laravel"], + "purl_types": ["composer"], + "version_extract": "major", + "references": [ + "https://laravel.com/docs/12.x/releases", + ], + "cycles": { + "10": { + "release_date": "2023-02-14", + "end_of_support": "2025-02-06", + "end_of_life": "2026-02-04", + }, + "11": { + "release_date": "2024-03-12", + "end_of_support": "2025-09-03", + "end_of_life": "2026-03-12", + }, + "12": { + "release_date": "2025-02-24", + "end_of_support": "2026-09-03", + "end_of_life": "2027-03-12", + }, + "13": { + "release_date": "2026-Q1", + "end_of_support": "2026-Q3", + "end_of_life": "2027-Q1", + }, + }, + }, + # ------------------------------------------------------------------------- + # PHP + # Source: https://www.php.net/supported-versions.php + # https://www.php.net/eol.php + # Note: PHP provides ~2 years of active support, then ~1 year of security-only + # support. Older branches only show EOL date (end_of_support is None). + # + # Common PURLs across ecosystems: + # Composer: pkg:composer/php@8.4.1 (rarely used directly) + # Alpine: pkg:apk/alpine/php@8.4.1, pkg:apk/alpine/php84@8.4.1 + # pkg:apk/alpine/php84-fpm@8.4.1, pkg:apk/alpine/php84-cli@8.4.1 + # pkg:apk/alpine/php83@8.3.6, pkg:apk/alpine/php83-common@8.3.6 + # Debian: pkg:deb/debian/php@8.4.1, pkg:deb/debian/php8.3@8.3.6 + # pkg:deb/debian/php8.3-fpm@8.3.6, pkg:deb/debian/php8.3-cli@8.3.6 + # pkg:deb/debian/php-fpm@8.3.6, pkg:deb/debian/php-cli@8.3.6 + # Ubuntu: pkg:deb/ubuntu/php@8.3.6, pkg:deb/ubuntu/php8.3@8.3.6 + # Fedora: pkg:rpm/fedora/php@8.3.6, pkg:rpm/fedora/php-fpm@8.3.6 + # Docker: php:8.4, php:8.4-fpm, php:8.4-alpine, php:8.4-fpm-alpine + # ------------------------------------------------------------------------- + "php": { + "name_patterns": [ + "php", + "php-cli", + "php-fpm", + "php-cgi", + "php-common", + "php7", + "php7.*", + "php8", + "php8.*", + "php74", + "php74-*", + "php80", + "php80-*", + "php81", + "php81-*", + "php82", + "php82-*", + "php83", + "php83-*", + "php84", + "php84-*", + "php85", + "php85-*", + "libphp*", # Shared libraries + ], + "purl_types": None, # Match all types (composer, deb, rpm, apk, etc.) + "version_extract": "major.minor", + "references": [ + "https://www.php.net/supported-versions.php", + "https://www.php.net/eol.php", + ], + "cycles": { + "7.4": { + "release_date": "2019-11-28", + "end_of_support": None, + "end_of_life": "2022-11-28", + }, + "8.0": { + "release_date": "2020-11-26", + "end_of_support": None, + "end_of_life": "2023-11-26", + }, + "8.1": { + "release_date": "2021-11-25", + "end_of_support": None, + "end_of_life": "2025-12-31", + }, + "8.2": { + "release_date": "2022-12-08", + "end_of_support": "2024-12-31", + "end_of_life": "2026-12-31", + }, + "8.3": { + "release_date": "2023-11-23", + "end_of_support": "2025-12-31", + "end_of_life": "2027-12-31", + }, + "8.4": { + "release_date": "2024-11-21", + "end_of_support": "2026-12-31", + "end_of_life": "2028-12-31", + }, + "8.5": { + "release_date": "2025-11-20", + "end_of_support": "2027-12-31", + "end_of_life": "2029-12-31", + }, + }, + }, + # ------------------------------------------------------------------------- + # Go (Golang) + # Source: https://go.dev/doc/devel/release + # Note: Go's release policy supports a major release until there are two + # newer major releases. EOS/EOL are the same date (when support ends). + # + # Common PURLs across ecosystems: + # Go modules: pkg:golang/golang.org/x/text@1.23.0 (libraries, not runtime) + # Alpine: pkg:apk/alpine/go@1.23.4 + # Debian: pkg:deb/debian/golang@1.23.4, pkg:deb/debian/golang-go@1.23.4 + # pkg:deb/debian/golang-1.23@1.23.4, pkg:deb/debian/golang-1.23-go@1.23.4 + # pkg:deb/debian/golang-1.23-src@1.23.4 + # Ubuntu: pkg:deb/ubuntu/golang@1.23.4, pkg:deb/ubuntu/golang-1.23-go@1.23.4 + # Fedora: pkg:rpm/fedora/golang@1.23.4 + # Docker: golang:1.23, golang:1.23-alpine, golang:1.23-bookworm + # ------------------------------------------------------------------------- + "golang": { + "name_patterns": [ + "go", + "golang", + "golang-go", + "golang-src", + "golang-doc", + "golang-1.*", # Debian versioned packages + "golang-1.*-go", + "golang-1.*-src", + "golang-1.*-doc", + ], + "purl_types": None, # Match all types (golang, deb, rpm, apk, etc.) + "version_extract": "major.minor", + "references": [ + "https://go.dev/doc/devel/release", + ], + "cycles": { + "1.22": { + "release_date": "2024-02-06", + "end_of_support": "2025-02-11", + "end_of_life": "2025-02-11", + }, + "1.23": { + "release_date": "2024-08-13", + "end_of_support": "2025-08-12", + "end_of_life": "2025-08-12", + }, + "1.24": { + "release_date": "2025-02-11", + "end_of_support": None, + "end_of_life": None, + }, + "1.25": { + "release_date": "2025-08-12", + "end_of_support": None, + "end_of_life": None, + }, + }, + }, + # ------------------------------------------------------------------------- + # Rust + # Source: https://rust-lang.org/policies/security/ + # https://blog.rust-lang.org/releases/ + # Note: Rust only supports the most recent stable release. When a new stable + # is released, the previous version is immediately unsupported. EOS/EOL are + # the same date (next stable release date). + # + # Common PURLs across ecosystems: + # Cargo: pkg:cargo/serde@1.91.0 (crates, not runtime itself) + # Alpine: pkg:apk/alpine/rust@1.91.0, pkg:apk/alpine/cargo@1.91.0 + # Debian: pkg:deb/debian/rustc@1.91.0, pkg:deb/debian/cargo@1.91.0 + # pkg:deb/debian/rust-all@1.91.0, pkg:deb/debian/rust-src@1.91.0 + # pkg:deb/debian/libstd-rust-1.91@1.91.0, pkg:deb/debian/libstd-rust-dev@1.91.0 + # Ubuntu: pkg:deb/ubuntu/rustc@1.91.0, pkg:deb/ubuntu/cargo@1.91.0 + # pkg:deb/ubuntu/rustc-1.77@1.77.0 (versioned) + # Fedora: pkg:rpm/fedora/rust@1.91.0, pkg:rpm/fedora/cargo@1.91.0 + # Docker: rust:1.91, rust:1.91-slim, rust:1.91-alpine + # ------------------------------------------------------------------------- + "rust": { + "name_patterns": [ + "rust", + "rustc", + "rustc-*", # Ubuntu versioned packages + "cargo", + "cargo-*", # Ubuntu versioned packages + "rust-all", + "rust-src", + "rust-doc", + "rust-gdb", + "rust-lldb", + "libstd-rust*", # Debian stdlib packages + ], + "purl_types": None, # Match all types (cargo, deb, rpm, apk, etc.) + "version_extract": "major.minor", + "references": [ + "https://rust-lang.org/policies/security/", + "https://blog.rust-lang.org/releases/", + ], + "cycles": { + "1.90": { + "release_date": "2025-09-18", + "end_of_support": "2025-10-30", + "end_of_life": "2025-10-30", + }, + "1.91": { + "release_date": "2025-10-30", + "end_of_support": "2025-12-11", + "end_of_life": "2025-12-11", + }, + "1.92": { + "release_date": "2025-12-11", + "end_of_support": None, + "end_of_life": None, + }, + }, + }, + # ------------------------------------------------------------------------- + # React + # Source: https://react.dev/blog/ + # Note: React does not publish fixed end-of-support/end-of-life dates for + # major versions. Only release dates are tracked. + # + # Common PURLs across ecosystems: + # npm: pkg:npm/react@19.0.0, pkg:npm/react-dom@19.0.0 + # pkg:npm/react-native@0.76.0 (different versioning, not tracked) + # ------------------------------------------------------------------------- + "react": { + "name_patterns": [ + "react", + "react-dom", # Usually same version as react + ], + "purl_types": ["npm"], + "version_extract": "major", + "references": [ + "https://react.dev/blog/2024/12/05/react-19", + "https://react.dev/blog/2022/03/29/react-v18", + "https://legacy.reactjs.org/blog/2020/10/20/react-v17.html", + ], + "cycles": { + "17": { + "release_date": "2020-10-20", + "end_of_support": None, + "end_of_life": None, + }, + "18": { + "release_date": "2022-03-29", + "end_of_support": None, + "end_of_life": None, + }, + "19": { + "release_date": "2024-12-05", + "end_of_support": None, + "end_of_life": None, + }, + }, + }, + # ------------------------------------------------------------------------- + # Vue.js + # Source: https://v2.vuejs.org/eol/ + # https://vuejs.org/guide/introduction.html + # Note: Vue 2 reached EOL on Dec 31, 2023. Vue 3 is current and does not + # have a published EOL date. + # + # Common PURLs across ecosystems: + # npm: pkg:npm/vue@3.4.0, pkg:npm/vue@2.7.14 + # pkg:npm/@vue/runtime-core@3.4.0, pkg:npm/@vue/compiler-sfc@3.4.0 + # ------------------------------------------------------------------------- + "vue": { + "name_patterns": [ + "vue", + "@vue/runtime-core", # Vue 3 core packages + "@vue/compiler-sfc", + "@vue/reactivity", + "@vue/shared", + ], + "purl_types": ["npm"], + "version_extract": "major", + "references": [ + "https://v2.vuejs.org/eol/", + "https://vuejs.org/guide/introduction.html", + ], + "cycles": { + "2": { + "release_date": None, + "end_of_support": "2023-12-31", + "end_of_life": "2023-12-31", + }, + "3": { + "release_date": None, + "end_of_support": None, + "end_of_life": None, + }, + }, + }, +} + + +def get_package_lifecycle_entry(package_name: str) -> Optional[PackageLifecycleEntry]: + """ + Find the lifecycle entry that matches a package name. + + Args: + package_name: Package name to match + + Returns: + PackageLifecycleEntry or None if no match found + """ + import fnmatch + + name_lower = package_name.lower() + + for entry_key, entry in PACKAGE_LIFECYCLE.items(): + patterns = entry.get("name_patterns", []) + for pattern in patterns: + if fnmatch.fnmatch(name_lower, pattern.lower()): + return entry + + return None + + +def extract_version_cycle(version: str, version_extract: Optional[str] = None) -> Optional[str]: + """ + Extract the version cycle from a full version string. + + Args: + version: Full version string (e.g., "3.12.7", "4.2.9", "19.0.1") + version_extract: "major" or "major.minor" (default: "major.minor") + + Returns: + Version cycle string (e.g., "3.12", "4.2", "19") or None + """ + if not version: + return None + + # Remove common prefixes + v = version.lstrip("v") + + # Split on dots + parts = v.split(".") + + if not parts: + return None + + # Handle version_extract mode + if version_extract == "major": + # Return just the major version + # Handle cases like "3.12" where there's no patch + return parts[0] if parts[0].isdigit() else None + else: + # Default: major.minor + if len(parts) >= 2: + major, minor = parts[0], parts[1] + # Handle minor versions with suffixes (e.g., "12-rc1") + minor = minor.split("-")[0].split("+")[0] + if major.isdigit() and minor.isdigit(): + return f"{major}.{minor}" + elif len(parts) == 1 and parts[0].isdigit(): + # Single number version (e.g., "19") - return as-is + return parts[0] + + return None + + +def get_package_lifecycle( + package_name: str, + version: str, + purl_type: Optional[str] = None, +) -> Optional[LifecycleDates]: + """ + Get lifecycle dates for a package version. + + Args: + package_name: Package name (e.g., "django", "python3") + version: Package version (e.g., "4.2.9", "3.12.1") + purl_type: Optional PURL type to filter matches (e.g., "pypi", "npm") + + Returns: + LifecycleDates dict or None if not found + """ + entry = get_package_lifecycle_entry(package_name) + if not entry: + return None + + # Check PURL type filter + allowed_types = entry.get("purl_types") + if allowed_types is not None and purl_type is not None: + if purl_type.lower() not in [t.lower() for t in allowed_types]: + return None + + # Extract version cycle + version_extract = entry.get("version_extract", "major.minor") + cycle = extract_version_cycle(version, version_extract) + if not cycle: + return None + + # Look up cycle in the entry's cycles + cycles = entry.get("cycles", {}) + return cycles.get(cycle) + + +def get_distro_lifecycle(distro_name: str, version: str) -> Optional[LifecycleDates]: + """ + Get lifecycle dates for an operating system version. + + Args: + distro_name: OS name (e.g., "debian", "ubuntu", "alpine") + version: OS version (e.g., "12.12", "22.04", "3.20") + + Returns: + LifecycleDates dict or None if not found + """ + import re + + distro_lower = distro_name.lower() + + # Map common OS name variations to our canonical names + distro_mappings = { + "alma": "almalinux", + "amazon": "amazonlinux", + "amzn": "amazonlinux", + } + distro_key = distro_mappings.get(distro_lower, distro_lower) + + distro_data = DISTRO_LIFECYCLE.get(distro_key) + if not distro_data: + return None + + # Normalize version string + # Handle complex versions like "2023.10.20260105 (Amazon Linux)" -> "2023" + # or "9.7 (Blue Onyx)" -> "9" + version_clean = version.split("(")[0].strip() # Remove parenthetical suffixes + + # Try exact match first + if version_clean in distro_data: + return distro_data[version_clean] + + # Try progressively shorter version prefixes + # e.g., "12.12" -> "12", "3.20.1" -> "3.20" -> "3" + parts = version_clean.split(".") + for i in range(len(parts) - 1, 0, -1): + prefix = ".".join(parts[:i]) + if prefix in distro_data: + return distro_data[prefix] + + # For Amazon Linux, try extracting just the year (2023, 2) + if distro_key == "amazonlinux": + year_match = re.match(r"^(\d{4}|\d)", version_clean) + if year_match: + year = year_match.group(1) + if year in distro_data: + return distro_data[year] + + return None diff --git a/sbomify_action/_enrichment/sources/__init__.py b/sbomify_action/_enrichment/sources/__init__.py index 75e6f9a..2a3d29a 100644 --- a/sbomify_action/_enrichment/sources/__init__.py +++ b/sbomify_action/_enrichment/sources/__init__.py @@ -6,6 +6,7 @@ from .depsdev import DepsDevSource from .ecosystems import EcosystemsSource from .license_db import LicenseDBSource +from .lifecycle import LifecycleSource from .pubdev import PubDevSource from .purl import PURLSource from .pypi import PyPISource @@ -22,4 +23,5 @@ "ClearlyDefinedSource", "RepologySource", "LicenseDBSource", + "LifecycleSource", ] diff --git a/sbomify_action/_enrichment/sources/license_db.py b/sbomify_action/_enrichment/sources/license_db.py index d8f7d40..4d9c8af 100644 --- a/sbomify_action/_enrichment/sources/license_db.py +++ b/sbomify_action/_enrichment/sources/license_db.py @@ -10,9 +10,8 @@ Strategy: 1. Check local cache first -2. Try to download from the latest GitHub release -3. If not found in release, generate the database locally (fallback) -4. Cache the result locally for future use +2. Try to download from recent GitHub releases (checks up to 5 releases) +3. Cache the result locally for future use """ import gzip @@ -20,6 +19,7 @@ import json import os import re +import threading from pathlib import Path from typing import Any, Dict, Optional, Tuple @@ -32,7 +32,10 @@ # GitHub repository hosting the license databases GITHUB_REPO = "sbomify/github-action" -GITHUB_RELEASES_API = f"https://api.github.com/repos/{GITHUB_REPO}/releases/latest" +GITHUB_RELEASES_API = f"https://api.github.com/repos/{GITHUB_REPO}/releases" + +# Number of recent releases to check when looking for a database +MAX_RELEASES_TO_CHECK = 5 # Default timeout for downloads DEFAULT_TIMEOUT = 30 @@ -41,8 +44,13 @@ # Cache directory (XDG compliant) DEFAULT_CACHE_DIR = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "sbomify" / "license-db" -# Flag to disable local generation (useful for testing or CI environments) -DISABLE_LOCAL_GENERATION = os.environ.get("SBOMIFY_DISABLE_LICENSE_DB_GENERATION", "").lower() in ("1", "true", "yes") +# Local generation is disabled by default (too slow for Ubuntu/Debian - takes hours) +# Set SBOMIFY_ENABLE_LICENSE_DB_GENERATION=true to enable local generation fallback +DISABLE_LOCAL_GENERATION = os.environ.get("SBOMIFY_ENABLE_LICENSE_DB_GENERATION", "").lower() not in ( + "1", + "true", + "yes", +) # Supported distros and their database file patterns SUPPORTED_DISTROS = { @@ -62,6 +70,11 @@ "type": "rpm", "versions": ["stream8", "stream9"], }, + "debian": { + "type": "deb", + "versions": ["11", "12", "13"], + "codenames": {"11": "bullseye", "12": "bookworm", "13": "trixie"}, + }, "ubuntu": { "type": "deb", "versions": ["20.04", "22.04", "24.04"], @@ -85,15 +98,19 @@ # Key: (distro, version) -> Dict of PURL -> license data _db_cache: Dict[Tuple[str, str], Dict[str, Any]] = {} -# Cache for latest release assets -_latest_release_assets: Optional[Dict[str, str]] = None # filename -> download_url +# Cache for release assets across multiple releases +# Key: filename -> download_url (from the first release that has it) +_release_assets_cache: Optional[Dict[str, str]] = None + +# Lock for database loading/generation to prevent race conditions +_db_lock = threading.Lock() def clear_cache() -> None: """Clear the license database cache.""" _db_cache.clear() - global _latest_release_assets - _latest_release_assets = None + global _release_assets_cache + _release_assets_cache = None def get_cache_dir() -> Path: @@ -152,7 +169,7 @@ def supports(self, purl: PackageURL) -> bool: return namespace in ("alpine", "wolfi") elif purl.type == "deb": namespace = (purl.namespace or "").lower() - return namespace == "ubuntu" + return namespace in ("debian", "ubuntu") elif purl.type == "rpm": namespace = (purl.namespace or "").lower() return namespace in ("rocky", "almalinux", "amazonlinux", "centos", "fedora") @@ -231,20 +248,8 @@ def fetch(self, purl: PackageURL, session: requests.Session) -> Optional[Normali if maintainer_email: field_sources["maintainer_email"] = self.name - # CLE (Common Lifecycle Enumeration) data from database metadata - # These are distro-level lifecycle dates applied to all packages - # See: https://sbomify.com/compliance/cle/ - db_metadata = db.get("metadata", {}) - cle_eos = db_metadata.get("end_of_support") - cle_eol = db_metadata.get("end_of_life") - cle_release_date = db_metadata.get("release_date") - - if cle_eos: - field_sources["cle_eos"] = self.name - if cle_eol: - field_sources["cle_eol"] = self.name - if cle_release_date: - field_sources["cle_release_date"] = self.name + # Note: CLE (lifecycle) data is now provided by the dedicated LifecycleSource + # See: sbomify_action/_enrichment/sources/lifecycle.py return NormalizedMetadata( licenses=licenses, @@ -254,9 +259,6 @@ def fetch(self, purl: PackageURL, session: requests.Session) -> Optional[Normali download_url=download_url, maintainer_name=maintainer_name, maintainer_email=maintainer_email, - cle_eos=cle_eos, - cle_eol=cle_eol, - cle_release_date=cle_release_date, source=self.name, field_sources=field_sources, ) @@ -345,10 +347,12 @@ def _load_database(self, distro: str, version: str, session: requests.Session) - Load the license database for a distro/version. Strategy: - 1. Check in-memory cache - 2. Check local file cache - 3. Try to download from latest GitHub release - 4. Fallback: generate locally if download fails + 1. Check in-memory cache (fast path, no lock) + 2. Acquire lock to prevent race conditions + 3. Double-check cache after acquiring lock + 4. Check local file cache + 5. Try to download from latest GitHub release + 6. Fallback: generate locally if download fails Args: distro: Distribution name (ubuntu, rocky, etc.) @@ -360,44 +364,50 @@ def _load_database(self, distro: str, version: str, session: requests.Session) - """ cache_key = (distro, version) - # Check in-memory cache + # Fast path: check in-memory cache without lock if cache_key in _db_cache: return _db_cache[cache_key] - # Check local file cache - cache_file = self._cache_dir / f"{distro}-{version}.json.gz" - if cache_file.exists(): - try: - db = self._load_from_file(cache_file) - _db_cache[cache_key] = db - logger.debug(f"Loaded license database from cache: {cache_file}") - return db - except Exception as e: - logger.warning(f"Failed to load cached database {cache_file}: {e}") - cache_file.unlink(missing_ok=True) - - # Try to download from latest GitHub Release - db = self._download_from_release(distro, version, session) - if db: - _db_cache[cache_key] = db - # Save to local cache - try: - self._save_to_file(cache_file, db) - logger.info(f"Cached license database: {cache_file}") - except Exception as e: - logger.warning(f"Failed to save database to cache: {e}") - return db - - # Fallback: generate locally - if not DISABLE_LOCAL_GENERATION: - logger.info(f"Database not found in release, generating locally for {distro}-{version}...") - db = self._generate_locally(distro, version, cache_file) + # Acquire lock to prevent race conditions during download/generation + with _db_lock: + # Double-check cache after acquiring lock (another thread may have loaded it) + if cache_key in _db_cache: + return _db_cache[cache_key] + + # Check local file cache + cache_file = self._cache_dir / f"{distro}-{version}.json.gz" + if cache_file.exists(): + try: + db = self._load_from_file(cache_file) + _db_cache[cache_key] = db + logger.debug(f"Loaded license database from cache: {cache_file}") + return db + except Exception as e: + logger.warning(f"Failed to load cached database {cache_file}: {e}") + cache_file.unlink(missing_ok=True) + + # Try to download from latest GitHub Release + db = self._download_from_release(distro, version, session) if db: _db_cache[cache_key] = db + # Save to local cache + try: + self._save_to_file(cache_file, db) + logger.info(f"Cached license database: {cache_file}") + except Exception as e: + logger.warning(f"Failed to save database to cache: {e}") return db - logger.debug(f"No license database available for {distro}-{version}") - return None + # Fallback: generate locally + if not DISABLE_LOCAL_GENERATION: + logger.info(f"Database not found in release, generating locally for {distro}-{version}...") + db = self._generate_locally(distro, version, cache_file) + if db: + _db_cache[cache_key] = db + return db + + logger.debug(f"No license database available for {distro}-{version}") + return None def _load_from_file(self, path: Path) -> Dict[str, Any]: """Load a gzipped JSON database from file.""" @@ -412,7 +422,7 @@ def _save_to_file(self, path: Path, db: Dict[str, Any]) -> None: def _download_from_release(self, distro: str, version: str, session: requests.Session) -> Optional[Dict[str, Any]]: """ - Download database from the latest GitHub Release. + Download database from GitHub Releases, checking recent releases. Args: distro: Distribution name @@ -424,58 +434,82 @@ def _download_from_release(self, distro: str, version: str, session: requests.Se """ filename = f"{distro}-{version}.json.gz" - # Get latest release assets - assets = self._get_latest_release_assets(session) + # Get assets from recent releases + assets = self._get_release_assets(session) if not assets: - logger.debug("No license database assets found in latest release") + logger.debug("No license database assets found in any recent release") return None - # Check if our file exists in the release + # Check if our file exists in any release download_url = assets.get(filename) if not download_url: - logger.debug(f"License database not found in latest release: {filename}") + logger.debug(f"License database not found in recent releases: {filename}") return None return self._download_asset(download_url, session) - def _get_latest_release_assets(self, session: requests.Session) -> Dict[str, str]: + def _get_release_assets(self, session: requests.Session) -> Dict[str, str]: """ - Get assets from the latest GitHub release. + Get assets from recent GitHub releases. + + Checks up to MAX_RELEASES_TO_CHECK releases, collecting all unique + database files. If a file exists in multiple releases, the most + recent version is used. Returns: Dict mapping filename -> download_url """ - global _latest_release_assets - if _latest_release_assets is not None: - return _latest_release_assets + global _release_assets_cache + if _release_assets_cache is not None: + return _release_assets_cache try: - response = session.get(GITHUB_RELEASES_API, timeout=DEFAULT_TIMEOUT) + # Fetch recent releases (GitHub returns them newest first) + response = session.get( + GITHUB_RELEASES_API, + params={"per_page": MAX_RELEASES_TO_CHECK}, + timeout=DEFAULT_TIMEOUT, + ) response.raise_for_status() - release = response.json() - assets = {} - for asset in release.get("assets", []): - name = asset.get("name", "") - url = asset.get("browser_download_url", "") - if name and url and name.endswith(".json.gz"): - assets[name] = url + releases = response.json() + if not releases: + logger.debug("No releases found yet") + _release_assets_cache = {} + return {} + + assets: Dict[str, str] = {} + releases_checked = 0 + + for release in releases: + tag = release.get("tag_name", "unknown") + release_assets = release.get("assets", []) + + for asset in release_assets: + name = asset.get("name", "") + url = asset.get("browser_download_url", "") + # Only add if not already found in a newer release + if name and url and name.endswith(".json.gz") and name not in assets: + assets[name] = url + logger.debug(f"Found {name} in release {tag}") + + releases_checked += 1 - _latest_release_assets = assets - logger.debug(f"Found {len(assets)} license database(s) in latest release") + _release_assets_cache = assets + logger.debug(f"Found {len(assets)} license database(s) across {releases_checked} release(s)") return assets except requests.exceptions.HTTPError as e: if e.response is not None and e.response.status_code == 404: logger.debug("No releases found yet") else: - logger.warning(f"Failed to fetch latest release: {e}") - _latest_release_assets = {} + logger.warning(f"Failed to fetch releases: {e}") + _release_assets_cache = {} return {} except Exception as e: - logger.warning(f"Failed to fetch latest release: {e}") - _latest_release_assets = {} + logger.warning(f"Failed to fetch releases: {e}") + _release_assets_cache = {} return {} def _download_asset(self, url: str, session: requests.Session) -> Optional[Dict[str, Any]]: @@ -511,6 +545,7 @@ def _generate_locally(self, distro: str, version: str, output_path: Path) -> Opt # Import generator functions from ..license_db_generator import ( generate_alpine_db, + generate_debian_db, generate_rpm_db, generate_ubuntu_db, generate_wolfi_db, @@ -527,6 +562,8 @@ def _generate_locally(self, distro: str, version: str, output_path: Path) -> Opt generate_wolfi_db(output_path) elif distro == "ubuntu": generate_ubuntu_db(version, output_path) + elif distro == "debian": + generate_debian_db(version, output_path) elif distro in ("rocky", "almalinux", "fedora", "amazonlinux", "centos"): generate_rpm_db(distro, version, output_path) else: diff --git a/sbomify_action/_enrichment/sources/lifecycle.py b/sbomify_action/_enrichment/sources/lifecycle.py new file mode 100644 index 0000000..2207eea --- /dev/null +++ b/sbomify_action/_enrichment/sources/lifecycle.py @@ -0,0 +1,214 @@ +"""Lifecycle data source for CLE (Common Lifecycle Enumeration) enrichment. + +This source provides lifecycle dates (release_date, end_of_support, end_of_life) +for language runtimes and frameworks that we explicitly track: +- Python, PHP, Go, Rust +- Django, Rails, Laravel, React, Vue + +Note: We do NOT provide lifecycle data for arbitrary OS packages (curl, nginx, etc.) +because the PURL doesn't reliably indicate the distro version, and the package's +lifecycle is not the same as the distro's lifecycle. + +Priority: 5 (high priority - local data, no API calls) +Supports: Packages matching PACKAGE_LIFECYCLE patterns only +""" + +import fnmatch +from typing import Dict, Optional + +import requests +from packageurl import PackageURL + +from sbomify_action.logging_config import logger + +from ..lifecycle_data import ( + PACKAGE_LIFECYCLE, + PackageLifecycleEntry, + extract_version_cycle, +) +from ..metadata import NormalizedMetadata + +# Simple in-memory cache for lifecycle lookups +_cache: Dict[str, Optional[NormalizedMetadata]] = {} + + +def clear_cache() -> None: + """Clear the lifecycle metadata cache.""" + _cache.clear() + + +class LifecycleSource: + """ + Data source for lifecycle (CLE) information. + + This source provides CLE dates for language runtimes and frameworks: + - Python, PHP, Go, Rust (matched across all PURL types) + - Django, Rails, Laravel, React, Vue (matched by ecosystem) + + Matched by name patterns: + - "python" matches pkg:pypi/python, pkg:deb/ubuntu/python3, etc. + - "django" matches pkg:pypi/django + - "rails" matches pkg:gem/rails + + Note: We do NOT provide lifecycle data for arbitrary OS packages. + A PURL like pkg:apk/alpine/curl@8.0 doesn't tell us the Alpine version, + and curl's upstream lifecycle is different from the distro's lifecycle. + + Priority: 5 (high - local data, no network calls) + Supports: Packages matching PACKAGE_LIFECYCLE patterns only + """ + + @property + def name(self) -> str: + return "lifecycle" + + @property + def priority(self) -> int: + # Priority 5: Very high - local data with no API calls + return 5 + + def supports(self, purl: PackageURL) -> bool: + """ + Check if this source supports the given PURL. + + Returns True only if the package name matches a pattern in PACKAGE_LIFECYCLE. + """ + entry = self._find_package_entry(purl) + return entry is not None + + def fetch(self, purl: PackageURL, session: requests.Session) -> Optional[NormalizedMetadata]: + """ + Fetch lifecycle metadata for the given PURL. + + Only provides CLE data for packages we explicitly track + (Python, PHP, Go, Rust, Django, Rails, Laravel, React, Vue). + + Args: + purl: Parsed PackageURL object + session: requests.Session (not used - local data only) + + Returns: + NormalizedMetadata with CLE fields if found, None otherwise + """ + # Build cache key + namespace = purl.namespace or "" + cache_key = f"lifecycle:{purl.type}:{namespace}:{purl.name}:{purl.version or 'latest'}" + + # Check cache + if cache_key in _cache: + logger.debug(f"Cache hit (lifecycle): {purl.name}") + return _cache[cache_key] + + # Fetch package lifecycle + metadata = self._fetch_package_lifecycle(purl) + _cache[cache_key] = metadata + return metadata + + def _fetch_package_lifecycle(self, purl: PackageURL) -> Optional[NormalizedMetadata]: + """Fetch lifecycle data for a language runtime or framework.""" + entry = self._find_package_entry(purl) + if not entry: + logger.debug(f"No lifecycle data found for: {purl.name}") + return None + + # Extract version cycle + version = purl.version or "" + version_extract = entry.get("version_extract", "major.minor") + cycle = extract_version_cycle(version, version_extract) + + if not cycle: + logger.debug(f"Could not extract version cycle from {purl.name}@{version}") + return None + + # Look up lifecycle dates for this cycle + cycles = entry.get("cycles", {}) + lifecycle_dates = cycles.get(cycle) + + if not lifecycle_dates: + logger.debug(f"No lifecycle data for {purl.name} cycle {cycle}") + return None + + return self._build_metadata(lifecycle_dates, f"{purl.name} {cycle}") + + def _build_metadata(self, lifecycle_dates: Dict, context: str) -> Optional[NormalizedMetadata]: + """Build NormalizedMetadata from lifecycle dates.""" + cle_eos = lifecycle_dates.get("end_of_support") + cle_eol = lifecycle_dates.get("end_of_life") + cle_release_date = lifecycle_dates.get("release_date") + + # Only return metadata if we have at least one CLE field + if not any([cle_eos, cle_eol, cle_release_date]): + logger.debug(f"No CLE dates available for {context}") + return None + + # Build field sources + field_sources: Dict[str, str] = {} + if cle_eos: + field_sources["cle_eos"] = self.name + if cle_eol: + field_sources["cle_eol"] = self.name + if cle_release_date: + field_sources["cle_release_date"] = self.name + + logger.debug(f"Found lifecycle data for {context}: EOS={cle_eos}, EOL={cle_eol}") + + return NormalizedMetadata( + cle_eos=cle_eos, + cle_eol=cle_eol, + cle_release_date=cle_release_date, + source=self.name, + field_sources=field_sources, + ) + + def _find_package_entry(self, purl: PackageURL) -> Optional[PackageLifecycleEntry]: + """ + Find the PACKAGE_LIFECYCLE entry matching the given PURL. + + Checks name patterns, namespace (for composer packages), and PURL type filters. + + For composer packages (like Laravel), the namespace is significant: + - pkg:composer/laravel/framework matches patterns against both "laravel" and "framework" + + Args: + purl: Parsed PackageURL object + + Returns: + PackageLifecycleEntry if found, None otherwise + """ + name_lower = purl.name.lower() + namespace_lower = (purl.namespace or "").lower() + purl_type = purl.type.lower() + + # Build list of names to check (name + namespace for namespaced packages) + names_to_check = [name_lower] + if namespace_lower: + names_to_check.append(namespace_lower) + # Also check combined namespace/name format + names_to_check.append(f"{namespace_lower}/{name_lower}") + + for entry_key, entry in PACKAGE_LIFECYCLE.items(): + # Check name patterns against all possible name variations + patterns = entry.get("name_patterns", []) + name_matches = False + + for pattern in patterns: + pattern_lower = pattern.lower() + for name in names_to_check: + if fnmatch.fnmatch(name, pattern_lower): + name_matches = True + break + if name_matches: + break + + if not name_matches: + continue + + # Check PURL type filter + allowed_types = entry.get("purl_types") + if allowed_types is not None: + if purl_type not in [t.lower() for t in allowed_types]: + continue + + return entry + + return None diff --git a/sbomify_action/_enrichment/sources/purl.py b/sbomify_action/_enrichment/sources/purl.py index 70fa592..e5ba2cb 100644 --- a/sbomify_action/_enrichment/sources/purl.py +++ b/sbomify_action/_enrichment/sources/purl.py @@ -32,10 +32,12 @@ "rhel": "Red Hat, Inc.", "centos": "CentOS Project", "fedora": "Fedora Project", - "amazon": "Amazon Web Services", + "amazon": "Amazon Web Services, Inc. (AWS)", + "amazonlinux": "Amazon Web Services, Inc. (AWS)", "oracle": "Oracle Corporation", "rocky": "Rocky Enterprise Software Foundation", "almalinux": "AlmaLinux OS Foundation", + "alma": "AlmaLinux OS Foundation", # Trivy uses "alma" as OS name # Alpine (apk) "alpine": "Alpine Linux", # Other distros diff --git a/sbomify_action/enrichment.py b/sbomify_action/enrichment.py index efa79ca..d51b054 100644 --- a/sbomify_action/enrichment.py +++ b/sbomify_action/enrichment.py @@ -74,6 +74,7 @@ # Import from plugin architecture from ._enrichment.enricher import Enricher, clear_all_caches +from ._enrichment.lifecycle_data import get_distro_lifecycle from ._enrichment.metadata import NormalizedMetadata from ._enrichment.sanitization import ( sanitize_description, @@ -96,6 +97,7 @@ from .logging_config import logger from .serialization import ( link_root_dependencies, + sanitize_cyclonedx_licenses, sanitize_dependency_graph, sanitize_purls, sanitize_spdx_json_file, @@ -617,19 +619,49 @@ def _add_external_ref(category: ExternalPackageRefCategory, ref_type: str, locat def _enrich_os_component(component: Component) -> List[str]: - """Enrich an operating-system type component with supplier info.""" + """Enrich an operating-system type component with supplier and lifecycle info.""" if component.type.name.lower() != COMPONENT_TYPE_OPERATING_SYSTEM: return [] added_fields = [] os_name = component.name.lower() if component.name else "" + os_version = component.version or "" + # Add publisher/supplier if missing if not component.publisher: supplier = NAMESPACE_TO_SUPPLIER.get(os_name) if supplier: component.publisher = supplier added_fields.append(f"publisher ({supplier})") + # Add CLE (Common Lifecycle Enumeration) properties + if os_name and os_version: + lifecycle = get_distro_lifecycle(os_name, os_version) + if lifecycle: + # Initialize properties set if needed + if component.properties is None: + component.properties = set() + + def _add_cle_property(name: str, value: str) -> bool: + """Add a CLE property if not already present.""" + for prop in component.properties: + if prop.name == name: + return False + component.properties.add(Property(name=name, value=value)) + return True + + if lifecycle.get("release_date"): + if _add_cle_property("cle:releaseDate", lifecycle["release_date"]): + added_fields.append(f"cle:releaseDate ({lifecycle['release_date']})") + + if lifecycle.get("end_of_support"): + if _add_cle_property("cle:eos", lifecycle["end_of_support"]): + added_fields.append(f"cle:eos ({lifecycle['end_of_support']})") + + if lifecycle.get("end_of_life"): + if _add_cle_property("cle:eol", lifecycle["end_of_life"]): + added_fields.append(f"cle:eol ({lifecycle['end_of_life']})") + return added_fields @@ -973,6 +1005,9 @@ def _enrich_cyclonedx_sbom(data: Dict[str, Any], input_path: Path, output_path: components.append(component_data) data["metadata"]["tools"] = {"components": components, "services": []} + # Sanitize invalid license IDs (e.g., Trivy puts non-SPDX IDs in license.id field) + sanitize_cyclonedx_licenses(data) + # Parse BOM try: bom = Bom.from_json(data) diff --git a/sbomify_action/serialization.py b/sbomify_action/serialization.py index 2ace3a6..4680197 100644 --- a/sbomify_action/serialization.py +++ b/sbomify_action/serialization.py @@ -760,3 +760,87 @@ def get_supported_spdx_versions() -> list[str]: List of version strings (e.g., ["2.2", "2.3"]) """ return SUPPORTED_SPDX_VERSIONS.copy() + + +# ============================================================================ +# License Sanitization +# ============================================================================ + + +def _is_valid_spdx_license_id(license_id: str) -> bool: + """ + Check if a string is a valid SPDX license ID using the license-expression library. + + Args: + license_id: The license ID string to check + + Returns: + True if it's a valid SPDX license ID, False otherwise + """ + if not license_id: + return False + + # Import here to avoid circular imports + from ._enrichment.license_utils import validate_spdx_expression + + return validate_spdx_expression(license_id) + + +def sanitize_cyclonedx_licenses(data: dict) -> int: + """ + Sanitize CycloneDX license data by moving invalid license IDs to license names. + + Some SBOM generators (like Trivy) incorrectly put non-SPDX license strings + in the license.id field, which causes schema validation failures. + This function moves such values to the license.name field instead. + + Args: + data: CycloneDX SBOM data as a dict (modified in place) + + Returns: + Number of licenses that were sanitized + """ + sanitized_count = 0 + + def _sanitize_license_choices(license_choices: list) -> int: + """Process a list of licenseChoice objects.""" + count = 0 + for choice in license_choices: + if not isinstance(choice, dict): + continue + + license_obj = choice.get("license") + if not isinstance(license_obj, dict): + continue + + license_id = license_obj.get("id") + if license_id and not _is_valid_spdx_license_id(license_id): + # Move id to name + logger.debug(f"Sanitizing invalid license ID: {license_id} -> name") + del license_obj["id"] + license_obj["name"] = license_id + count += 1 + + return count + + # Process metadata licenses + metadata = data.get("metadata", {}) + if "licenses" in metadata: + sanitized_count += _sanitize_license_choices(metadata["licenses"]) + + # Process component licenses + components = data.get("components", []) + for component in components: + if "licenses" in component: + sanitized_count += _sanitize_license_choices(component["licenses"]) + + # Process service licenses (if present) + services = data.get("services", []) + for service in services: + if "licenses" in service: + sanitized_count += _sanitize_license_choices(service["licenses"]) + + if sanitized_count > 0: + logger.info(f"Sanitized {sanitized_count} invalid license ID(s) to license name(s)") + + return sanitized_count diff --git a/tests/test_enrichment_module.py b/tests/test_enrichment_module.py index a998bb9..6bbc41c 100644 --- a/tests/test_enrichment_module.py +++ b/tests/test_enrichment_module.py @@ -800,7 +800,7 @@ class TestOSComponentEnrichment: """Test enriching operating-system type components.""" def test_enrich_debian_os(self): - """Test enriching a Debian OS component.""" + """Test enriching a Debian OS component with publisher and CLE.""" component = Component(name="debian", version="12", type=ComponentType.OPERATING_SYSTEM) added_fields = _enrich_os_component(component) @@ -808,6 +808,22 @@ def test_enrich_debian_os(self): assert component.publisher == "Debian Project" assert "publisher" in " ".join(added_fields) + # Check CLE properties + props = {p.name: p.value for p in component.properties} + assert props.get("cle:releaseDate") == "2023-06-10" + assert props.get("cle:eos") == "2026-06-10" + assert props.get("cle:eol") == "2028-06-30" + + def test_enrich_debian_os_with_point_release(self): + """Test enriching Debian with point release version (12.12 -> 12).""" + component = Component(name="debian", version="12.12", type=ComponentType.OPERATING_SYSTEM) + + _enrich_os_component(component) + + # Should still get Debian 12 lifecycle data + props = {p.name: p.value for p in component.properties} + assert props.get("cle:eol") == "2028-06-30" + def test_enrich_ubuntu_os(self): """Test enriching an Ubuntu OS component.""" component = Component(name="ubuntu", version="22.04", type=ComponentType.OPERATING_SYSTEM) @@ -816,6 +832,12 @@ def test_enrich_ubuntu_os(self): assert component.publisher == "Canonical Ltd" + # Check CLE properties + props = {p.name: p.value for p in component.properties} + assert props.get("cle:releaseDate") == "2022-04" + assert props.get("cle:eos") == "2027-06" + assert props.get("cle:eol") == "2032-04" + def test_enrich_alpine_os(self): """Test enriching an Alpine OS component.""" component = Component(name="alpine", version="3.19", type=ComponentType.OPERATING_SYSTEM) @@ -824,14 +846,20 @@ def test_enrich_alpine_os(self): assert component.publisher == "Alpine Linux" + # Check CLE properties + props = {p.name: p.value for p in component.properties} + assert props.get("cle:releaseDate") == "2023-12-07" + assert props.get("cle:eol") == "2025-11-01" + def test_enrich_unknown_os(self): - """Test that unknown OS is not enriched.""" + """Test that unknown OS gets no CLE data.""" component = Component(name="unknownos", version="1.0", type=ComponentType.OPERATING_SYSTEM) - added_fields = _enrich_os_component(component) + _enrich_os_component(component) assert component.publisher is None - assert added_fields == [] + # No CLE properties for unknown OS + assert component.properties is None or len(component.properties) == 0 def test_enrich_non_os_type(self): """Test that non-OS types are not enriched.""" @@ -1093,8 +1121,10 @@ def test_enrich_os_packages_with_purl_fallback(self, tmp_path): component = result["components"][0] assert component["publisher"] == "Canonical Ltd" # Check for enrichment source property + # May include "lifecycle" (for distro CLE) and/or "purl" (for publisher) props = {p["name"]: p["value"] for p in component.get("properties", [])} - assert props.get("sbomify:enrichment:source") == "purl" + source = props.get("sbomify:enrichment:source", "") + assert "purl" in source or "lifecycle" in source # ============================================================================= diff --git a/tests/test_lifecycle_enrichment.py b/tests/test_lifecycle_enrichment.py new file mode 100644 index 0000000..aeca49a --- /dev/null +++ b/tests/test_lifecycle_enrichment.py @@ -0,0 +1,1150 @@ +""" +Tests for the lifecycle enrichment module. + +Tests cover: +- DISTRO_LIFECYCLE data structure and helper functions +- PACKAGE_LIFECYCLE data structure and matching +- LifecycleSource DataSource implementation +- Version cycle extraction +- PURL matching for various package types +""" + +from unittest.mock import Mock + +import pytest +from packageurl import PackageURL + +from sbomify_action._enrichment.lifecycle_data import ( + DISTRO_LIFECYCLE, + PACKAGE_LIFECYCLE, + extract_version_cycle, + get_distro_lifecycle, + get_package_lifecycle, + get_package_lifecycle_entry, +) +from sbomify_action._enrichment.sources.lifecycle import ( + LifecycleSource, + clear_cache, +) + +# ============================================================================= +# Test Fixtures +# ============================================================================= + + +@pytest.fixture(autouse=True) +def clear_lifecycle_cache(): + """Clear lifecycle cache before each test.""" + clear_cache() + yield + + +@pytest.fixture +def mock_session(): + """Create a mock requests session.""" + return Mock() + + +# ============================================================================= +# Test DISTRO_LIFECYCLE Data +# ============================================================================= + + +class TestDistroLifecycleData: + """Test DISTRO_LIFECYCLE data structure.""" + + def test_distro_lifecycle_contains_expected_distros(self): + """Test that all expected distros are in DISTRO_LIFECYCLE.""" + expected_distros = [ + "wolfi", + "alpine", + "rocky", + "almalinux", + "amazonlinux", + "centos", + "fedora", + "ubuntu", + "debian", + ] + for distro in expected_distros: + assert distro in DISTRO_LIFECYCLE, f"Missing distro: {distro}" + + def test_alpine_versions_present(self): + """Test Alpine versions are present.""" + alpine = DISTRO_LIFECYCLE["alpine"] + expected_versions = ["3.13", "3.14", "3.15", "3.16", "3.17", "3.18", "3.19", "3.20", "3.21"] + for version in expected_versions: + assert version in alpine, f"Missing Alpine version: {version}" + + def test_ubuntu_versions_present(self): + """Test Ubuntu versions are present.""" + ubuntu = DISTRO_LIFECYCLE["ubuntu"] + expected_versions = ["20.04", "22.04", "24.04"] + for version in expected_versions: + assert version in ubuntu, f"Missing Ubuntu version: {version}" + + def test_distro_lifecycle_dates_format(self): + """Test that lifecycle dates have correct format.""" + for distro_name, versions in DISTRO_LIFECYCLE.items(): + for version, dates in versions.items(): + # Check required keys exist + assert "release_date" in dates or dates.get("release_date") is None + assert "end_of_support" in dates or dates.get("end_of_support") is None + assert "end_of_life" in dates or dates.get("end_of_life") is None + + def test_wolfi_rolling_release(self): + """Test Wolfi rolling release has None dates.""" + wolfi = DISTRO_LIFECYCLE["wolfi"]["rolling"] + assert wolfi["release_date"] is None + assert wolfi["end_of_support"] is None + assert wolfi["end_of_life"] is None + + def test_alpine_has_lifecycle_dates(self): + """Test Alpine 3.20 has lifecycle dates.""" + alpine_320 = DISTRO_LIFECYCLE["alpine"]["3.20"] + assert alpine_320["release_date"] == "2024-05-22" + assert alpine_320["end_of_support"] == "2026-04-01" + assert alpine_320["end_of_life"] == "2026-04-01" + + +class TestGetDistroLifecycle: + """Test get_distro_lifecycle helper function.""" + + def test_get_ubuntu_lifecycle(self): + """Test getting Ubuntu 24.04 lifecycle.""" + lifecycle = get_distro_lifecycle("ubuntu", "24.04") + assert lifecycle is not None + assert lifecycle["release_date"] == "2024-04" + assert lifecycle["end_of_support"] == "2029-05" + assert lifecycle["end_of_life"] == "2034-04" + + def test_get_alpine_lifecycle(self): + """Test getting Alpine 3.21 lifecycle.""" + lifecycle = get_distro_lifecycle("alpine", "3.21") + assert lifecycle is not None + assert lifecycle["release_date"] == "2024-12-05" + + def test_get_unknown_distro_returns_none(self): + """Test that unknown distro returns None.""" + lifecycle = get_distro_lifecycle("unknown", "1.0") + assert lifecycle is None + + def test_get_unknown_version_returns_none(self): + """Test that unknown version returns None.""" + lifecycle = get_distro_lifecycle("ubuntu", "99.99") + assert lifecycle is None + + def test_case_insensitive_distro(self): + """Test that distro lookup is case-insensitive.""" + lifecycle = get_distro_lifecycle("UBUNTU", "24.04") + assert lifecycle is not None + + def test_get_debian_lifecycle(self): + """Test getting Debian 12 lifecycle.""" + lifecycle = get_distro_lifecycle("debian", "12") + assert lifecycle is not None + assert lifecycle["release_date"] == "2023-06-10" + assert lifecycle["end_of_support"] == "2026-06-10" + assert lifecycle["end_of_life"] == "2028-06-30" + + def test_get_debian_version_normalization(self): + """Test Debian version normalization (12.12 -> 12).""" + lifecycle = get_distro_lifecycle("debian", "12.12") + assert lifecycle is not None + assert lifecycle["end_of_life"] == "2028-06-30" + + def test_get_debian_11(self): + """Test getting Debian 11 lifecycle.""" + lifecycle = get_distro_lifecycle("debian", "11") + assert lifecycle is not None + assert lifecycle["end_of_life"] == "2026-08-31" + + def test_get_debian_13(self): + """Test getting Debian 13 (trixie) lifecycle.""" + lifecycle = get_distro_lifecycle("debian", "13") + assert lifecycle is not None + assert lifecycle["release_date"] == "2025-08-09" + assert lifecycle["end_of_support"] == "2028-08-09" + assert lifecycle["end_of_life"] == "2030-06-30" + + def test_get_almalinux_via_alma_name(self): + """Test that 'alma' name maps to 'almalinux' lifecycle.""" + lifecycle = get_distro_lifecycle("alma", "9") + assert lifecycle is not None + assert lifecycle["end_of_life"] == "2032-05-31" + + def test_get_amazonlinux_via_amazon_name(self): + """Test that 'amazon' name maps to 'amazonlinux' lifecycle.""" + lifecycle = get_distro_lifecycle("amazon", "2023") + assert lifecycle is not None + assert lifecycle["end_of_life"] == "2029-06" + + def test_get_amazonlinux_complex_version(self): + """Test Amazon Linux with complex version string like '2023.10.20260105 (Amazon Linux)'.""" + lifecycle = get_distro_lifecycle("amazon", "2023.10.20260105 (Amazon Linux)") + assert lifecycle is not None + assert lifecycle["end_of_life"] == "2029-06" + + def test_get_almalinux_with_point_release(self): + """Test AlmaLinux with point release version like '9.7'.""" + lifecycle = get_distro_lifecycle("alma", "9.7") + assert lifecycle is not None + assert lifecycle["end_of_life"] == "2032-05-31" + + +# ============================================================================= +# Test PACKAGE_LIFECYCLE Data +# ============================================================================= + + +class TestPackageLifecycleData: + """Test PACKAGE_LIFECYCLE data structure.""" + + def test_package_lifecycle_contains_expected_packages(self): + """Test that all expected packages are in PACKAGE_LIFECYCLE.""" + expected_packages = ["python", "django", "rails", "laravel", "php", "golang", "rust", "react", "vue"] + for pkg in expected_packages: + assert pkg in PACKAGE_LIFECYCLE, f"Missing package: {pkg}" + + def test_python_has_expected_cycles(self): + """Test Python has expected version cycles.""" + python = PACKAGE_LIFECYCLE["python"] + cycles = python["cycles"] + expected_cycles = ["2.7", "3.10", "3.11", "3.12", "3.13", "3.14"] + for cycle in expected_cycles: + assert cycle in cycles, f"Missing Python cycle: {cycle}" + + def test_python_312_lifecycle(self): + """Test Python 3.12 lifecycle dates.""" + python_312 = PACKAGE_LIFECYCLE["python"]["cycles"]["3.12"] + assert python_312["release_date"] == "2023-10-02" + assert python_312["end_of_support"] == "2025-04-02" + assert python_312["end_of_life"] == "2028-10-31" + + def test_django_has_purl_types(self): + """Test Django is limited to pypi PURL type.""" + django = PACKAGE_LIFECYCLE["django"] + assert django["purl_types"] == ["pypi"] + + def test_rails_has_gem_purl_type(self): + """Test Rails is limited to gem PURL type.""" + rails = PACKAGE_LIFECYCLE["rails"] + assert rails["purl_types"] == ["gem"] + + def test_laravel_has_composer_purl_type(self): + """Test Laravel is limited to composer PURL type.""" + laravel = PACKAGE_LIFECYCLE["laravel"] + assert laravel["purl_types"] == ["composer"] + + def test_react_has_npm_purl_type(self): + """Test React is limited to npm PURL type.""" + react = PACKAGE_LIFECYCLE["react"] + assert react["purl_types"] == ["npm"] + + def test_vue_has_npm_purl_type(self): + """Test Vue is limited to npm PURL type.""" + vue = PACKAGE_LIFECYCLE["vue"] + assert vue["purl_types"] == ["npm"] + + def test_php_matches_all_purl_types(self): + """Test PHP matches all PURL types (None).""" + php = PACKAGE_LIFECYCLE["php"] + assert php["purl_types"] is None + + def test_php_has_expected_cycles(self): + """Test PHP has expected version cycles.""" + php = PACKAGE_LIFECYCLE["php"] + cycles = php["cycles"] + expected_cycles = ["7.4", "8.0", "8.1", "8.2", "8.3", "8.4", "8.5"] + for cycle in expected_cycles: + assert cycle in cycles, f"Missing PHP cycle: {cycle}" + + def test_php_84_lifecycle(self): + """Test PHP 8.4 lifecycle dates.""" + php_84 = PACKAGE_LIFECYCLE["php"]["cycles"]["8.4"] + assert php_84["release_date"] == "2024-11-21" + assert php_84["end_of_support"] == "2026-12-31" + assert php_84["end_of_life"] == "2028-12-31" + + def test_php_74_eol(self): + """Test PHP 7.4 (unsupported) has only EOL date.""" + php_74 = PACKAGE_LIFECYCLE["php"]["cycles"]["7.4"] + assert php_74["release_date"] == "2019-11-28" + assert php_74["end_of_support"] is None # Unsupported branches don't have EOS + assert php_74["end_of_life"] == "2022-11-28" + + def test_golang_matches_all_purl_types(self): + """Test Go matches all PURL types (None).""" + golang = PACKAGE_LIFECYCLE["golang"] + assert golang["purl_types"] is None + + def test_golang_has_expected_cycles(self): + """Test Go has expected version cycles.""" + golang = PACKAGE_LIFECYCLE["golang"] + cycles = golang["cycles"] + expected_cycles = ["1.22", "1.23", "1.24", "1.25"] + for cycle in expected_cycles: + assert cycle in cycles, f"Missing Go cycle: {cycle}" + + def test_golang_123_lifecycle(self): + """Test Go 1.23 lifecycle dates.""" + go_123 = PACKAGE_LIFECYCLE["golang"]["cycles"]["1.23"] + assert go_123["release_date"] == "2024-08-13" + assert go_123["end_of_support"] == "2025-08-12" + assert go_123["end_of_life"] == "2025-08-12" + + def test_rust_matches_all_purl_types(self): + """Test Rust matches all PURL types (None).""" + rust = PACKAGE_LIFECYCLE["rust"] + assert rust["purl_types"] is None + + def test_rust_has_expected_cycles(self): + """Test Rust has expected version cycles.""" + rust = PACKAGE_LIFECYCLE["rust"] + cycles = rust["cycles"] + expected_cycles = ["1.90", "1.91", "1.92"] + for cycle in expected_cycles: + assert cycle in cycles, f"Missing Rust cycle: {cycle}" + + def test_rust_191_lifecycle(self): + """Test Rust 1.91 lifecycle dates.""" + rust_191 = PACKAGE_LIFECYCLE["rust"]["cycles"]["1.91"] + assert rust_191["release_date"] == "2025-10-30" + assert rust_191["end_of_support"] == "2025-12-11" + assert rust_191["end_of_life"] == "2025-12-11" + + def test_python_matches_all_purl_types(self): + """Test Python matches all PURL types (None).""" + python = PACKAGE_LIFECYCLE["python"] + assert python["purl_types"] is None + + def test_python_name_patterns(self): + """Test Python name patterns include expected variations.""" + python = PACKAGE_LIFECYCLE["python"] + patterns = python["name_patterns"] + assert "python" in patterns + assert "python3" in patterns + assert "python3.*" in patterns + + def test_react_no_eol_dates(self): + """Test React versions have no fixed EOL dates.""" + react_19 = PACKAGE_LIFECYCLE["react"]["cycles"]["19"] + assert react_19["end_of_support"] is None + assert react_19["end_of_life"] is None + assert react_19["release_date"] == "2024-12-05" + + def test_vue_2_eol(self): + """Test Vue 2 has EOL date.""" + vue_2 = PACKAGE_LIFECYCLE["vue"]["cycles"]["2"] + assert vue_2["end_of_life"] == "2023-12-31" + + def test_laravel_quarter_dates(self): + """Test Laravel 13 has quarter dates.""" + laravel_13 = PACKAGE_LIFECYCLE["laravel"]["cycles"]["13"] + assert laravel_13["release_date"] == "2026-Q1" + assert laravel_13["end_of_support"] == "2026-Q3" + + +# ============================================================================= +# Test Version Cycle Extraction +# ============================================================================= + + +class TestVersionCycleExtraction: + """Test extract_version_cycle function.""" + + def test_extract_major_minor_from_full_version(self): + """Test extracting major.minor from 3.12.7.""" + cycle = extract_version_cycle("3.12.7") + assert cycle == "3.12" + + def test_extract_major_minor_from_two_part_version(self): + """Test extracting major.minor from 3.12.""" + cycle = extract_version_cycle("3.12") + assert cycle == "3.12" + + def test_extract_major_only(self): + """Test extracting major version only.""" + cycle = extract_version_cycle("19.0.1", version_extract="major") + assert cycle == "19" + + def test_extract_from_v_prefix(self): + """Test extracting from version with v prefix.""" + cycle = extract_version_cycle("v3.12.7") + assert cycle == "3.12" + + def test_extract_from_single_number(self): + """Test extracting from single number version.""" + cycle = extract_version_cycle("19", version_extract="major.minor") + assert cycle == "19" + + def test_extract_handles_rc_suffix(self): + """Test extracting handles -rc1 suffix.""" + cycle = extract_version_cycle("3.14.0-rc1") + assert cycle == "3.14" + + def test_extract_empty_version(self): + """Test extracting from empty string returns None.""" + cycle = extract_version_cycle("") + assert cycle is None + + def test_extract_none_version(self): + """Test extracting from None returns None.""" + cycle = extract_version_cycle(None) + assert cycle is None + + +# ============================================================================= +# Test Package Lifecycle Entry Lookup +# ============================================================================= + + +class TestGetPackageLifecycleEntry: + """Test get_package_lifecycle_entry function.""" + + def test_find_python_entry(self): + """Test finding Python lifecycle entry.""" + entry = get_package_lifecycle_entry("python") + assert entry is not None + assert "python" in entry.get("name_patterns", []) + + def test_find_python3_entry(self): + """Test finding entry for python3.""" + entry = get_package_lifecycle_entry("python3") + assert entry is not None + + def test_find_python312_entry(self): + """Test finding entry for python3.12 (glob pattern).""" + entry = get_package_lifecycle_entry("python3.12") + assert entry is not None + + def test_find_django_entry(self): + """Test finding Django lifecycle entry.""" + entry = get_package_lifecycle_entry("django") + assert entry is not None + assert entry.get("purl_types") == ["pypi"] + + def test_find_rails_entry(self): + """Test finding Rails lifecycle entry.""" + entry = get_package_lifecycle_entry("rails") + assert entry is not None + assert entry.get("purl_types") == ["gem"] + + def test_case_insensitive_lookup(self): + """Test that lookup is case-insensitive.""" + entry = get_package_lifecycle_entry("Django") + assert entry is not None + + def test_unknown_package_returns_none(self): + """Test that unknown package returns None.""" + entry = get_package_lifecycle_entry("unknownpackage") + assert entry is None + + +# ============================================================================= +# Test Get Package Lifecycle +# ============================================================================= + + +class TestGetPackageLifecycle: + """Test get_package_lifecycle function.""" + + def test_get_python_312_lifecycle(self): + """Test getting Python 3.12 lifecycle.""" + lifecycle = get_package_lifecycle("python", "3.12.7") + assert lifecycle is not None + assert lifecycle["release_date"] == "2023-10-02" + assert lifecycle["end_of_support"] == "2025-04-02" + assert lifecycle["end_of_life"] == "2028-10-31" + + def test_get_django_42_lifecycle(self): + """Test getting Django 4.2 lifecycle.""" + lifecycle = get_package_lifecycle("django", "4.2.9", purl_type="pypi") + assert lifecycle is not None + assert lifecycle["end_of_life"] == "2026-04-30" + + def test_django_wrong_purl_type_returns_none(self): + """Test Django with wrong PURL type returns None.""" + lifecycle = get_package_lifecycle("django", "4.2.9", purl_type="npm") + assert lifecycle is None + + def test_get_react_19_lifecycle(self): + """Test getting React 19 lifecycle.""" + lifecycle = get_package_lifecycle("react", "19.0.1", purl_type="npm") + assert lifecycle is not None + assert lifecycle["release_date"] == "2024-12-05" + assert lifecycle["end_of_support"] is None + + def test_get_laravel_major_version(self): + """Test Laravel uses major version extraction.""" + lifecycle = get_package_lifecycle("laravel", "12.5.3", purl_type="composer") + assert lifecycle is not None + assert lifecycle["release_date"] == "2025-02-24" + + def test_unknown_cycle_returns_none(self): + """Test unknown cycle returns None.""" + lifecycle = get_package_lifecycle("python", "2.5.0") + assert lifecycle is None + + def test_python_matches_any_purl_type(self): + """Test Python matches any PURL type.""" + lifecycle_pypi = get_package_lifecycle("python3", "3.12.1", purl_type="pypi") + lifecycle_deb = get_package_lifecycle("python3", "3.12.1", purl_type="deb") + lifecycle_rpm = get_package_lifecycle("python3", "3.12.1", purl_type="rpm") + + assert lifecycle_pypi is not None + assert lifecycle_deb is not None + assert lifecycle_rpm is not None + + def test_get_php_84_lifecycle(self): + """Test getting PHP 8.4 lifecycle.""" + lifecycle = get_package_lifecycle("php", "8.4.1") + assert lifecycle is not None + assert lifecycle["release_date"] == "2024-11-21" + assert lifecycle["end_of_support"] == "2026-12-31" + assert lifecycle["end_of_life"] == "2028-12-31" + + def test_php_matches_any_purl_type(self): + """Test PHP matches any PURL type.""" + lifecycle_composer = get_package_lifecycle("php", "8.4.1", purl_type="composer") + lifecycle_deb = get_package_lifecycle("php", "8.4.1", purl_type="deb") + lifecycle_apk = get_package_lifecycle("php", "8.4.1", purl_type="apk") + + assert lifecycle_composer is not None + assert lifecycle_deb is not None + assert lifecycle_apk is not None + + def test_get_golang_123_lifecycle(self): + """Test getting Go 1.23 lifecycle.""" + lifecycle = get_package_lifecycle("go", "1.23.4") + assert lifecycle is not None + assert lifecycle["release_date"] == "2024-08-13" + assert lifecycle["end_of_support"] == "2025-08-12" + assert lifecycle["end_of_life"] == "2025-08-12" + + def test_golang_matches_any_purl_type(self): + """Test Go matches any PURL type.""" + lifecycle_golang = get_package_lifecycle("golang", "1.23.1", purl_type="golang") + lifecycle_apk = get_package_lifecycle("go", "1.23.1", purl_type="apk") + lifecycle_deb = get_package_lifecycle("go", "1.23.1", purl_type="deb") + + assert lifecycle_golang is not None + assert lifecycle_apk is not None + assert lifecycle_deb is not None + + def test_get_rust_191_lifecycle(self): + """Test getting Rust 1.91 lifecycle.""" + lifecycle = get_package_lifecycle("rust", "1.91.0") + assert lifecycle is not None + assert lifecycle["release_date"] == "2025-10-30" + assert lifecycle["end_of_support"] == "2025-12-11" + assert lifecycle["end_of_life"] == "2025-12-11" + + def test_rust_matches_any_purl_type(self): + """Test Rust matches any PURL type.""" + lifecycle_cargo = get_package_lifecycle("rust", "1.91.0", purl_type="cargo") + lifecycle_deb = get_package_lifecycle("rustc", "1.91.0", purl_type="deb") + lifecycle_apk = get_package_lifecycle("cargo", "1.91.0", purl_type="apk") + + assert lifecycle_cargo is not None + assert lifecycle_deb is not None + assert lifecycle_apk is not None + + +# ============================================================================= +# Test LifecycleSource DataSource +# ============================================================================= + + +class TestLifecycleSource: + """Test LifecycleSource DataSource implementation.""" + + def test_source_properties(self): + """Test source name and priority.""" + source = LifecycleSource() + assert source.name == "lifecycle" + assert source.priority == 5 # High priority for local data + + def test_supports_python_pypi(self): + """Test that LifecycleSource supports Python from PyPI.""" + source = LifecycleSource() + purl = PackageURL.from_string("pkg:pypi/python@3.12.1") + assert source.supports(purl) is True + + def test_supports_python_deb(self): + """Test that LifecycleSource supports Python from deb.""" + source = LifecycleSource() + purl = PackageURL.from_string("pkg:deb/ubuntu/python3@3.12.1") + assert source.supports(purl) is True + + def test_supports_python_rpm(self): + """Test that LifecycleSource supports Python from rpm.""" + source = LifecycleSource() + purl = PackageURL.from_string("pkg:rpm/fedora/python3@3.12.1") + assert source.supports(purl) is True + + def test_supports_python3_with_version_suffix(self): + """Test that LifecycleSource supports python3.12 packages.""" + source = LifecycleSource() + purl = PackageURL.from_string("pkg:apk/alpine/python3.12@3.12.1") + assert source.supports(purl) is True + + def test_supports_django(self): + """Test that LifecycleSource supports Django.""" + source = LifecycleSource() + purl = PackageURL.from_string("pkg:pypi/django@4.2.9") + assert source.supports(purl) is True + + def test_does_not_support_django_from_npm(self): + """Test that LifecycleSource does not support Django from npm.""" + source = LifecycleSource() + purl = PackageURL.from_string("pkg:npm/django@4.2.9") + assert source.supports(purl) is False + + def test_supports_rails(self): + """Test that LifecycleSource supports Rails.""" + source = LifecycleSource() + purl = PackageURL.from_string("pkg:gem/rails@8.0.1") + assert source.supports(purl) is True + + def test_supports_react(self): + """Test that LifecycleSource supports React.""" + source = LifecycleSource() + purl = PackageURL.from_string("pkg:npm/react@19.0.1") + assert source.supports(purl) is True + + def test_supports_vue(self): + """Test that LifecycleSource supports Vue.""" + source = LifecycleSource() + purl = PackageURL.from_string("pkg:npm/vue@3.4.1") + assert source.supports(purl) is True + + def test_supports_php(self): + """Test that LifecycleSource supports PHP.""" + source = LifecycleSource() + purl = PackageURL.from_string("pkg:apk/alpine/php@8.4.1") + assert source.supports(purl) is True + + def test_supports_php_from_deb(self): + """Test that LifecycleSource supports PHP from deb.""" + source = LifecycleSource() + purl = PackageURL.from_string("pkg:deb/ubuntu/php@8.3.6") + assert source.supports(purl) is True + + def test_supports_golang(self): + """Test that LifecycleSource supports Go.""" + source = LifecycleSource() + purl = PackageURL.from_string("pkg:golang/golang@1.23.4") + assert source.supports(purl) is True + + def test_supports_go_from_apk(self): + """Test that LifecycleSource supports Go from apk.""" + source = LifecycleSource() + purl = PackageURL.from_string("pkg:apk/alpine/go@1.23.4") + assert source.supports(purl) is True + + def test_supports_rust(self): + """Test that LifecycleSource supports Rust.""" + source = LifecycleSource() + purl = PackageURL.from_string("pkg:cargo/rust@1.91.0") + assert source.supports(purl) is True + + def test_supports_rustc_from_deb(self): + """Test that LifecycleSource supports rustc from deb.""" + source = LifecycleSource() + purl = PackageURL.from_string("pkg:deb/debian/rustc@1.91.0") + assert source.supports(purl) is True + + def test_supports_cargo(self): + """Test that LifecycleSource supports cargo (Rust package manager).""" + source = LifecycleSource() + purl = PackageURL.from_string("pkg:deb/ubuntu/cargo@1.91.0") + assert source.supports(purl) is True + + def test_does_not_support_unknown_package(self): + """Test that LifecycleSource does not support unknown packages.""" + source = LifecycleSource() + purl = PackageURL.from_string("pkg:pypi/requests@2.31.0") + assert source.supports(purl) is False + + def test_fetch_python_312(self, mock_session): + """Test fetching Python 3.12 lifecycle.""" + source = LifecycleSource() + purl = PackageURL.from_string("pkg:pypi/python@3.12.7") + + metadata = source.fetch(purl, mock_session) + + assert metadata is not None + assert metadata.cle_release_date == "2023-10-02" + assert metadata.cle_eos == "2025-04-02" + assert metadata.cle_eol == "2028-10-31" + assert metadata.source == "lifecycle" + + def test_fetch_django_42(self, mock_session): + """Test fetching Django 4.2 lifecycle.""" + source = LifecycleSource() + purl = PackageURL.from_string("pkg:pypi/django@4.2.9") + + metadata = source.fetch(purl, mock_session) + + assert metadata is not None + assert metadata.cle_eos == "2023-12-04" + assert metadata.cle_eol == "2026-04-30" + + def test_fetch_react_19(self, mock_session): + """Test fetching React 19 lifecycle (no EOL dates).""" + source = LifecycleSource() + purl = PackageURL.from_string("pkg:npm/react@19.0.0") + + metadata = source.fetch(purl, mock_session) + + assert metadata is not None + assert metadata.cle_release_date == "2024-12-05" + # React doesn't have fixed EOL dates + assert metadata.cle_eos is None + assert metadata.cle_eol is None + + def test_fetch_unknown_version_returns_none(self, mock_session): + """Test fetching unknown Python version returns None.""" + source = LifecycleSource() + purl = PackageURL.from_string("pkg:pypi/python@2.5.0") + + metadata = source.fetch(purl, mock_session) + + assert metadata is None + + def test_fetch_unknown_package_returns_none(self, mock_session): + """Test fetching unknown package returns None.""" + source = LifecycleSource() + purl = PackageURL.from_string("pkg:pypi/requests@2.31.0") + + metadata = source.fetch(purl, mock_session) + + assert metadata is None + + def test_fetch_no_version_returns_none(self, mock_session): + """Test fetching package without version returns None.""" + source = LifecycleSource() + purl = PackageURL.from_string("pkg:pypi/python") + + metadata = source.fetch(purl, mock_session) + + assert metadata is None + + def test_fetch_caches_results(self, mock_session): + """Test that fetch results are cached.""" + source = LifecycleSource() + purl = PackageURL.from_string("pkg:pypi/python@3.12.7") + + # First call + metadata1 = source.fetch(purl, mock_session) + # Second call (should be cached) + metadata2 = source.fetch(purl, mock_session) + + assert metadata1 is not None + assert metadata2 is not None + # Both should reference the same cached object + assert metadata1 is metadata2 + + def test_fetch_field_sources_tracked(self, mock_session): + """Test that field sources are properly tracked.""" + source = LifecycleSource() + purl = PackageURL.from_string("pkg:pypi/django@4.2.9") + + metadata = source.fetch(purl, mock_session) + + assert metadata is not None + assert metadata.field_sources.get("cle_eos") == "lifecycle" + assert metadata.field_sources.get("cle_eol") == "lifecycle" + + def test_fetch_python_from_deb(self, mock_session): + """Test fetching Python lifecycle from deb package.""" + source = LifecycleSource() + purl = PackageURL.from_string("pkg:deb/ubuntu/python3@3.12.3") + + metadata = source.fetch(purl, mock_session) + + assert metadata is not None + assert metadata.cle_eol == "2028-10-31" + + def test_fetch_laravel_major_version(self, mock_session): + """Test fetching Laravel with major version extraction.""" + source = LifecycleSource() + purl = PackageURL.from_string("pkg:composer/laravel/framework@12.1.0") + + metadata = source.fetch(purl, mock_session) + + assert metadata is not None + assert metadata.cle_release_date == "2025-02-24" + + def test_fetch_php_84(self, mock_session): + """Test fetching PHP 8.4 lifecycle.""" + source = LifecycleSource() + purl = PackageURL.from_string("pkg:apk/alpine/php@8.4.1") + + metadata = source.fetch(purl, mock_session) + + assert metadata is not None + assert metadata.cle_release_date == "2024-11-21" + assert metadata.cle_eos == "2026-12-31" + assert metadata.cle_eol == "2028-12-31" + + def test_fetch_php_from_deb(self, mock_session): + """Test fetching PHP lifecycle from deb package.""" + source = LifecycleSource() + purl = PackageURL.from_string("pkg:deb/ubuntu/php@8.3.6") + + metadata = source.fetch(purl, mock_session) + + assert metadata is not None + assert metadata.cle_eol == "2027-12-31" + + def test_fetch_golang_123(self, mock_session): + """Test fetching Go 1.23 lifecycle.""" + source = LifecycleSource() + purl = PackageURL.from_string("pkg:golang/go@1.23.4") + + metadata = source.fetch(purl, mock_session) + + assert metadata is not None + assert metadata.cle_release_date == "2024-08-13" + assert metadata.cle_eos == "2025-08-12" + assert metadata.cle_eol == "2025-08-12" + + def test_fetch_golang_from_apk(self, mock_session): + """Test fetching Go lifecycle from apk package.""" + source = LifecycleSource() + purl = PackageURL.from_string("pkg:apk/alpine/go@1.24.1") + + metadata = source.fetch(purl, mock_session) + + assert metadata is not None + assert metadata.cle_release_date == "2025-02-11" + # 1.24 doesn't have EOL yet + assert metadata.cle_eos is None + assert metadata.cle_eol is None + + def test_fetch_rust_191(self, mock_session): + """Test fetching Rust 1.91 lifecycle.""" + source = LifecycleSource() + purl = PackageURL.from_string("pkg:cargo/rust@1.91.0") + + metadata = source.fetch(purl, mock_session) + + assert metadata is not None + assert metadata.cle_release_date == "2025-10-30" + assert metadata.cle_eos == "2025-12-11" + assert metadata.cle_eol == "2025-12-11" + + def test_fetch_rustc_from_deb(self, mock_session): + """Test fetching Rust lifecycle from deb rustc package.""" + source = LifecycleSource() + purl = PackageURL.from_string("pkg:deb/debian/rustc@1.92.0") + + metadata = source.fetch(purl, mock_session) + + assert metadata is not None + assert metadata.cle_release_date == "2025-12-11" + # 1.92 is current, no EOL yet + assert metadata.cle_eos is None + assert metadata.cle_eol is None + + +# ============================================================================= +# Test Clear Cache +# ============================================================================= + + +class TestClearCache: + """Test cache clearing functionality.""" + + def test_clear_cache_resets_state(self, mock_session): + """Test that clear_cache resets the cache.""" + source = LifecycleSource() + purl = PackageURL.from_string("pkg:pypi/python@3.12.7") + + # Populate cache + source.fetch(purl, mock_session) + + # Clear cache + clear_cache() + + # Fetch again - should work (not test for identity, just functionality) + metadata = source.fetch(purl, mock_session) + assert metadata is not None + + +# ============================================================================= +# Test Integration with Enricher Registry +# ============================================================================= + + +class TestLifecycleSourceRegistration: + """Test that LifecycleSource is properly registered.""" + + def test_lifecycle_source_in_default_registry(self): + """Test LifecycleSource is registered in default registry.""" + from sbomify_action._enrichment.enricher import create_default_registry + + registry = create_default_registry() + sources = registry.list_sources() + source_names = [s["name"] for s in sources] + + assert "lifecycle" in source_names + + def test_lifecycle_source_priority(self): + """Test LifecycleSource has correct priority.""" + from sbomify_action._enrichment.enricher import create_default_registry + + registry = create_default_registry() + sources = registry.list_sources() + + lifecycle_source = next((s for s in sources if s["name"] == "lifecycle"), None) + assert lifecycle_source is not None + assert lifecycle_source["priority"] == 5 + + +# ============================================================================= +# Test Edge Cases +# ============================================================================= + + +class TestEdgeCases: + """Test edge cases and boundary conditions.""" + + def test_python27_eol(self, mock_session): + """Test Python 2.7 EOL is correctly reported.""" + source = LifecycleSource() + purl = PackageURL.from_string("pkg:pypi/python@2.7.18") + + metadata = source.fetch(purl, mock_session) + + assert metadata is not None + assert metadata.cle_eos == "2020-01-01" + assert metadata.cle_eol == "2020-04-20" + + def test_vue2_eol(self, mock_session): + """Test Vue 2 EOL is correctly reported.""" + source = LifecycleSource() + purl = PackageURL.from_string("pkg:npm/vue@2.7.14") + + metadata = source.fetch(purl, mock_session) + + assert metadata is not None + assert metadata.cle_eol == "2023-12-31" + + def test_rails_70_eol(self, mock_session): + """Test Rails 7.0 EOL is correctly reported.""" + source = LifecycleSource() + purl = PackageURL.from_string("pkg:gem/rails@7.0.8") + + metadata = source.fetch(purl, mock_session) + + assert metadata is not None + assert metadata.cle_eol == "2025-10-29" + + def test_cpython_alias(self, mock_session): + """Test cpython alias is matched.""" + source = LifecycleSource() + purl = PackageURL.from_string("pkg:generic/cpython@3.12.1") + + # Should be supported due to name pattern + assert source.supports(purl) is True + + metadata = source.fetch(purl, mock_session) + assert metadata is not None + + def test_railties_alias(self, mock_session): + """Test railties (Rails component) is matched.""" + source = LifecycleSource() + purl = PackageURL.from_string("pkg:gem/railties@8.0.1") + + assert source.supports(purl) is True + + metadata = source.fetch(purl, mock_session) + assert metadata is not None + assert metadata.cle_eol == "2026-11-07" + + def test_php74_eol(self, mock_session): + """Test PHP 7.4 EOL is correctly reported (unsupported branch).""" + source = LifecycleSource() + purl = PackageURL.from_string("pkg:apk/alpine/php@7.4.33") + + metadata = source.fetch(purl, mock_session) + + assert metadata is not None + assert metadata.cle_release_date == "2019-11-28" + assert metadata.cle_eos is None # Unsupported branches don't publish EOS + assert metadata.cle_eol == "2022-11-28" + + def test_php_cli_variant(self, mock_session): + """Test php-cli variant is matched.""" + source = LifecycleSource() + purl = PackageURL.from_string("pkg:deb/debian/php-cli@8.3.6") + + assert source.supports(purl) is True + + metadata = source.fetch(purl, mock_session) + assert metadata is not None + assert metadata.cle_eol == "2027-12-31" + + def test_alpine_php83_variant(self, mock_session): + """Test Alpine php83 package naming is matched.""" + source = LifecycleSource() + purl = PackageURL.from_string("pkg:apk/alpine/php83@8.3.6") + + assert source.supports(purl) is True + + metadata = source.fetch(purl, mock_session) + assert metadata is not None + assert metadata.cle_eol == "2027-12-31" + + def test_libpython_stdlib(self, mock_session): + """Test Debian libpython stdlib package is matched.""" + source = LifecycleSource() + purl = PackageURL.from_string("pkg:deb/debian/libpython3.12-stdlib@3.12.1") + + assert source.supports(purl) is True + + metadata = source.fetch(purl, mock_session) + assert metadata is not None + assert metadata.cle_eol == "2028-10-31" + + def test_golang_versioned_debian(self, mock_session): + """Test Debian versioned golang package is matched.""" + source = LifecycleSource() + purl = PackageURL.from_string("pkg:deb/debian/golang-1.23-go@1.23.4") + + assert source.supports(purl) is True + + metadata = source.fetch(purl, mock_session) + assert metadata is not None + + def test_libstd_rust_stdlib(self, mock_session): + """Test Debian libstd-rust stdlib package is matched.""" + source = LifecycleSource() + purl = PackageURL.from_string("pkg:deb/debian/libstd-rust-1.91@1.91.0") + + assert source.supports(purl) is True + + metadata = source.fetch(purl, mock_session) + assert metadata is not None + + def test_react_dom_alias(self, mock_session): + """Test react-dom alias is matched.""" + source = LifecycleSource() + purl = PackageURL.from_string("pkg:npm/react-dom@19.0.0") + + assert source.supports(purl) is True + + metadata = source.fetch(purl, mock_session) + assert metadata is not None + assert metadata.cle_release_date == "2024-12-05" + + def test_rails_activesupport_alias(self, mock_session): + """Test Rails activesupport gem is matched.""" + source = LifecycleSource() + purl = PackageURL.from_string("pkg:gem/activesupport@8.0.1") + + assert source.supports(purl) is True + + metadata = source.fetch(purl, mock_session) + assert metadata is not None + assert metadata.cle_eol == "2026-11-07" + + +# ============================================================================= +# Test Non-Tracked Packages Return None +# ============================================================================= + + +class TestNonTrackedPackages: + """Test that non-tracked OS packages return None (no distro lifecycle fallback).""" + + @pytest.fixture + def mock_session(self): + """Create a mock session for testing.""" + return Mock() + + def test_ubuntu_curl_not_supported(self, mock_session): + """Test that curl on Ubuntu is not supported (we don't track curl).""" + source = LifecycleSource() + purl = PackageURL.from_string("pkg:deb/ubuntu/curl@8.5.0-2ubuntu10.6") + + # curl is not tracked - we only track specific runtimes/frameworks + assert source.supports(purl) is False + + metadata = source.fetch(purl, mock_session) + assert metadata is None + + def test_alpine_nginx_not_supported(self, mock_session): + """Test that nginx on Alpine is not supported (we don't track nginx).""" + source = LifecycleSource() + purl = PackageURL.from_string("pkg:apk/alpine/nginx@1.27.0-r0") + + assert source.supports(purl) is False + + metadata = source.fetch(purl, mock_session) + assert metadata is None + + def test_rocky_openssl_not_supported(self, mock_session): + """Test that openssl on Rocky is not supported (we don't track openssl).""" + source = LifecycleSource() + purl = PackageURL.from_string("pkg:rpm/rocky/openssl@3.0.7") + + assert source.supports(purl) is False + + metadata = source.fetch(purl, mock_session) + assert metadata is None + + def test_python_on_ubuntu_is_supported(self, mock_session): + """Test that Python on Ubuntu IS supported (we track Python).""" + source = LifecycleSource() + purl = PackageURL.from_string("pkg:deb/ubuntu/python3@3.12.3-1") + + assert source.supports(purl) is True + + metadata = source.fetch(purl, mock_session) + assert metadata is not None + assert metadata.cle_eol == "2028-10-31" + + def test_php_on_debian_is_supported(self, mock_session): + """Test that PHP on Debian IS supported (we track PHP).""" + source = LifecycleSource() + purl = PackageURL.from_string("pkg:deb/debian/php@8.3.6") + + assert source.supports(purl) is True + + metadata = source.fetch(purl, mock_session) + assert metadata is not None + assert metadata.cle_eol == "2027-12-31" + + def test_unknown_package_returns_none(self, mock_session): + """Test that unknown packages return None.""" + source = LifecycleSource() + purl = PackageURL.from_string("pkg:deb/someunknowndistro/curl@8.0.0") + + assert source.supports(purl) is False + + metadata = source.fetch(purl, mock_session) + assert metadata is None + + def test_wolfi_busybox_not_supported(self, mock_session): + """Test that busybox on Wolfi is not supported (we don't track busybox).""" + source = LifecycleSource() + purl = PackageURL.from_string("pkg:apk/wolfi/busybox@1.36.1-r6") + + assert source.supports(purl) is False + + metadata = source.fetch(purl, mock_session) + assert metadata is None + + def test_amazonlinux_awscli_not_supported(self, mock_session): + """Test that aws-cli on Amazon Linux is not supported (we don't track aws-cli).""" + source = LifecycleSource() + purl = PackageURL.from_string("pkg:rpm/amzn/aws-cli@2.15.0") + + assert source.supports(purl) is False + + metadata = source.fetch(purl, mock_session) + assert metadata is None diff --git a/uv.lock b/uv.lock index 259c226..07edc24 100644 --- a/uv.lock +++ b/uv.lock @@ -24,6 +24,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6a/21/5b6702a7f963e95456c0de2d495f67bf5fd62840ac655dc451586d23d39a/attrs-24.2.0-py3-none-any.whl", hash = "sha256:81921eb96de3191c8258c199618104dd27ac608d9366f5e35d011eae1867ede2", size = 63001, upload-time = "2024-08-06T14:37:36.958Z" }, ] +[[package]] +name = "backports-tarfile" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/86/72/cd9b395f25e290e633655a100af28cb253e4393396264a98bd5f5951d50f/backports_tarfile-1.2.0.tar.gz", hash = "sha256:d75e02c268746e1b8144c278978b6e98e85de6ad16f8e4b0844a154557eca991", size = 86406, upload-time = "2024-05-28T17:01:54.731Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/fa/123043af240e49752f1c4bd24da5053b6bd00cad78c2be53c0d1e8b975bc/backports.tarfile-1.2.0-py3-none-any.whl", hash = "sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34", size = 30181, upload-time = "2024-05-28T17:01:53.112Z" }, +] + [[package]] name = "beartype" version = "0.22.6" @@ -51,6 +60,59 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/12/90/3c9ff0512038035f59d279fddeb79f5f1eccd8859f06d6163c58798b9487/certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8", size = 167321, upload-time = "2024-08-30T01:55:02.591Z" }, ] +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811, upload-time = "2025-09-08T23:22:12.267Z" }, + { url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402, upload-time = "2025-09-08T23:22:13.455Z" }, + { url = "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217, upload-time = "2025-09-08T23:22:14.596Z" }, + { url = "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", size = 203079, upload-time = "2025-09-08T23:22:15.769Z" }, + { url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475, upload-time = "2025-09-08T23:22:17.427Z" }, + { url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829, upload-time = "2025-09-08T23:22:19.069Z" }, + { url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211, upload-time = "2025-09-08T23:22:20.588Z" }, + { url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036, upload-time = "2025-09-08T23:22:22.143Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, + { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, + { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, + { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, +] + [[package]] name = "cfgv" version = "3.4.0" @@ -224,6 +286,55 @@ toml = [ { name = "tomli", marker = "python_full_version <= '3.11'" }, ] +[[package]] +name = "cryptography" +version = "46.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/33/c00162f49c0e2fe8064a62cb92b93e50c74a72bc370ab92f86112b33ff62/cryptography-46.0.3.tar.gz", hash = "sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1", size = 749258, upload-time = "2025-10-15T23:18:31.74Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1c/67/38769ca6b65f07461eb200e85fc1639b438bdc667be02cf7f2cd6a64601c/cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc", size = 4296667, upload-time = "2025-10-15T23:16:54.369Z" }, + { url = "https://files.pythonhosted.org/packages/5c/49/498c86566a1d80e978b42f0d702795f69887005548c041636df6ae1ca64c/cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d", size = 4450807, upload-time = "2025-10-15T23:16:56.414Z" }, + { url = "https://files.pythonhosted.org/packages/4b/0a/863a3604112174c8624a2ac3c038662d9e59970c7f926acdcfaed8d61142/cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb", size = 4299615, upload-time = "2025-10-15T23:16:58.442Z" }, + { url = "https://files.pythonhosted.org/packages/64/02/b73a533f6b64a69f3cd3872acb6ebc12aef924d8d103133bb3ea750dc703/cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849", size = 4016800, upload-time = "2025-10-15T23:17:00.378Z" }, + { url = "https://files.pythonhosted.org/packages/25/d5/16e41afbfa450cde85a3b7ec599bebefaef16b5c6ba4ec49a3532336ed72/cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8", size = 4984707, upload-time = "2025-10-15T23:17:01.98Z" }, + { url = "https://files.pythonhosted.org/packages/c9/56/e7e69b427c3878352c2fb9b450bd0e19ed552753491d39d7d0a2f5226d41/cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec", size = 4482541, upload-time = "2025-10-15T23:17:04.078Z" }, + { url = "https://files.pythonhosted.org/packages/78/f6/50736d40d97e8483172f1bb6e698895b92a223dba513b0ca6f06b2365339/cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91", size = 4299464, upload-time = "2025-10-15T23:17:05.483Z" }, + { url = "https://files.pythonhosted.org/packages/00/de/d8e26b1a855f19d9994a19c702fa2e93b0456beccbcfe437eda00e0701f2/cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e", size = 4950838, upload-time = "2025-10-15T23:17:07.425Z" }, + { url = "https://files.pythonhosted.org/packages/8f/29/798fc4ec461a1c9e9f735f2fc58741b0daae30688f41b2497dcbc9ed1355/cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926", size = 4481596, upload-time = "2025-10-15T23:17:09.343Z" }, + { url = "https://files.pythonhosted.org/packages/15/8d/03cd48b20a573adfff7652b76271078e3045b9f49387920e7f1f631d125e/cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71", size = 4426782, upload-time = "2025-10-15T23:17:11.22Z" }, + { url = "https://files.pythonhosted.org/packages/fa/b1/ebacbfe53317d55cf33165bda24c86523497a6881f339f9aae5c2e13e57b/cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac", size = 4698381, upload-time = "2025-10-15T23:17:12.829Z" }, + { url = "https://files.pythonhosted.org/packages/73/dc/9aa866fbdbb95b02e7f9d086f1fccfeebf8953509b87e3f28fff927ff8a0/cryptography-46.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c8daeb2d2174beb4575b77482320303f3d39b8e81153da4f0fb08eb5fe86a6c5", size = 4288728, upload-time = "2025-10-15T23:17:21.527Z" }, + { url = "https://files.pythonhosted.org/packages/c5/fd/bc1daf8230eaa075184cbbf5f8cd00ba9db4fd32d63fb83da4671b72ed8a/cryptography-46.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:39b6755623145ad5eff1dab323f4eae2a32a77a7abef2c5089a04a3d04366715", size = 4435078, upload-time = "2025-10-15T23:17:23.042Z" }, + { url = "https://files.pythonhosted.org/packages/82/98/d3bd5407ce4c60017f8ff9e63ffee4200ab3e23fe05b765cab805a7db008/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:db391fa7c66df6762ee3f00c95a89e6d428f4d60e7abc8328f4fe155b5ac6e54", size = 4293460, upload-time = "2025-10-15T23:17:24.885Z" }, + { url = "https://files.pythonhosted.org/packages/26/e9/e23e7900983c2b8af7a08098db406cf989d7f09caea7897e347598d4cd5b/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:78a97cf6a8839a48c49271cdcbd5cf37ca2c1d6b7fdd86cc864f302b5e9bf459", size = 3995237, upload-time = "2025-10-15T23:17:26.449Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/af68c509d4a138cfe299d0d7ddb14afba15233223ebd933b4bbdbc7155d3/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:dfb781ff7eaa91a6f7fd41776ec37c5853c795d3b358d4896fdbb5df168af422", size = 4967344, upload-time = "2025-10-15T23:17:28.06Z" }, + { url = "https://files.pythonhosted.org/packages/ca/e3/8643d077c53868b681af077edf6b3cb58288b5423610f21c62aadcbe99f4/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6f61efb26e76c45c4a227835ddeae96d83624fb0d29eb5df5b96e14ed1a0afb7", size = 4466564, upload-time = "2025-10-15T23:17:29.665Z" }, + { url = "https://files.pythonhosted.org/packages/0e/43/c1e8726fa59c236ff477ff2b5dc071e54b21e5a1e51aa2cee1676f1c986f/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:23b1a8f26e43f47ceb6d6a43115f33a5a37d57df4ea0ca295b780ae8546e8044", size = 4292415, upload-time = "2025-10-15T23:17:31.686Z" }, + { url = "https://files.pythonhosted.org/packages/42/f9/2f8fefdb1aee8a8e3256a0568cffc4e6d517b256a2fe97a029b3f1b9fe7e/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b419ae593c86b87014b9be7396b385491ad7f320bde96826d0dd174459e54665", size = 4931457, upload-time = "2025-10-15T23:17:33.478Z" }, + { url = "https://files.pythonhosted.org/packages/79/30/9b54127a9a778ccd6d27c3da7563e9f2d341826075ceab89ae3b41bf5be2/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:50fc3343ac490c6b08c0cf0d704e881d0d660be923fd3076db3e932007e726e3", size = 4466074, upload-time = "2025-10-15T23:17:35.158Z" }, + { url = "https://files.pythonhosted.org/packages/ac/68/b4f4a10928e26c941b1b6a179143af9f4d27d88fe84a6a3c53592d2e76bf/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22d7e97932f511d6b0b04f2bfd818d73dcd5928db509460aaf48384778eb6d20", size = 4420569, upload-time = "2025-10-15T23:17:37.188Z" }, + { url = "https://files.pythonhosted.org/packages/a3/49/3746dab4c0d1979888f125226357d3262a6dd40e114ac29e3d2abdf1ec55/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d55f3dffadd674514ad19451161118fd010988540cee43d8bc20675e775925de", size = 4681941, upload-time = "2025-10-15T23:17:39.236Z" }, + { url = "https://files.pythonhosted.org/packages/27/32/b68d27471372737054cbd34c84981f9edbc24fe67ca225d389799614e27f/cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683", size = 4294089, upload-time = "2025-10-15T23:17:48.269Z" }, + { url = "https://files.pythonhosted.org/packages/26/42/fa8389d4478368743e24e61eea78846a0006caffaf72ea24a15159215a14/cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d", size = 4440029, upload-time = "2025-10-15T23:17:49.837Z" }, + { url = "https://files.pythonhosted.org/packages/5f/eb/f483db0ec5ac040824f269e93dd2bd8a21ecd1027e77ad7bdf6914f2fd80/cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0", size = 4297222, upload-time = "2025-10-15T23:17:51.357Z" }, + { url = "https://files.pythonhosted.org/packages/fd/cf/da9502c4e1912cb1da3807ea3618a6829bee8207456fbbeebc361ec38ba3/cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc", size = 4012280, upload-time = "2025-10-15T23:17:52.964Z" }, + { url = "https://files.pythonhosted.org/packages/6b/8f/9adb86b93330e0df8b3dcf03eae67c33ba89958fc2e03862ef1ac2b42465/cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3", size = 4978958, upload-time = "2025-10-15T23:17:54.965Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a0/5fa77988289c34bdb9f913f5606ecc9ada1adb5ae870bd0d1054a7021cc4/cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971", size = 4473714, upload-time = "2025-10-15T23:17:56.754Z" }, + { url = "https://files.pythonhosted.org/packages/14/e5/fc82d72a58d41c393697aa18c9abe5ae1214ff6f2a5c18ac470f92777895/cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac", size = 4296970, upload-time = "2025-10-15T23:17:58.588Z" }, + { url = "https://files.pythonhosted.org/packages/78/06/5663ed35438d0b09056973994f1aec467492b33bd31da36e468b01ec1097/cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04", size = 4940236, upload-time = "2025-10-15T23:18:00.897Z" }, + { url = "https://files.pythonhosted.org/packages/fc/59/873633f3f2dcd8a053b8dd1d38f783043b5fce589c0f6988bf55ef57e43e/cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506", size = 4472642, upload-time = "2025-10-15T23:18:02.749Z" }, + { url = "https://files.pythonhosted.org/packages/3d/39/8e71f3930e40f6877737d6f69248cf74d4e34b886a3967d32f919cc50d3b/cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963", size = 4423126, upload-time = "2025-10-15T23:18:04.85Z" }, + { url = "https://files.pythonhosted.org/packages/cd/c7/f65027c2810e14c3e7268353b1681932b87e5a48e65505d8cc17c99e36ae/cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4", size = 4686573, upload-time = "2025-10-15T23:18:06.908Z" }, + { url = "https://files.pythonhosted.org/packages/da/38/f59940ec4ee91e93d3311f7532671a5cef5570eb04a144bf203b58552d11/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:191bb60a7be5e6f54e30ba16fdfae78ad3a342a0599eb4193ba88e3f3d6e185b", size = 4243992, upload-time = "2025-10-15T23:18:18.695Z" }, + { url = "https://files.pythonhosted.org/packages/b0/0c/35b3d92ddebfdfda76bb485738306545817253d0a3ded0bfe80ef8e67aa5/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c70cc23f12726be8f8bc72e41d5065d77e4515efae3690326764ea1b07845cfb", size = 4409944, upload-time = "2025-10-15T23:18:20.597Z" }, + { url = "https://files.pythonhosted.org/packages/99/55/181022996c4063fc0e7666a47049a1ca705abb9c8a13830f074edb347495/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:9394673a9f4de09e28b5356e7fff97d778f8abad85c9d5ac4a4b7e25a0de7717", size = 4242957, upload-time = "2025-10-15T23:18:22.18Z" }, + { url = "https://files.pythonhosted.org/packages/ba/af/72cd6ef29f9c5f731251acadaeb821559fe25f10852f44a63374c9ca08c1/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:94cd0549accc38d1494e1f8de71eca837d0509d0d44bf11d158524b0e12cebf9", size = 4409447, upload-time = "2025-10-15T23:18:24.209Z" }, +] + [[package]] name = "cyclonedx-bom" version = "7.2.1" @@ -282,6 +393,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/91/a1/cf2472db20f7ce4a6be1253a81cfdf85ad9c7885ffbed7047fb72c24cf87/distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87", size = 468973, upload-time = "2024-10-09T18:35:44.272Z" }, ] +[[package]] +name = "docutils" +version = "0.22.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/b6/03bb70946330e88ffec97aefd3ea75ba575cb2e762061e0e62a213befee8/docutils-0.22.4.tar.gz", hash = "sha256:4db53b1fde9abecbb74d91230d32ab626d94f6badfc575d6db9194a49df29968", size = 2291750, upload-time = "2025-12-18T19:00:26.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de", size = 633196, upload-time = "2025-12-18T19:00:18.077Z" }, +] + [[package]] name = "exceptiongroup" version = "1.2.2" @@ -309,6 +429,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cf/58/8acf1b3e91c58313ce5cb67df61001fc9dcd21be4fadb76c1a2d540e09ed/fqdn-1.5.1-py3-none-any.whl", hash = "sha256:3a179af3761e4df6eb2e026ff9e1a3033d3587bf980a0b1b2e1e5d08d7358014", size = 9121, upload-time = "2021-03-11T07:16:28.351Z" }, ] +[[package]] +name = "id" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/11/102da08f88412d875fa2f1a9a469ff7ad4c874b0ca6fed0048fe385bdb3d/id-1.5.0.tar.gz", hash = "sha256:292cb8a49eacbbdbce97244f47a97b4c62540169c976552e497fd57df0734c1d", size = 15237, upload-time = "2024-12-04T19:53:05.575Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/cb/18326d2d89ad3b0dd143da971e77afd1e6ca6674f1b1c3df4b6bec6279fc/id-1.5.0-py3-none-any.whl", hash = "sha256:f1434e1cef91f2cbb8a4ec64663d5a23b9ed43ef44c4c957d02583d61714c658", size = 13611, upload-time = "2024-12-04T19:53:03.02Z" }, +] + [[package]] name = "identify" version = "2.6.12" @@ -327,6 +459,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, ] +[[package]] +name = "importlib-metadata" +version = "8.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" }, +] + [[package]] name = "iniconfig" version = "2.0.0" @@ -357,6 +501,51 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7b/55/e5326141505c5d5e34c5e0935d2908a74e4561eca44108fbfb9c13d2911a/isoduration-20.11.0-py3-none-any.whl", hash = "sha256:b2904c2a4228c3d44f409c8ae8e2370eb21a26f7ac2ec5446df141dde3452042", size = 11321, upload-time = "2020-11-01T10:59:58.02Z" }, ] +[[package]] +name = "jaraco-classes" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", size = 11780, upload-time = "2024-03-31T07:27:36.643Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790", size = 6777, upload-time = "2024-03-31T07:27:34.792Z" }, +] + +[[package]] +name = "jaraco-context" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-tarfile", marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cb/9c/a788f5bb29c61e456b8ee52ce76dbdd32fd72cd73dd67bc95f42c7a8d13c/jaraco_context-6.1.0.tar.gz", hash = "sha256:129a341b0a85a7db7879e22acd66902fda67882db771754574338898b2d5d86f", size = 15850, upload-time = "2026-01-13T02:53:53.847Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/48/aa685dbf1024c7bd82bede569e3a85f82c32fd3d79ba5fea578f0159571a/jaraco_context-6.1.0-py3-none-any.whl", hash = "sha256:a43b5ed85815223d0d3cfdb6d7ca0d2bc8946f28f30b6f3216bda070f68badda", size = 7065, upload-time = "2026-01-13T02:53:53.031Z" }, +] + +[[package]] +name = "jaraco-functools" +version = "4.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0f/27/056e0638a86749374d6f57d0b0db39f29509cce9313cf91bdc0ac4d91084/jaraco_functools-4.4.0.tar.gz", hash = "sha256:da21933b0417b89515562656547a77b4931f98176eb173644c0d35032a33d6bb", size = 19943, upload-time = "2025-12-21T09:29:43.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/c4/813bb09f0985cb21e959f21f2464169eca882656849adf727ac7bb7e1767/jaraco_functools-4.4.0-py3-none-any.whl", hash = "sha256:9eec1e36f45c818d9bf307c8948eb03b2b56cd44087b3cdc989abca1f20b9176", size = 10481, upload-time = "2025-12-21T09:29:42.27Z" }, +] + +[[package]] +name = "jeepney" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732", size = 106758, upload-time = "2025-02-27T18:51:01.684Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" }, +] + [[package]] name = "jsonpointer" version = "3.0.0" @@ -406,6 +595,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/0f/8910b19ac0670a0f80ce1008e5e751c4a57e14d2c4c13a482aa6079fa9d6/jsonschema_specifications-2024.10.1-py3-none-any.whl", hash = "sha256:a09a0680616357d9a0ecf05c12ad234479f549239d0f5b55f3deea67475da9bf", size = 18459, upload-time = "2024-10-08T12:29:30.439Z" }, ] +[[package]] +name = "keyring" +version = "25.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata", marker = "python_full_version < '3.12'" }, + { name = "jaraco-classes" }, + { name = "jaraco-context" }, + { name = "jaraco-functools" }, + { name = "jeepney", marker = "sys_platform == 'linux'" }, + { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, + { name = "secretstorage", marker = "sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/4b/674af6ef2f97d56f0ab5153bf0bfa28ccb6c3ed4d1babf4305449668807b/keyring-25.7.0.tar.gz", hash = "sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b", size = 63516, upload-time = "2025-11-16T16:26:09.482Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160, upload-time = "2025-11-16T16:26:08.402Z" }, +] + [[package]] name = "lark" version = "1.3.1" @@ -530,6 +737,48 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] +[[package]] +name = "more-itertools" +version = "10.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ea/5d/38b681d3fce7a266dd9ab73c66959406d565b3e85f21d5e66e1181d93721/more_itertools-10.8.0.tar.gz", hash = "sha256:f638ddf8a1a0d134181275fb5d58b086ead7c6a72429ad725c67503f13ba30bd", size = 137431, upload-time = "2025-09-02T15:23:11.018Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/8e/469e5a4a2f5855992e425f3cb33804cc07bf18d48f2db061aec61ce50270/more_itertools-10.8.0-py3-none-any.whl", hash = "sha256:52d4362373dcf7c52546bc4af9a86ee7c4579df9a8dc268be0a2f949d376cc9b", size = 69667, upload-time = "2025-09-02T15:23:09.635Z" }, +] + +[[package]] +name = "nh3" +version = "0.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/a5/34c26015d3a434409f4d2a1cd8821a06c05238703f49283ffeb937bef093/nh3-0.3.2.tar.gz", hash = "sha256:f394759a06df8b685a4ebfb1874fb67a9cbfd58c64fc5ed587a663c0e63ec376", size = 19288, upload-time = "2025-10-30T11:17:45.948Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/01/a1eda067c0ba823e5e2bb033864ae4854549e49fb6f3407d2da949106bfb/nh3-0.3.2-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:d18957a90806d943d141cc5e4a0fefa1d77cf0d7a156878bf9a66eed52c9cc7d", size = 1419839, upload-time = "2025-10-30T11:17:09.956Z" }, + { url = "https://files.pythonhosted.org/packages/30/57/07826ff65d59e7e9cc789ef1dc405f660cabd7458a1864ab58aefa17411b/nh3-0.3.2-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:45c953e57028c31d473d6b648552d9cab1efe20a42ad139d78e11d8f42a36130", size = 791183, upload-time = "2025-10-30T11:17:11.99Z" }, + { url = "https://files.pythonhosted.org/packages/af/2f/e8a86f861ad83f3bb5455f596d5c802e34fcdb8c53a489083a70fd301333/nh3-0.3.2-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2c9850041b77a9147d6bbd6dbbf13eeec7009eb60b44e83f07fcb2910075bf9b", size = 829127, upload-time = "2025-10-30T11:17:13.192Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/77aef4daf0479754e8e90c7f8f48f3b7b8725a3b8c0df45f2258017a6895/nh3-0.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:403c11563e50b915d0efdb622866d1d9e4506bce590ef7da57789bf71dd148b5", size = 997131, upload-time = "2025-10-30T11:17:14.677Z" }, + { url = "https://files.pythonhosted.org/packages/41/ee/fd8140e4df9d52143e89951dd0d797f5546004c6043285289fbbe3112293/nh3-0.3.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:0dca4365db62b2d71ff1620ee4f800c4729849906c5dd504ee1a7b2389558e31", size = 1068783, upload-time = "2025-10-30T11:17:15.861Z" }, + { url = "https://files.pythonhosted.org/packages/87/64/bdd9631779e2d588b08391f7555828f352e7f6427889daf2fa424bfc90c9/nh3-0.3.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0fe7ee035dd7b2290715baf29cb27167dddd2ff70ea7d052c958dbd80d323c99", size = 994732, upload-time = "2025-10-30T11:17:17.155Z" }, + { url = "https://files.pythonhosted.org/packages/79/66/90190033654f1f28ca98e3d76b8be1194505583f9426b0dcde782a3970a2/nh3-0.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a40202fd58e49129764f025bbaae77028e420f1d5b3c8e6f6fd3a6490d513868", size = 975997, upload-time = "2025-10-30T11:17:18.77Z" }, + { url = "https://files.pythonhosted.org/packages/34/30/ebf8e2e8d71fdb5a5d5d8836207177aed1682df819cbde7f42f16898946c/nh3-0.3.2-cp314-cp314t-win32.whl", hash = "sha256:1f9ba555a797dbdcd844b89523f29cdc90973d8bd2e836ea6b962cf567cadd93", size = 583364, upload-time = "2025-10-30T11:17:20.286Z" }, + { url = "https://files.pythonhosted.org/packages/94/ae/95c52b5a75da429f11ca8902c2128f64daafdc77758d370e4cc310ecda55/nh3-0.3.2-cp314-cp314t-win_amd64.whl", hash = "sha256:dce4248edc427c9b79261f3e6e2b3ecbdd9b88c267012168b4a7b3fc6fd41d13", size = 589982, upload-time = "2025-10-30T11:17:21.384Z" }, + { url = "https://files.pythonhosted.org/packages/b4/bd/c7d862a4381b95f2469704de32c0ad419def0f4a84b7a138a79532238114/nh3-0.3.2-cp314-cp314t-win_arm64.whl", hash = "sha256:019ecbd007536b67fdf76fab411b648fb64e2257ca3262ec80c3425c24028c80", size = 577126, upload-time = "2025-10-30T11:17:22.755Z" }, + { url = "https://files.pythonhosted.org/packages/b6/3e/f5a5cc2885c24be13e9b937441bd16a012ac34a657fe05e58927e8af8b7a/nh3-0.3.2-cp38-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:7064ccf5ace75825bd7bf57859daaaf16ed28660c1c6b306b649a9eda4b54b1e", size = 1431980, upload-time = "2025-10-30T11:17:25.457Z" }, + { url = "https://files.pythonhosted.org/packages/7f/f7/529a99324d7ef055de88b690858f4189379708abae92ace799365a797b7f/nh3-0.3.2-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c8745454cdd28bbbc90861b80a0111a195b0e3961b9fa2e672be89eb199fa5d8", size = 820805, upload-time = "2025-10-30T11:17:26.98Z" }, + { url = "https://files.pythonhosted.org/packages/3d/62/19b7c50ccd1fa7d0764822d2cea8f2a320f2fd77474c7a1805cb22cf69b0/nh3-0.3.2-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72d67c25a84579f4a432c065e8b4274e53b7cf1df8f792cf846abfe2c3090866", size = 803527, upload-time = "2025-10-30T11:17:28.284Z" }, + { url = "https://files.pythonhosted.org/packages/4a/ca/f022273bab5440abff6302731a49410c5ef66b1a9502ba3fbb2df998d9ff/nh3-0.3.2-cp38-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:13398e676a14d6233f372c75f52d5ae74f98210172991f7a3142a736bd92b131", size = 1051674, upload-time = "2025-10-30T11:17:29.909Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f7/5728e3b32a11daf5bd21cf71d91c463f74305938bc3eb9e0ac1ce141646e/nh3-0.3.2-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:03d617e5c8aa7331bd2659c654e021caf9bba704b109e7b2b28b039a00949fe5", size = 1004737, upload-time = "2025-10-30T11:17:31.205Z" }, + { url = "https://files.pythonhosted.org/packages/53/7f/f17e0dba0a99cee29e6cee6d4d52340ef9cb1f8a06946d3a01eb7ec2fb01/nh3-0.3.2-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f2f55c4d2d5a207e74eefe4d828067bbb01300e06e2a7436142f915c5928de07", size = 911745, upload-time = "2025-10-30T11:17:32.945Z" }, + { url = "https://files.pythonhosted.org/packages/42/0f/c76bf3dba22c73c38e9b1113b017cf163f7696f50e003404ec5ecdb1e8a6/nh3-0.3.2-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7bb18403f02b655a1bbe4e3a4696c2ae1d6ae8f5991f7cacb684b1ae27e6c9f7", size = 797184, upload-time = "2025-10-30T11:17:34.226Z" }, + { url = "https://files.pythonhosted.org/packages/08/a1/73d8250f888fb0ddf1b119b139c382f8903d8bb0c5bd1f64afc7e38dad1d/nh3-0.3.2-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6d66f41672eb4060cf87c037f760bdbc6847852ca9ef8e9c5a5da18f090abf87", size = 838556, upload-time = "2025-10-30T11:17:35.875Z" }, + { url = "https://files.pythonhosted.org/packages/d1/09/deb57f1fb656a7a5192497f4a287b0ade5a2ff6b5d5de4736d13ef6d2c1f/nh3-0.3.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f97f8b25cb2681d25e2338148159447e4d689aafdccfcf19e61ff7db3905768a", size = 1006695, upload-time = "2025-10-30T11:17:37.071Z" }, + { url = "https://files.pythonhosted.org/packages/b6/61/8f4d41c4ccdac30e4b1a4fa7be4b0f9914d8314a5058472f84c8e101a418/nh3-0.3.2-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:2ab70e8c6c7d2ce953d2a58102eefa90c2d0a5ed7aa40c7e29a487bc5e613131", size = 1075471, upload-time = "2025-10-30T11:17:38.225Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c6/966aec0cb4705e69f6c3580422c239205d5d4d0e50fac380b21e87b6cf1b/nh3-0.3.2-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:1710f3901cd6440ca92494ba2eb6dc260f829fa8d9196b659fa10de825610ce0", size = 1002439, upload-time = "2025-10-30T11:17:39.553Z" }, + { url = "https://files.pythonhosted.org/packages/e2/c8/97a2d5f7a314cce2c5c49f30c6f161b7f3617960ade4bfc2fd1ee092cb20/nh3-0.3.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:91e9b001101fb4500a2aafe3e7c92928d85242d38bf5ac0aba0b7480da0a4cd6", size = 987439, upload-time = "2025-10-30T11:17:40.81Z" }, + { url = "https://files.pythonhosted.org/packages/0d/95/2d6fc6461687d7a171f087995247dec33e8749a562bfadd85fb5dbf37a11/nh3-0.3.2-cp38-abi3-win32.whl", hash = "sha256:169db03df90da63286e0560ea0efa9b6f3b59844a9735514a1d47e6bb2c8c61b", size = 589826, upload-time = "2025-10-30T11:17:42.239Z" }, + { url = "https://files.pythonhosted.org/packages/64/9a/1a1c154f10a575d20dd634e5697805e589bbdb7673a0ad00e8da90044ba7/nh3-0.3.2-cp38-abi3-win_amd64.whl", hash = "sha256:562da3dca7a17f9077593214a9781a94b8d76de4f158f8c895e62f09573945fe", size = 596406, upload-time = "2025-10-30T11:17:43.773Z" }, + { url = "https://files.pythonhosted.org/packages/9e/7e/a96255f63b7aef032cbee8fc4d6e37def72e3aaedc1f72759235e8f13cb1/nh3-0.3.2-cp38-abi3-win_arm64.whl", hash = "sha256:cf5964d54edd405e68583114a7cba929468bcd7db5e676ae38ee954de1cfc104", size = 584162, upload-time = "2025-10-30T11:17:44.96Z" }, +] + [[package]] name = "nodeenv" version = "1.9.1" @@ -625,6 +874,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9b/bf/7595e817906a29453ba4d99394e781b6fabe55d21f3c15d240f85dd06bb1/py_serializable-2.1.0-py3-none-any.whl", hash = "sha256:b56d5d686b5a03ba4f4db5e769dc32336e142fc3bd4d68a8c25579ebb0a67304", size = 23045, upload-time = "2025-07-21T09:56:46.848Z" }, ] +[[package]] +name = "pycparser" +version = "2.23" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", size = 173734, upload-time = "2025-09-09T13:23:47.91Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" }, +] + [[package]] name = "pygments" version = "2.19.2" @@ -697,6 +955,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, ] +[[package]] +name = "pywin32-ctypes" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" }, +] + [[package]] name = "pyyaml" version = "6.0.2" @@ -754,6 +1021,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a7/52/9d03e93f2e00d2a07749ee90f358d08c07822819d084f08c387b7ade8b56/rdflib-7.4.0-py3-none-any.whl", hash = "sha256:0af003470404ff21bc0eb04077cc97ee96da581f2429bf42a8e163fc1c2797bc", size = 569019, upload-time = "2025-10-30T12:55:14.462Z" }, ] +[[package]] +name = "readme-renderer" +version = "44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils" }, + { name = "nh3" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/a9/104ec9234c8448c4379768221ea6df01260cd6c2ce13182d4eac531c8342/readme_renderer-44.0.tar.gz", hash = "sha256:8712034eabbfa6805cacf1402b4eeb2a73028f72d1166d6f5cb7f9c047c5d1e1", size = 32056, upload-time = "2024-07-08T15:00:57.805Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/67/921ec3024056483db83953ae8e48079ad62b92db7880013ca77632921dd0/readme_renderer-44.0-py3-none-any.whl", hash = "sha256:2fbca89b81a08526aadf1357a8c2ae889ec05fb03f5da67f9769c9a592166151", size = 13310, upload-time = "2024-07-08T15:00:56.577Z" }, +] + [[package]] name = "referencing" version = "0.35.1" @@ -782,6 +1063,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7c/e4/56027c4a6b4ae70ca9de302488c5ca95ad4a39e190093d6c1a8ace08341b/requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c", size = 64847, upload-time = "2025-06-09T16:43:05.728Z" }, ] +[[package]] +name = "requests-toolbelt" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", size = 206888, upload-time = "2023-05-01T04:11:33.229Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" }, +] + [[package]] name = "rfc3339-validator" version = "0.1.4" @@ -794,6 +1087,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7b/44/4e421b96b67b2daff264473f7465db72fbdf36a07e05494f50300cc7b0c6/rfc3339_validator-0.1.4-py2.py3-none-any.whl", hash = "sha256:24f6ec1eda14ef823da9e36ec7113124b39c04d50a4d3d3a3c2859577e7791fa", size = 3490, upload-time = "2021-05-12T16:37:52.536Z" }, ] +[[package]] +name = "rfc3986" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/40/1520d68bfa07ab5a6f065a186815fb6610c86fe957bc065754e47f7b0840/rfc3986-2.0.0.tar.gz", hash = "sha256:97aacf9dbd4bfd829baad6e6309fa6573aaf1be3f6fa735c8ab05e46cecb261c", size = 49026, upload-time = "2022-01-10T00:52:30.832Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/9a/9afaade874b2fa6c752c36f1548f718b5b83af81ed9b76628329dab81c1b/rfc3986-2.0.0-py2.py3-none-any.whl", hash = "sha256:50b1502b60e289cb37883f3dfd34532b8873c7de9f49bb546641ce9cbd256ebd", size = 31326, upload-time = "2022-01-10T00:52:29.594Z" }, +] + [[package]] name = "rfc3986-validator" version = "0.1.1" @@ -947,6 +1249,7 @@ dev = [ { name = "pytest-cov" }, { name = "pytest-mock" }, { name = "ruff" }, + { name = "twine" }, ] [package.metadata] @@ -968,6 +1271,20 @@ dev = [ { name = "pytest-cov", specifier = ">=4.1.0,<5" }, { name = "pytest-mock", specifier = ">=3.12.0,<4" }, { name = "ruff", specifier = ">=0.12.0,<0.13" }, + { name = "twine", specifier = ">=6.2.0" }, +] + +[[package]] +name = "secretstorage" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "jeepney" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554, upload-time = "2025-11-23T19:02:51.545Z" }, ] [[package]] @@ -1039,6 +1356,26 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cf/db/ce8eda256fa131af12e0a76d481711abe4681b6923c27efb9a255c9e4594/tomli-2.0.2-py3-none-any.whl", hash = "sha256:2ebe24485c53d303f690b0ec092806a085f07af5a5aa1464f3931eec36caaa38", size = 13237, upload-time = "2024-10-02T10:46:11.806Z" }, ] +[[package]] +name = "twine" +version = "6.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "id" }, + { name = "keyring", marker = "platform_machine != 'ppc64le' and platform_machine != 's390x'" }, + { name = "packaging" }, + { name = "readme-renderer" }, + { name = "requests" }, + { name = "requests-toolbelt" }, + { name = "rfc3986" }, + { name = "rich" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e0/a8/949edebe3a82774c1ec34f637f5dd82d1cf22c25e963b7d63771083bbee5/twine-6.2.0.tar.gz", hash = "sha256:e5ed0d2fd70c9959770dce51c8f39c8945c574e18173a7b81802dab51b4b75cf", size = 172262, upload-time = "2025-09-04T15:43:17.255Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/7a/882d99539b19b1490cac5d77c67338d126e4122c8276bf640e411650c830/twine-6.2.0-py3-none-any.whl", hash = "sha256:418ebf08ccda9a8caaebe414433b0ba5e25eb5e4a927667122fbe8f829f985d8", size = 42727, upload-time = "2025-09-04T15:43:15.994Z" }, +] + [[package]] name = "types-python-dateutil" version = "2.9.0.20241003" @@ -1116,6 +1453,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c0/20/69a0e6058bc5ea74892d089d64dfc3a62ba78917ec5e2cfa70f7c92ba3a5/xmltodict-1.0.2-py3-none-any.whl", hash = "sha256:62d0fddb0dcbc9f642745d8bbf4d81fd17d6dfaec5a15b5c1876300aad92af0d", size = 13893, upload-time = "2025-09-17T21:59:24.859Z" }, ] +[[package]] +name = "zipp" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" }, +] + [[package]] name = "zstandard" version = "0.25.0"