├── .gitignore ├── shard.lock ├── src ├── main.cr ├── shard │ ├── shard_file.cr │ └── shard_lock_file.cr ├── cyclonedx │ ├── component.cr │ └── bom.cr └── app.cr ├── shard.yml ├── github-action ├── Dockerfile ├── README.md └── entrypoint.sh ├── .github ├── FUNDING.yml ├── workflows │ ├── publish-homebrew-tap.yml │ ├── release-sbom.yml │ ├── ci.yml │ └── publish-ghcr.yml └── copilot-instructions.md ├── LICENSE ├── action.yml ├── Dockerfile ├── spec └── main_spec.cr └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | bin 2 | lib 3 | ./bom.* 4 | -------------------------------------------------------------------------------- /shard.lock: -------------------------------------------------------------------------------- 1 | version: 2.0 2 | shards: {} 3 | -------------------------------------------------------------------------------- /src/main.cr: -------------------------------------------------------------------------------- 1 | require "./app" 2 | 3 | App.new.run 4 | -------------------------------------------------------------------------------- /shard.yml: -------------------------------------------------------------------------------- 1 | name: cyclonedx-cr 2 | version: 1.0.0 3 | 4 | authors: 5 | - hahwul 6 | 7 | targets: 8 | cyclonedx-cr: 9 | main: src/main.cr 10 | 11 | crystal: 1.6.2 12 | 13 | license: MIT 14 | -------------------------------------------------------------------------------- /github-action/Dockerfile: -------------------------------------------------------------------------------- 1 | # Use the cyclonedx-cr base image from GitHub Container Registry 2 | FROM ghcr.io/hahwul/cyclonedx-cr:v1.0.0 3 | 4 | # Copy the entrypoint script into the container 5 | COPY entrypoint.sh /entrypoint.sh 6 | 7 | # Run the script via /bin/sh, so no executable bit is required 8 | 9 | # Set the entrypoint to the script 10 | ENTRYPOINT ["/bin/sh", "-l", "/entrypoint.sh"] 11 | -------------------------------------------------------------------------------- /src/shard/shard_file.cr: -------------------------------------------------------------------------------- 1 | require "yaml" 2 | 3 | # Represents the structure of a `shard.yml` file. 4 | # This class is used to parse the main project's metadata 5 | # such as its name and version. 6 | class ShardFile 7 | include YAML::Serializable 8 | # The name of the project/shard. 9 | property name : String 10 | # The version of the project/shard. 11 | property version : String 12 | end 13 | -------------------------------------------------------------------------------- /github-action/README.md: -------------------------------------------------------------------------------- 1 | # CycloneDX Crystal GitHub Action 2 | 3 | This directory contains the Docker-based GitHub Action implementation for cyclonedx-cr. 4 | 5 | ## Files 6 | 7 | - `Dockerfile`: Docker image configuration using `ghcr.io/hahwul/cyclonedx-cr` as base 8 | - `entrypoint.sh`: Script that processes GitHub Action inputs and executes cyclonedx-cr 9 | 10 | ## Usage 11 | 12 | See the main repository README and `action.yml` for complete usage instructions. -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: hahwul 2 | patreon: # Replace with a single Patreon username 3 | open_collective: # Replace with a single Open Collective username 4 | ko_fi: # Replace with a single Ko-fi username 5 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 6 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 7 | liberapay: # Replace with a single Liberapay username 8 | issuehunt: # Replace with a single IssueHunt username 9 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 10 | polar: # Replace with a single Polar username 11 | buy_me_a_coffee: hahwul 12 | thanks_dev: # Replace with a single thanks.dev username 13 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 14 | -------------------------------------------------------------------------------- /src/shard/shard_lock_file.cr: -------------------------------------------------------------------------------- 1 | require "yaml" 2 | 3 | # Represents the structure of a `shard.lock` file. 4 | # This file contains the resolved dependencies of the project. 5 | class ShardLockFile 6 | include YAML::Serializable 7 | # A hash mapping shard names to their `ShardLockEntry` details. 8 | property shards : Hash(String, ShardLockEntry) 9 | end 10 | 11 | # Represents a single entry within the `shards` section of a `shard.lock` file. 12 | # It provides details about a specific dependency. 13 | class ShardLockEntry 14 | include YAML::Serializable 15 | # The version of the locked dependency. 16 | property version : String 17 | # The Git URL if the dependency is sourced from a Git repository. 18 | property git : String? 19 | # The GitHub repository path (e.g., "owner/repo") if sourced from GitHub. 20 | property github : String? 21 | # The local path if the dependency is a local path dependency. 22 | property path : String? 23 | end 24 | -------------------------------------------------------------------------------- /.github/workflows/publish-homebrew-tap.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Homebrew tap Publish 3 | on: 4 | release: 5 | types: [published] 6 | jobs: 7 | homebrew-releaser: 8 | runs-on: ubuntu-latest 9 | name: homebrew-releaser 10 | steps: 11 | - name: Release cyclonedx-cr to Homebrew tap 12 | uses: Justintime50/homebrew-releaser@v1 13 | with: 14 | homebrew_owner: hahwul 15 | homebrew_tap: homebrew-cyclonedx-cr 16 | formula_folder: Formula 17 | github_token: ${{ secrets.PUBLISH_TOKEN }} 18 | commit_owner: hahwul 19 | commit_email: hahwul@gmail.com 20 | depends_on: | 21 | "crystal" 22 | install: | 23 | system "shards install" 24 | system "shards build --release --no-debug --production" 25 | bin.install "bin/cyclonedx-cr" 26 | test: system "{bin}/cyclonedx-cr", "-v" 27 | update_readme_table: true 28 | skip_commit: false 29 | debug: false 30 | -------------------------------------------------------------------------------- /.github/workflows/release-sbom.yml: -------------------------------------------------------------------------------- 1 | name: Generate and Upload SBOM 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | jobs: 8 | generate-sbom: 9 | runs-on: ubuntu-latest 10 | permissions: 11 | contents: write 12 | steps: 13 | # Checkout the repository code 14 | - name: Checkout code 15 | uses: actions/checkout@v4 16 | 17 | # Generate SBOM using hahwul/cyclonedx-cr action 18 | - name: Generate SBOM 19 | uses: hahwul/cyclonedx-cr@v1.0.0 20 | with: 21 | shard_file: ./shard.yml # Explicitly map to shard_file 22 | lock_file: ./shard.lock # Explicitly map to lock_file 23 | output_file: ./sbom.xml # Map to output_file 24 | output_format: xml # Map to output_format 25 | spec_version: 1.6 # Optional, specify if needed 26 | 27 | # Upload SBOM to GitHub Release 28 | - name: Upload SBOM to Release 29 | uses: softprops/action-gh-release@v2 30 | with: 31 | files: ./sbom.xml 32 | token: ${{ secrets.GITHUB_TOKEN }} 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 HAHWUL 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | name: CycloneDX Crystal Action 2 | description: A GitHub Action to generate CycloneDX SBOM from Crystal shard projects. 3 | branding: 4 | icon: package 5 | color: purple 6 | inputs: 7 | shard_file: 8 | description: The shard.yml file path 9 | required: false 10 | default: "shard.yml" 11 | lock_file: 12 | description: The shard.lock file path 13 | required: false 14 | default: "shard.lock" 15 | output_file: 16 | description: Output file path (if not specified, outputs to stdout) 17 | required: false 18 | default: "" 19 | spec_version: 20 | description: "CycloneDX spec version (options: 1.4, 1.5, 1.6)" 21 | required: false 22 | default: "1.6" 23 | output_format: 24 | description: "Output format (options: json, xml, csv)" 25 | required: false 26 | default: "json" 27 | outputs: 28 | sbom_file: 29 | description: Path to the generated SBOM file 30 | sbom_content: 31 | description: Content of the generated SBOM (when output_file is not specified) 32 | runs: 33 | using: docker 34 | image: github-action/Dockerfile 35 | args: 36 | - ${{ inputs.shard_file }} 37 | - ${{ inputs.lock_file }} 38 | - ${{ inputs.output_file }} 39 | - ${{ inputs.spec_version }} 40 | - ${{ inputs.output_format }} 41 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ##= BUILDER =## 2 | FROM 84codes/crystal:latest-debian-12 AS builder 3 | 4 | WORKDIR /cyclonedx-cr 5 | 6 | # Install build dependencies for the libraries Crystal links against 7 | RUN apt-get update && \ 8 | apt-get install -y --no-install-recommends \ 9 | build-essential pkg-config \ 10 | libyaml-dev libxml2-dev libicu-dev libpcre2-dev libgc-dev liblzma-dev zlib1g-dev && \ 11 | rm -rf /var/lib/apt/lists/* 12 | 13 | # Install shards first for better layer cache 14 | COPY shard.yml shard.lock ./ 15 | RUN shards install --production 16 | 17 | # Copy the rest and build (no --static) 18 | COPY . . 19 | RUN shards build --release --no-debug --production && \ 20 | strip bin/cyclonedx-cr 21 | 22 | ##= RUNNER =## 23 | # Use Debian 12 runtime to match builder's ABI (avoids ICU/glibc mismatches) 24 | FROM debian:12-slim 25 | 26 | # Install runtime libraries required by the linked binary 27 | RUN apt-get update && apt-get install -y --no-install-recommends \ 28 | libyaml-0-2 \ 29 | libxml2 \ 30 | libicu72 \ 31 | libpcre2-8-0 \ 32 | libgc1 \ 33 | liblzma5 \ 34 | zlib1g \ 35 | ca-certificates && \ 36 | rm -rf /var/lib/apt/lists/* 37 | 38 | COPY --from=builder /cyclonedx-cr/bin/cyclonedx-cr /usr/local/bin/cyclonedx-cr 39 | 40 | # Run as non-root 41 | # USER 2:2 42 | 43 | # Default command 44 | CMD ["cyclonedx-cr"] 45 | -------------------------------------------------------------------------------- /src/cyclonedx/component.cr: -------------------------------------------------------------------------------- 1 | require "json" 2 | require "xml" 3 | 4 | # Represents a component in the CycloneDX Bill of Materials (BOM). 5 | # This class is responsible for defining the structure and serialization 6 | # of a software component, including its type, name, version, and PURL. 7 | class CycloneDX::Component 8 | include JSON::Serializable 9 | 10 | # The type of the component (e.g., "library", "application"). 11 | @[JSON::Field(key: "type")] 12 | property component_type : String 13 | # The name of the component. 14 | property name : String 15 | # The version of the component. 16 | property version : String 17 | # The Package URL (PURL) of the component, if available. 18 | property purl : String? 19 | 20 | # Initializes a new CycloneDX Component. 21 | # 22 | # @param name [String] The name of the component. 23 | # @param version [String] The version of the component. 24 | # @param component_type [String] The type of the component (default: "library"). 25 | # @param purl [String?] The PURL of the component (default: nil). 26 | def initialize(@name : String, @version : String, @component_type = "library", @purl = nil) 27 | end 28 | 29 | # Serializes the component to XML format. 30 | # 31 | # @param builder [XML::Builder] The XML builder instance. 32 | def to_xml(builder : XML::Builder) 33 | builder.element("component", attributes: {"type": @component_type}) do 34 | builder.element("name") { builder.text(@name) } 35 | builder.element("version") { builder.text(@version) } 36 | builder.element("purl") { builder.text(@purl.not_nil!) } if @purl 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /spec/main_spec.cr: -------------------------------------------------------------------------------- 1 | require "spec" 2 | require "../src/main" 3 | 4 | describe App do 5 | it "runs without errors" do 6 | # This is a very basic test to ensure the app doesn't crash. 7 | # We'll need to add more specific tests later. 8 | app = App.new 9 | # We need to mock the file system to test this properly. 10 | # For now, we'll just check that the App class can be instantiated. 11 | app.should_not be_nil 12 | end 13 | end 14 | 15 | describe CycloneDX::BOM do 16 | describe "spec version support" do 17 | it "supports spec version 1.4" do 18 | components = [CycloneDX::Component.new("test", "1.0.0")] 19 | bom = CycloneDX::BOM.new(components: components, spec_version: "1.4") 20 | json = bom.to_json 21 | json.should contain(%("specVersion":"1.4")) 22 | end 23 | 24 | it "supports spec version 1.5" do 25 | components = [CycloneDX::Component.new("test", "1.0.0")] 26 | bom = CycloneDX::BOM.new(components: components, spec_version: "1.5") 27 | json = bom.to_json 28 | json.should contain(%("specVersion":"1.5")) 29 | end 30 | 31 | it "supports spec version 1.6" do 32 | components = [CycloneDX::Component.new("test", "1.0.0")] 33 | bom = CycloneDX::BOM.new(components: components, spec_version: "1.6") 34 | json = bom.to_json 35 | json.should contain(%("specVersion":"1.6")) 36 | end 37 | 38 | it "supports spec version 1.7" do 39 | components = [CycloneDX::Component.new("test", "1.0.0")] 40 | bom = CycloneDX::BOM.new(components: components, spec_version: "1.7") 41 | json = bom.to_json 42 | json.should contain(%("specVersion":"1.7")) 43 | end 44 | 45 | it "generates correct XML namespace for spec version 1.7" do 46 | components = [CycloneDX::Component.new("test", "1.0.0")] 47 | bom = CycloneDX::BOM.new(components: components, spec_version: "1.7") 48 | xml = bom.to_xml 49 | xml.should contain(%(xmlns="http://cyclonedx.org/schema/bom/1.7")) 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /github-action/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh -l 2 | 3 | # GitHub Action inputs with defaults 4 | SHARD_FILE=${1:-shard.yml} 5 | LOCK_FILE=${2:-shard.lock} 6 | OUTPUT_FILE=$3 7 | SPEC_VERSION=${4:-1.6} 8 | OUTPUT_FORMAT=${5:-json} 9 | 10 | # Validate inputs 11 | if [ ! -f "$SHARD_FILE" ]; then 12 | echo "Error: Shard file '$SHARD_FILE' not found" 13 | exit 1 14 | fi 15 | 16 | if [ ! -f "$LOCK_FILE" ]; then 17 | echo "Error: Lock file '$LOCK_FILE' not found" 18 | exit 1 19 | fi 20 | 21 | # Find cyclonedx-cr binary 22 | if command -v cyclonedx-cr >/dev/null 2>&1; then 23 | CYCLONEDX_BIN="cyclonedx-cr" 24 | elif [ -f "/usr/local/bin/cyclonedx-cr" ]; then 25 | CYCLONEDX_BIN="/usr/local/bin/cyclonedx-cr" 26 | elif [ -f "/usr/bin/cyclonedx-cr" ]; then 27 | CYCLONEDX_BIN="/usr/bin/cyclonedx-cr" 28 | else 29 | echo "Error: cyclonedx-cr binary not found" 30 | exit 1 31 | fi 32 | 33 | # Build the cyclonedx-cr command 34 | cmd="$CYCLONEDX_BIN -s $SHARD_FILE -i $LOCK_FILE --spec-version $SPEC_VERSION --output-format $OUTPUT_FORMAT" 35 | 36 | # Handle output file 37 | if [ -n "$OUTPUT_FILE" ]; then 38 | cmd="$cmd -o $OUTPUT_FILE" 39 | OUTPUT_TO_FILE=true 40 | else 41 | OUTPUT_FILE="/tmp/sbom_output" 42 | cmd="$cmd -o $OUTPUT_FILE" 43 | OUTPUT_TO_FILE=false 44 | fi 45 | 46 | echo "Executing command: $cmd" 47 | 48 | # Execute the command 49 | if ! eval "$cmd"; then 50 | echo "Error: cyclonedx-cr command failed" 51 | exit 1 52 | fi 53 | 54 | # Verify output file exists 55 | if [ ! -f "$OUTPUT_FILE" ]; then 56 | echo "Error: Output file '$OUTPUT_FILE' not found" 57 | exit 1 58 | fi 59 | 60 | # Set GitHub Action outputs 61 | if [ "$OUTPUT_TO_FILE" = "true" ]; then 62 | echo "sbom_file=$OUTPUT_FILE" >> "$GITHUB_OUTPUT" 63 | echo "Generated SBOM file: $OUTPUT_FILE" 64 | else 65 | # Use heredoc for multiline output to avoid delimiter issues 66 | { 67 | echo "sbom_content<> "$GITHUB_OUTPUT" 71 | echo "Generated SBOM content (captured to output)" 72 | fi 73 | 74 | echo "cyclonedx-cr GitHub Action completed successfully" 75 | -------------------------------------------------------------------------------- /src/cyclonedx/bom.cr: -------------------------------------------------------------------------------- 1 | require "json" 2 | require "xml" 3 | require "csv" 4 | require "uuid" 5 | require "./component" 6 | 7 | # Represents a CycloneDX Bill of Materials (BOM). 8 | # This class manages a collection of components and provides methods 9 | # for serializing the BOM into different formats (JSON, XML, CSV). 10 | class CycloneDX::BOM 11 | include JSON::Serializable 12 | 13 | # Specifies the format of the BOM (always "CycloneDX" for JSON serialization). 14 | @[JSON::Field(key: "bomFormat")] 15 | property bom_format : String = "CycloneDX" 16 | 17 | # The CycloneDX specification version. 18 | @[JSON::Field(key: "specVersion")] 19 | property spec_version : String 20 | 21 | # The version of the BOM itself (not the spec version), typically 1. 22 | @[JSON::Field(key: "version")] 23 | property bom_version : Int32 = 1 24 | 25 | # An array of `CycloneDX::Component` objects included in the BOM. 26 | property components : Array(Component) 27 | 28 | # Initializes a new CycloneDX BOM. 29 | # 30 | # @param components [Array(Component)] An array of components to include in the BOM. 31 | # @param spec_version [String] The CycloneDX specification version (e.g., "1.4", "1.5"). 32 | def initialize(@components : Array(Component), @spec_version : String) 33 | end 34 | 35 | # Serializes the BOM to XML format. 36 | # The XML output includes a unique serial number (UUID). 37 | # 38 | # @return [String] The BOM in XML format. 39 | def to_xml 40 | String.build do |str| 41 | XML.build(str) do |xml| 42 | xml.element("bom", attributes: { 43 | "xmlns": "http://cyclonedx.org/schema/bom/#{@spec_version}", 44 | "version": "1", 45 | "serialNumber": "urn:uuid:#{UUID.random}", 46 | }) do 47 | xml.element("components") do 48 | @components.each do |component| 49 | component.to_xml(xml) 50 | end 51 | end 52 | end 53 | end 54 | end 55 | end 56 | 57 | # Serializes the BOM to CSV format. 58 | # The CSV output includes Name, Version, PURL, and Type for each component. 59 | # 60 | # @return [String] The BOM in CSV format. 61 | def to_csv 62 | CSV.build do |csv| 63 | csv.row "Name", "Version", "PURL", "Type" 64 | @components.each do |component| 65 | csv.row component.name, component.version, component.purl, component.component_type 66 | end 67 | end 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: CI 3 | on: 4 | pull_request_target: 5 | branches: [main] 6 | paths: ["**/*.cr", shard.yml, Dockerfile] 7 | workflow_dispatch: 8 | inputs: 9 | logLevel: 10 | description: manual run 11 | required: false 12 | default: "" 13 | jobs: 14 | build-crystal: 15 | runs-on: ubuntu-latest 16 | strategy: 17 | matrix: 18 | crystal-version: [1.14.1, 1.15.0, 1.16.0, 1.17.0] 19 | steps: 20 | - uses: actions/checkout@v4 21 | - uses: MeilCli/setup-crystal-action@v4 22 | with: 23 | crystal_version: ${{ matrix.crystal-version }} 24 | - name: Install dependencies 25 | run: shards install 26 | - name: Build 27 | run: shards build 28 | build-docker: 29 | runs-on: ubuntu-latest 30 | strategy: 31 | matrix: 32 | arch: [linux/amd64, linux/arm64] 33 | steps: 34 | - name: Checkout repository 35 | uses: actions/checkout@v4 36 | - name: Install cosign 37 | if: github.event_name != 'pull_request' 38 | uses: sigstore/cosign-installer@v3.1.1 39 | with: 40 | cosign-release: v2.1.1 41 | - name: Set up QEMU 42 | uses: docker/setup-qemu-action@v3 43 | - name: Setup Docker buildx 44 | uses: docker/setup-buildx-action@v3 45 | - name: Extract Docker metadata 46 | id: meta 47 | uses: docker/metadata-action@v5 48 | with: 49 | images: ghcr.io/${{ github.repository }} 50 | - name: Build Docker image 51 | id: build-and-push 52 | uses: docker/build-push-action@v5 53 | with: 54 | context: . 55 | push: false 56 | tags: ${{ steps.meta.outputs.tags }} 57 | labels: ${{ steps.meta.outputs.labels }} 58 | platforms: ${{ matrix.arch }} 59 | cache-from: type=gha,scope=${{ matrix.arch }} 60 | cache-to: type=gha,mode=max,scope=${{ matrix.arch }} 61 | lint: 62 | runs-on: ubuntu-latest 63 | container: 64 | image: crystallang/crystal 65 | steps: 66 | - uses: actions/checkout@v3 67 | - name: Crystal Ameba Linter 68 | id: crystal-ameba 69 | uses: crystal-ameba/github-action@v0.8.0 70 | env: 71 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 72 | tests: 73 | runs-on: ubuntu-latest 74 | container: 75 | image: 84codes/crystal:latest-debian-12 76 | steps: 77 | - uses: actions/checkout@v3 78 | - name: Install dependencies 79 | run: shards install 80 | - name: Run tests 81 | run: crystal spec 82 | -------------------------------------------------------------------------------- /.github/workflows/publish-ghcr.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: GHCR Publish 3 | # This workflow uses actions that are not certified by GitHub. 4 | # They are provided by a third-party and are governed by 5 | # separate terms of service, privacy policy, and support 6 | # documentation. 7 | on: 8 | push: 9 | branches: [main] 10 | release: 11 | types: [published] 12 | workflow_dispatch: 13 | 14 | env: 15 | # Use docker.io for Docker Hub if empty 16 | REGISTRY: ghcr.io 17 | # github.repository as / 18 | IMAGE_NAME: ${{ github.repository }} 19 | jobs: 20 | build: 21 | runs-on: ubuntu-latest 22 | permissions: 23 | contents: read 24 | packages: write 25 | # This is used to complete the identity challenge 26 | # with sigstore/fulcio when running outside of PRs. 27 | id-token: write 28 | steps: 29 | - name: Checkout repository 30 | uses: actions/checkout@v4 31 | 32 | # Install the cosign tool except on PR 33 | # https://github.com/sigstore/cosign-installer 34 | - name: Install cosign 35 | if: github.event_name != 'pull_request' 36 | uses: sigstore/cosign-installer@v3.1.1 37 | with: 38 | cosign-release: v2.1.1 39 | 40 | # Using QEME for multiple platforms 41 | # https://github.com/docker/build-push-action?tab=readme-ov-file#usage 42 | - name: Set up QEMU 43 | uses: docker/setup-qemu-action@v3 44 | 45 | # Workaround: https://github.com/docker/build-push-action/issues/461 46 | - name: Setup Docker buildx 47 | uses: docker/setup-buildx-action@v3 48 | 49 | # Login against a Docker registry except on PR 50 | # https://github.com/docker/login-action 51 | - name: Log into registry ${{ env.REGISTRY }} 52 | if: github.event_name != 'pull_request' 53 | uses: docker/login-action@v3 54 | with: 55 | registry: ${{ env.REGISTRY }} 56 | username: ${{ github.actor }} 57 | password: ${{ secrets.GITHUB_TOKEN }} 58 | 59 | # Extract metadata (tags, labels) for Docker 60 | # https://github.com/docker/metadata-action 61 | - name: Extract Docker metadata 62 | id: meta 63 | uses: docker/metadata-action@v5 64 | with: 65 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 66 | 67 | # Build and push Docker image with Buildx (don't push on PR) 68 | # https://github.com/docker/build-push-action 69 | - name: Build and push Docker image 70 | id: build-and-push 71 | uses: docker/build-push-action@v5 72 | with: 73 | context: . 74 | push: true 75 | tags: ${{ steps.meta.outputs.tags }} 76 | labels: ${{ steps.meta.outputs.labels }} 77 | platforms: linux/amd64, linux/arm64 78 | cache-from: type=gha 79 | cache-to: type=gha,mode=max 80 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # cyclonedx-cr (Crystal) 2 | 3 | A Crystal tool for generating [CycloneDX](https://cyclonedx.org/) Software Bill of Materials (SBOM) from Crystal shard projects. 4 | 5 | ## Features 6 | 7 | - 🔍 Generates CycloneDX SBOMs from Crystal `shard.yml` and `shard.lock` files 8 | - 📋 Supports multiple output formats: JSON, XML, CSV 9 | - 📊 Compatible with CycloneDX spec versions 1.4, 1.5, 1.6, and 1.7 10 | - 🔗 Automatically generates Package URLs (PURLs) for dependencies 11 | - 🐳 Docker support for containerized usage 12 | - ⚡ Fast and lightweight implementation in Crystal 13 | 14 | ## Installation 15 | 16 | ### Binary Releases 17 | 18 | Download the latest binary from the [releases page](https://github.com/hahwul/cyclonedx-cr/releases). 19 | 20 | ### Homebrew (macOS/Linux) 21 | 22 | ```bash 23 | brew install hahwul/cyclonedx-cr/cyclonedx-cr 24 | ``` 25 | 26 | ### Docker 27 | 28 | ```bash 29 | docker run --rm -v $(pwd):/workspace -w /workspace ghcr.io/hahwul/cyclonedx-cr:latest 30 | ``` 31 | 32 | ### From Source 33 | 34 | Requirements: [Crystal](https://crystal-lang.org/) 1.6.2+ 35 | 36 | ```bash 37 | git clone https://github.com/hahwul/cyclonedx-cr.git 38 | cd cyclonedx-cr 39 | shards install 40 | shards build --release 41 | ``` 42 | 43 | ## Usage 44 | 45 | ### Basic Usage 46 | 47 | Generate an SBOM from your Crystal project: 48 | 49 | ```bash 50 | cyclonedx-cr 51 | ``` 52 | 53 | This will read `shard.yml` and `shard.lock` from the current directory and output the SBOM to stdout in JSON format. 54 | 55 | ### Command Line Options 56 | 57 | ```bash 58 | Usage: cyclonedx-cr [arguments] 59 | -i FILE, --input=FILE shard.lock file path (default: shard.lock) 60 | -s FILE, --shard=FILE shard.yml file path (default: shard.yml) 61 | -o FILE, --output=FILE Output file path (default: stdout) 62 | --spec-version VERSION CycloneDX spec version (options: 1.4, 1.5, 1.6, 1.7, default: 1.6) 63 | --output-format FORMAT Output format (options: json, xml, csv, default: json) 64 | -h, --help Show this help 65 | ``` 66 | 67 | ### Examples 68 | 69 | #### Generate JSON SBOM to file 70 | ```bash 71 | cyclonedx-cr -o sbom.json 72 | ``` 73 | 74 | #### Generate XML SBOM with specific spec version 75 | ```bash 76 | cyclonedx-cr --output-format xml --spec-version 1.5 -o sbom.xml 77 | ``` 78 | 79 | #### Generate CSV SBOM from custom shard files 80 | ```bash 81 | cyclonedx-cr -s my-shard.yml -i my-shard.lock --output-format csv -o sbom.csv 82 | ``` 83 | 84 | #### Docker usage 85 | ```bash 86 | # Generate SBOM for current directory 87 | docker run --rm -v $(pwd):/workspace -w /workspace ghcr.io/hahwul/cyclonedx-cr:latest -o sbom.json 88 | 89 | # With custom shard files 90 | docker run --rm -v $(pwd):/workspace -w /workspace ghcr.io/hahwul/cyclonedx-cr:latest \ 91 | -s custom-shard.yml -i custom-shard.lock --output-format xml -o sbom.xml 92 | ``` 93 | 94 | #### GitHub Actions 95 | ```yaml 96 | name: Generate and Upload SBOM 97 | 98 | on: 99 | release: 100 | types: [created] 101 | 102 | jobs: 103 | generate-sbom: 104 | runs-on: ubuntu-latest 105 | permissions: 106 | contents: write 107 | steps: 108 | # Checkout the repository code 109 | - name: Checkout code 110 | uses: actions/checkout@v4 111 | 112 | # Generate SBOM using hahwul/cyclonedx-cr action 113 | - name: Generate SBOM 114 | uses: hahwul/cyclonedx-cr@v1.0.0 115 | with: 116 | shard_file: ./shard.yml # Explicitly map to shard_file 117 | lock_file: ./shard.lock # Explicitly map to lock_file 118 | output_file: ./sbom.xml # Map to output_file 119 | output_format: xml # Map to output_format 120 | spec_version: 1.6 # Optional, specify if needed 121 | 122 | # Upload SBOM to GitHub Release 123 | - name: Upload SBOM to Release 124 | uses: softprops/action-gh-release@v2 125 | with: 126 | files: ./sbom.xml 127 | token: ${{ secrets.GITHUB_TOKEN }} 128 | 129 | ``` 130 | 131 | ## Requirements 132 | 133 | Your Crystal project must have: 134 | - `shard.yml` file (project configuration) 135 | - `shard.lock` file (locked dependency versions) 136 | 137 | Generate the `shard.lock` file by running `shards install` in your Crystal project. 138 | 139 | ## Output Formats 140 | 141 | ### JSON (Default) 142 | Standard CycloneDX JSON format, suitable for most SBOM tools and platforms. 143 | 144 | ### XML 145 | CycloneDX XML format, compatible with tools that require XML input. 146 | 147 | ### CSV 148 | Simplified comma-separated values format for basic analysis and reporting. 149 | 150 | ## CycloneDX Specification Versions 151 | 152 | - **1.7**: Latest version with full feature support 153 | - **1.6** (default): Latest stable version with broad compatibility 154 | - **1.5**: Stable version with broad tool compatibility 155 | - **1.4**: Legacy version for compatibility with older tools 156 | 157 | ## Contributing 158 | 159 | 1. Fork the repository 160 | 2. Create your feature branch (`git checkout -b feature/amazing-feature`) 161 | 3. Commit your changes (`git commit -am 'Add some amazing feature'`) 162 | 4. Push to the branch (`git push origin feature/amazing-feature`) 163 | 5. Open a Pull Request 164 | 165 | ## License 166 | 167 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. 168 | 169 | ## Related Projects 170 | 171 | - [CycloneDX](https://cyclonedx.org/) - OWASP CycloneDX SBOM Standard 172 | - [Crystal](https://crystal-lang.org/) - The Crystal Programming Language 173 | - [Shards](https://github.com/crystal-lang/shards) - Crystal Package Manager 174 | -------------------------------------------------------------------------------- /src/app.cr: -------------------------------------------------------------------------------- 1 | require "option_parser" 2 | require "./cyclonedx/bom" 3 | require "./cyclonedx/component" 4 | require "./shard/shard_file" 5 | require "./shard/shard_lock_file" 6 | 7 | # Main application class for generating CycloneDX SBOMs from Crystal Shard files. 8 | # Handles command-line argument parsing, file reading, and SBOM generation. 9 | class App 10 | SUPPORTED_VERSIONS = ["1.4", "1.5", "1.6", "1.7"] 11 | SUPPORTED_FORMATS = ["json", "xml", "csv"] 12 | DEFAULT_VERSION = "1.6" 13 | DEFAULT_FORMAT = "json" 14 | 15 | # Runs the main application logic. 16 | def run 17 | options = parse_options 18 | return unless validate_options(options) 19 | return unless validate_input_files(options) 20 | 21 | bom = generate_bom(options) 22 | write_output(bom, options) 23 | end 24 | 25 | # Parses command-line options and returns a hash of options. 26 | private def parse_options 27 | options = { 28 | "shard_file" => "shard.yml", 29 | "shard_lock_file" => "shard.lock", 30 | "output_file" => "", 31 | "spec_version" => DEFAULT_VERSION, 32 | "output_format" => DEFAULT_FORMAT, 33 | } 34 | 35 | OptionParser.parse do |parser| 36 | parser.banner = "Usage: cyclonedx-cr [arguments]" 37 | parser.on("-i FILE", "--input=FILE", "shard.lock file path (default: shard.lock)") { |f| options["shard_lock_file"] = f } 38 | parser.on("-s FILE", "--shard=FILE", "shard.yml file path (default: shard.yml)") { |f| options["shard_file"] = f } 39 | parser.on("-o FILE", "--output=FILE", "Output file path (default: stdout)") { |f| options["output_file"] = f } 40 | parser.on("--spec-version VERSION", "CycloneDX spec version (options: #{SUPPORTED_VERSIONS.join(", ")}, default: #{DEFAULT_VERSION})") { |v| options["spec_version"] = v } 41 | parser.on("--output-format FORMAT", "Output format (options: #{SUPPORTED_FORMATS.join(", ")}, default: #{DEFAULT_FORMAT})") { |f| options["output_format"] = f.downcase } 42 | parser.on("-h", "--help", "Show this help") do 43 | puts parser 44 | exit 0 45 | end 46 | end 47 | 48 | options 49 | end 50 | 51 | # Validates the parsed options. 52 | private def validate_options(options) 53 | unless SUPPORTED_VERSIONS.includes?(options["spec_version"]) 54 | puts "Error: Unsupported spec version '#{options["spec_version"]}'. Supported versions are: #{SUPPORTED_VERSIONS.join(", ")}" 55 | return false 56 | end 57 | 58 | unless SUPPORTED_FORMATS.includes?(options["output_format"]) 59 | puts "Error: Unsupported output format '#{options["output_format"]}'. Supported formats are: #{SUPPORTED_FORMATS.join(", ")}" 60 | return false 61 | end 62 | 63 | true 64 | end 65 | 66 | # Validates that required input files exist. 67 | private def validate_input_files(options) 68 | unless File.exists?(options["shard_file"]) 69 | puts "Error: `#{options["shard_file"]}` not found." 70 | return false 71 | end 72 | 73 | unless File.exists?(options["shard_lock_file"]) 74 | puts "Error: `#{options["shard_lock_file"]}` not found." 75 | return false 76 | end 77 | 78 | true 79 | end 80 | 81 | # Generates the BOM from input files. 82 | private def generate_bom(options) 83 | main_component = parse_main_component(options["shard_file"]) 84 | dependencies = parse_dependencies(options["shard_lock_file"]) 85 | 86 | CycloneDX::BOM.new( 87 | spec_version: options["spec_version"], 88 | components: [main_component] + dependencies 89 | ) 90 | end 91 | 92 | # Writes the BOM output to file or stdout. 93 | private def write_output(bom, options) 94 | output_content = serialize_bom(bom, options["output_format"]) 95 | 96 | if options["output_file"].empty? 97 | puts output_content 98 | else 99 | File.write(options["output_file"], output_content) 100 | puts "SBOM successfully written to #{options["output_file"]} in #{options["output_format"].upcase} format." 101 | end 102 | end 103 | 104 | # Serializes the BOM to the specified format. 105 | private def serialize_bom(bom, format) 106 | case format 107 | when "json" 108 | bom.to_json 109 | when "xml" 110 | bom.to_xml 111 | when "csv" 112 | bom.to_csv 113 | else 114 | "" # Should not happen due to validation 115 | end 116 | end 117 | 118 | # Parses the main component information from `shard.yml`. 119 | # 120 | # @param file_path [String] The path to the `shard.yml` file. 121 | # @return [CycloneDX::Component] The main application component. 122 | private def parse_main_component(file_path : String) : CycloneDX::Component 123 | shard = ShardFile.from_yaml(File.read(file_path)) 124 | CycloneDX::Component.new( 125 | component_type: "application", 126 | name: shard.name, 127 | version: shard.version 128 | ) 129 | end 130 | 131 | # Parses dependency components from `shard.lock`. 132 | # 133 | # @param file_path [String] The path to the `shard.lock` file. 134 | # @return [Array(CycloneDX::Component)] An array of dependency components. 135 | private def parse_dependencies(file_path : String) : Array(CycloneDX::Component) 136 | lock_file = ShardLockFile.from_yaml(File.read(file_path)) 137 | components = [] of CycloneDX::Component 138 | lock_file.shards.each do |name, details| 139 | purl = generate_purl(name, details) 140 | components << CycloneDX::Component.new( 141 | name: name, 142 | version: details.version, 143 | purl: purl 144 | ) 145 | end 146 | components 147 | end 148 | 149 | # Generates a Package URL (PURL) for a given shard based on its details. 150 | # Currently supports GitHub-based PURLs. 151 | # 152 | # @param name [String] The name of the shard. 153 | # @param details [ShardLockEntry] The details of the shard from `shard.lock`. 154 | # @return [String?] The generated PURL, or `nil` if one cannot be determined. 155 | private def generate_purl(name : String, details : ShardLockEntry) : String? 156 | case 157 | when github_repo = details.github 158 | "pkg:github/#{github_repo}@#{details.version}" 159 | when git_url = details.git 160 | if github_repo = parse_github_repo_from_git_url(git_url) 161 | "pkg:github/#{github_repo}@#{details.version}" 162 | else 163 | nil 164 | end 165 | else 166 | nil 167 | end 168 | end 169 | 170 | # Extracts the GitHub repository path from a Git URL. 171 | # 172 | # @param git_url [String] The Git URL. 173 | # @return [String?] The GitHub repository path (e.g., "owner/repo"), or `nil` if not a GitHub URL. 174 | private def parse_github_repo_from_git_url(git_url : String) : String? 175 | if git_url.includes?("github.com") 176 | git_url.sub(/.*github.com\//, "").sub(/\.git$/, "") 177 | else 178 | nil 179 | end 180 | end 181 | end 182 | -------------------------------------------------------------------------------- /.github/copilot-instructions.md: -------------------------------------------------------------------------------- 1 | # cyclonedx-cr (Crystal) 2 | 3 | cyclonedx-cr is a command-line tool written in Crystal that generates CycloneDX Software Bill of Materials (SBOM) from Crystal shard files (shard.yml and shard.lock). It outputs SBOM data in JSON, XML, or CSV formats with support for CycloneDX spec versions 1.4, 1.5, and 1.6. 4 | 5 | Always reference these instructions first and fallback to search or bash commands only when you encounter unexpected information that does not match the info here. 6 | 7 | ## Working Effectively 8 | 9 | - Install Crystal and dependencies: 10 | - `sudo apt-get update && sudo apt-get install -y crystal shards` 11 | - Crystal version 1.11.2+ is tested and working 12 | - Shards is Crystal's package manager and build tool 13 | 14 | - Bootstrap and build the repository: 15 | - `shards install` -- takes <1 second. Dependencies are minimal. 16 | - `shards build` -- takes 4 seconds. NEVER CANCEL. Set timeout to 60+ seconds for safety. 17 | 18 | - Run tests: 19 | - `crystal spec` -- takes 4 seconds. NEVER CANCEL. Set timeout to 30+ seconds for safety. 20 | 21 | - Run the application: 22 | - ALWAYS run `shards build` first to create the `bin/cyclonedx-cr` binary 23 | - Basic usage: `./bin/cyclonedx-cr` (processes shard.yml and shard.lock in current directory) 24 | - View help: `./bin/cyclonedx-cr --help` 25 | - Specify files: `./bin/cyclonedx-cr -s shard.yml -i shard.lock` 26 | - Change output format: `./bin/cyclonedx-cr --output-format xml` (options: json, xml, csv) 27 | - Specify CycloneDX version: `./bin/cyclonedx-cr --spec-version 1.5` (options: 1.4, 1.5, 1.6) 28 | 29 | ## Validation 30 | 31 | - Always manually validate any changes by running the application with different output formats: 32 | - `./bin/cyclonedx-cr --output-format json` 33 | - `./bin/cyclonedx-cr --output-format xml` 34 | - `./bin/cyclonedx-cr --output-format csv` 35 | 36 | - ALWAYS run through at least one complete end-to-end scenario after making changes: 37 | - Build the project: `shards build` 38 | - Test basic functionality: `./bin/cyclonedx-cr --help` 39 | - Generate SBOM: `./bin/cyclonedx-cr --output-format json -o test-output.json` 40 | - Verify output file was created and contains valid SBOM data 41 | 42 | - The application requires shard.yml and shard.lock files to be present in the working directory 43 | - Use the project's own shard.yml and shard.lock for testing (they always exist) 44 | 45 | ## Common Tasks 46 | 47 | ### Development Build Cycle 48 | - `shards install` (if dependencies changed) 49 | - `shards build` 50 | - `crystal spec` (run tests) 51 | - `./bin/cyclonedx-cr --help` (verify binary works) 52 | 53 | ### Testing Different Output Formats 54 | - JSON (default): `./bin/cyclonedx-cr` 55 | - XML: `./bin/cyclonedx-cr --output-format xml` 56 | - CSV: `./bin/cyclonedx-cr --output-format csv` 57 | 58 | ### Docker Build (Has Known Issues) 59 | - `docker build -t cyclonedx-cr .` -- FAILS due to missing liblzma-dev dependency in static linking 60 | - The Dockerfile attempts to create a static binary but lacks the liblzma-dev package 61 | - Takes ~30 seconds before failing. NEVER CANCEL. Set timeout to 120+ seconds. 62 | - For development, use native Crystal build instead of Docker 63 | 64 | ## Project Structure 65 | 66 | ### Key Directories and Files 67 | ``` 68 | /home/runner/work/cyclonedx-cr/cyclonedx-cr/ 69 | ├── shard.yml # Project metadata 70 | ├── shard.lock # Locked dependencies 71 | ├── src/ 72 | │ ├── main.cr # Entry point 73 | │ ├── app.cr # Main application logic and CLI parsing 74 | │ ├── cyclonedx/ 75 | │ │ ├── bom.cr # CycloneDX BOM class with JSON/XML/CSV serialization 76 | │ │ └── component.cr # CycloneDX Component class 77 | │ └── shard/ 78 | │ ├── shard_file.cr # Shard.yml parser 79 | │ └── shard_lock_file.cr # Shard.lock parser 80 | ├── spec/ 81 | │ └── main_spec.cr # Basic test file 82 | ├── bin/ # Generated binary location (after shards build) 83 | ├── .github/workflows/ # CI/CD configuration 84 | └── Dockerfile # Docker build (has static linking issues) 85 | ``` 86 | 87 | ### Frequently Modified Files 88 | - When changing CLI options: edit `src/app.cr` (OptionParser configuration) 89 | - When changing SBOM output: edit `src/cyclonedx/bom.cr` or `src/cyclonedx/component.cr` 90 | - When changing file parsing: edit `src/shard/shard_file.cr` or `src/shard/shard_lock_file.cr` 91 | - Always update tests in `spec/main_spec.cr` when adding new functionality 92 | 93 | ## CI/CD Integration 94 | 95 | The project uses GitHub Actions with the following jobs: 96 | - **build-crystal**: Tests multiple Crystal versions (1.14.1, 1.15.0, 1.16.0, 1.17.0) 97 | - **build-docker**: Builds Docker images for linux/amd64 and linux/arm64 98 | - **lint**: Uses Crystal Ameba linter (ameba is not installed locally by default) 99 | - **tests**: Runs `crystal spec` in Docker container 100 | 101 | Always ensure your changes work with: 102 | - `shards build` (builds successfully) 103 | - `crystal spec` (tests pass) 104 | - Manual validation with different output formats 105 | 106 | ## Command Reference 107 | 108 | ### Successful Commands (Validated Working) 109 | ```bash 110 | # Installation 111 | sudo apt-get update && sudo apt-get install -y crystal shards 112 | 113 | # Build and test cycle 114 | shards install # <1 second 115 | shards build # 4 seconds 116 | crystal spec # 4 seconds 117 | 118 | # Application usage 119 | ./bin/cyclonedx-cr --help 120 | ./bin/cyclonedx-cr --output-format json 121 | ./bin/cyclonedx-cr --output-format xml 122 | ./bin/cyclonedx-cr --output-format csv 123 | ./bin/cyclonedx-cr --spec-version 1.5 --output-format json -o output.json 124 | ``` 125 | 126 | ### Known Failing Commands 127 | ```bash 128 | # Docker build fails due to missing liblzma-dev for static linking 129 | docker build -t cyclonedx-cr . # FAILS after ~30 seconds 130 | 131 | # Ameba linter not available locally (works in CI only) 132 | ameba # Command not found locally 133 | ``` 134 | 135 | ### Expected Output Samples 136 | - Help command shows all CLI options including input/output files, formats, and spec versions 137 | - JSON output: `{"bomFormat":"CycloneDX","specVersion":"1.6","version":1,"components":[...]}` 138 | - XML output: `` 139 | - CSV output: Headers `Name,Version,PURL,Type` followed by component data 140 | 141 | ## Troubleshooting 142 | 143 | ### Build Issues 144 | - If `crystal: command not found`: Install with `sudo apt-get install -y crystal` 145 | - If `shards: command not found`: Install with `sudo apt-get install -y shards` 146 | - If build fails with missing libraries: Crystal requires development headers for linked libraries 147 | 148 | ### Runtime Issues 149 | - If "shard.yml not found": The tool requires shard.yml and shard.lock files in the working directory 150 | - If "Invalid spec version": Only 1.4, 1.5, and 1.6 are supported 151 | - If "Invalid output format": Only json, xml, and csv are supported 152 | 153 | ### Docker Issues 154 | - Static linking fails due to missing liblzma-dev in the 84codes/crystal container 155 | - For development, use native Crystal build instead of Docker build 156 | - Container builds work for CI/CD but not for static binary generation --------------------------------------------------------------------------------