├── .devcontainer ├── Dockerfile └── devcontainer.json ├── .dockerignore ├── .flake8 ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── actions │ ├── e2e-testing │ │ └── action.yml │ ├── publish-image │ │ └── action.yml │ ├── setup-poetry │ │ └── action.yml │ └── test-integration │ │ └── action.yml ├── dependabot.yml ├── pull_request_template.md └── workflows │ ├── ci.yml │ ├── codecov.yml │ ├── conventional-title.yml │ ├── mkdocs.yml │ ├── publish.yml │ ├── release.yml │ ├── scorecard.yml │ └── validate-adrs.yml ├── .gitignore ├── .mega-linter.yml ├── .pre-commit-config.yaml ├── .release-please-manifest.json ├── CHANGELOG.md ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── TEMPLATES └── github │ ├── trestlebot-create-component-definition.yml │ └── trestlebot-rules-transform.yml ├── TROUBLESHOOTING.md ├── actions ├── README.md ├── autosync │ ├── README.md │ ├── action.yml │ └── auto-sync-entrypoint.sh ├── common.sh ├── create-cd │ ├── README.md │ ├── action.yml │ └── create-cd-entrypoint.sh ├── rules-transform │ ├── README.md │ ├── action.yml │ └── rules-transform-entrypoint.sh └── sync-upstreams │ ├── README.md │ ├── action.yml │ └── sync-upstreams-entrypoint.sh ├── commitlint.config.js ├── docs ├── architecture │ ├── .trestle │ │ ├── .keep │ │ └── author │ │ │ └── decisions │ │ │ └── 0.0.1 │ │ │ └── template.md │ ├── decisions │ │ ├── implement-cli-framework_001.md │ │ └── record-architecture-decisions_000.md │ └── diagrams │ │ └── c4.md ├── contributing.md ├── index.md ├── troubleshooting.md ├── tutorials │ ├── authoring.md │ ├── github.md │ ├── sync-cac-content.md │ └── sync-oscal-content.md └── workflows │ ├── assemble_diagrams.md │ └── create_diagrams.md ├── mkdocs.yml ├── poetry.lock ├── pyproject.toml ├── release-please-config.json ├── scripts ├── get-github-release.py ├── get_mappings_profile_control_levels.py ├── get_product_controls.py ├── get_rule_impacted_files.py └── update_action_readmes.py ├── sonar-project.properties ├── tests ├── __init__.py ├── conftest.py ├── data │ ├── content_dir │ │ ├── controls │ │ │ ├── 1234-example.yml │ │ │ ├── abcd-levels.yml │ │ │ └── simplified_nist_ocp4.yml │ │ ├── linux_os │ │ │ └── guide │ │ │ │ ├── benchmark.yml │ │ │ │ └── test │ │ │ │ ├── configure_crypto_policy │ │ │ │ └── rule.yml │ │ │ │ ├── file_groupownership_sshd_private_key │ │ │ │ └── rule.yml │ │ │ │ ├── group.yml │ │ │ │ ├── sshd_set_keepalive │ │ │ │ └── rule.yml │ │ │ │ ├── var_password_pam_minlen.var │ │ │ │ ├── var_sshd_set_keepalive.var │ │ │ │ └── var_system_crypto_policy.var │ │ ├── products │ │ │ └── rhel8 │ │ │ │ ├── product.yml │ │ │ │ └── profiles │ │ │ │ └── example.profile │ │ └── shared │ │ │ └── macros │ │ │ └── test-macros.jinja │ ├── json │ │ ├── github_example_pull_response.json │ │ ├── github_example_repo_response.json │ │ ├── invalid_comp.json │ │ ├── invalid_test_ssp_index.json │ │ ├── rhel8-abcd-levels-high.json │ │ ├── rhel8-abcd-levels-low.json │ │ ├── rhel8-abcd-levels-medium.json │ │ ├── rhel8.json │ │ ├── simplified_filter_profile.json │ │ ├── simplified_nist_catalog.json │ │ ├── simplified_nist_profile.json │ │ ├── test_comp.json │ │ ├── test_comp_2.json │ │ └── test_ssp_index.json │ └── yaml │ │ ├── extra_yaml_header.yaml │ │ ├── test_complete_rule.yaml │ │ ├── test_complete_rule_multiple_controls.yaml │ │ ├── test_complete_rule_no_params.yaml │ │ ├── test_incomplete_rule.yaml │ │ ├── test_invalid_rule.yaml │ │ └── test_rule_invalid_params.yaml ├── e2e │ ├── Dockerfile │ ├── README.md │ ├── conftest.py │ ├── e2e_testutils.py │ ├── mappings │ │ └── mapping.json │ ├── play-kube.yml │ ├── test_e2e_compdef.py │ └── test_e2e_ssp.py ├── integration │ ├── README.md │ ├── conftest.py │ └── test_int.py ├── integration_data │ ├── c2p-openscap-manifest.json │ ├── sample-catalog.json │ ├── sample-component-definition.json │ └── sample-profile.json ├── testutils.py ├── trestlebot │ ├── __init__.py │ ├── cli │ │ ├── test_autosync_cmd.py │ │ ├── test_config.py │ │ ├── test_create_cmd.py │ │ ├── test_init_cmd.py │ │ ├── test_rules_transform_cmd.py │ │ ├── test_sync_cac_catalog_task.py │ │ ├── test_sync_cac_content_cmd.py │ │ ├── test_sync_oscal_content_cmd.py │ │ └── test_sync_upstreams_cmd.py │ ├── tasks │ │ ├── __init__.py │ │ ├── authored │ │ │ ├── __init__.py │ │ │ ├── test_compdef.py │ │ │ ├── test_profile.py │ │ │ ├── test_ssp.py │ │ │ └── test_types.py │ │ ├── test_assemble_task.py │ │ ├── test_base_task.py │ │ ├── test_regenerate_task.py │ │ ├── test_rule_transform_task.py │ │ ├── test_sync_cac_profile_task.py │ │ └── test_sync_upstream_task.py │ ├── test_bot.py │ ├── test_github.py │ ├── test_gitlab.py │ ├── test_reporter.py │ └── transformers │ │ ├── __init__.py │ │ ├── test_csv_transformer.py │ │ └── test_yaml_transformer.py └── workflows │ ├── __init__.py │ └── test_rules_transform_workflow.py └── trestlebot ├── __init__.py ├── __main__.py ├── bot.py ├── cli ├── commands │ ├── autosync.py │ ├── create.py │ ├── init.py │ ├── rules_transform.py │ ├── sync_cac_content.py │ ├── sync_oscal_content.py │ ├── sync_upstreams.py │ └── version.py ├── config.py ├── log.py ├── options │ ├── common.py │ └── create.py ├── root.py └── utils.py ├── const.py ├── github.py ├── gitlab.py ├── provider.py ├── provider_factory.py ├── py.typed ├── reporter.py ├── tasks ├── __init__.py ├── assemble_task.py ├── authored │ ├── __init__.py │ ├── base_authored.py │ ├── catalog.py │ ├── compdef.py │ ├── profile.py │ ├── ssp.py │ └── types.py ├── base_task.py ├── regenerate_task.py ├── rule_transform_task.py ├── sync_cac_catalog_task.py ├── sync_cac_content_profile_task.py ├── sync_cac_content_task.py ├── sync_oscal_content_catalog_task.py ├── sync_oscal_content_cd_task.py ├── sync_oscal_content_profile_task.py └── sync_upstreams_task.py ├── transformers ├── __init__.py ├── base_transformer.py ├── cac_transformer.py ├── csv_transformer.py ├── trestle_rule.py └── yaml_transformer.py └── utils.py /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM quay.io/fedora/fedora:37 2 | 3 | ARG POETRY_VERSION=1.7.1 4 | 5 | RUN dnf -y update && \ 6 | yum -y reinstall shadow-utils && \ 7 | yum install -y git \ 8 | python3 \ 9 | python3-pip \ 10 | python3-devel \ 11 | gcc-c++ && \ 12 | rm -rf /var/cache /var/log/dnf* /var/log/yum.* 13 | 14 | RUN useradd -u 1000 trestlebot 15 | 16 | ENV HOME=/home/trestlebot 17 | ENV PYSETUP_PATH="$HOME/trestle-bot" \ 18 | VENV_PATH="$HOME/trestle-bot/.venv" 19 | 20 | RUN mkdir -p "$PYSETUP_PATH" 21 | 22 | # Installing poetry and pipx. 23 | RUN python3 -m pip install --no-cache-dir --upgrade pip \ 24 | && python3 -m pip install --no-cache-dir pipx \ 25 | && python3 -m pipx install poetry=="$POETRY_VERSION" 26 | 27 | # set permissions 28 | RUN chown trestlebot:trestlebot -R /home/trestlebot 29 | 30 | USER trestlebot -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "TrestleBot Dev Environment ", 3 | "build": { 4 | "dockerfile": "Dockerfile", 5 | "context": ".." 6 | }, 7 | "customizations": { 8 | "vscode": { 9 | "settings": { 10 | "python.linting.enabled": true, 11 | "python.defaultInterpreterPath": "/home/trestlebot/trestle-bot/.venv/bin/python3" 12 | }, 13 | "extensions": [ 14 | "ms-python.python", 15 | "ms-python.mypy-type-checker", 16 | "ms-python.flake8", 17 | "ms-python.isort", 18 | "ms-azuretools.vscode-docker" 19 | ] 20 | } 21 | }, 22 | "updateRemoteUserUID": true, 23 | "containerUser": "trestlebot", 24 | "containerEnv": { 25 | "HOME": "/home/trestlebot", 26 | "PIP_NO_CACHE_DIR": "off", 27 | "PIP_DISABLE_PIP_VERSION_CHECK": "on", 28 | "PIP_DEFAULT_TIMEOUT": "100", 29 | "POETRY_VIRTUALENVS_IN_PROJECT": "true" 30 | }, 31 | "updateContentCommand": "python3 -m venv $VENV_PATH && source $VENV_PATH/bin/activate && poetry install --no-root --no-interaction", 32 | "postCreateCommand": "echo \"source $VENV_PATH/bin/activate\" >> $HOME/.bashrc && make pre-commit", 33 | "workspaceMount": "source=${localWorkspaceFolder},target=/workspace,type=bind", 34 | "workspaceFolder": "/workspace" 35 | } -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Configuration files 2 | .github/** 3 | .mega-linter.yml 4 | .pre-commit-config.yaml 5 | .flake8 6 | sonar-project.properties 7 | 8 | # Docs 9 | docs/ 10 | CONTRIBUTING.md 11 | TROUBLESHOOTING.md 12 | 13 | # Local files 14 | .git 15 | .cache 16 | .env 17 | **/.venv/** 18 | .coverage 19 | .coverage.* -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length=105 3 | exclude=.venv* -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Additional context** 27 | Add any other context about the problem here. 28 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/actions/e2e-testing/action.yml: -------------------------------------------------------------------------------- 1 | name: "e2e-testing" 2 | description: "Composite action for trestle-bot end-to-end tests." 3 | 4 | inputs: 5 | build: 6 | description: "Whether to build the image before testing." 7 | required: false 8 | default: "true" 9 | image: 10 | description: | 11 | "Name of the trestlebot image you want to test if pre-existing. Required if build is false." 12 | required: false 13 | 14 | runs: 15 | using: "composite" 16 | steps: 17 | - name: Set up poetry and install 18 | uses: ./.github/actions/setup-poetry 19 | with: 20 | python-version: "3.9" 21 | 22 | # https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-an-intermediate-environment-variable 23 | - name: Pull the image 24 | if: ${{ inputs.build == 'false' }} 25 | run: | 26 | podman pull "${IMAGE}" 27 | echo "TRESTLEBOT_IMAGE=$(sed -e 's/^docker-daemon://' <<<${IMAGE})" >> "$GITHUB_ENV" 28 | env: 29 | IMAGE: ${{ inputs.image }} 30 | shell: bash 31 | 32 | - name: Run tests 33 | run: make test-e2e 34 | shell: bash 35 | -------------------------------------------------------------------------------- /.github/actions/publish-image/action.yml: -------------------------------------------------------------------------------- 1 | name: "publish-image" 2 | description: "Composite action to publish trestle-bot images." 3 | 4 | inputs: 5 | image: 6 | required: true 7 | description: The image repository location in the format of registry/name/app 8 | release_version: 9 | required: true 10 | description: The version to build type semver tags from 11 | no_cache: 12 | description: Skip using cache when building the image. 13 | required: false 14 | default: "false" 15 | skip_tests: 16 | description: Skip pre-push testing 17 | required: false 18 | default: "false" 19 | outputs: 20 | image_sha: 21 | value: ${{ inputs.image }}@${{ steps.build-and-push.outputs.digest }} 22 | description: The published image with digest 23 | 24 | runs: 25 | using: "composite" 26 | steps: 27 | - name: Set up QEMU 28 | uses: docker/setup-qemu-action@53851d14592bedcffcf25ea515637cff71ef929a # pin@v3 29 | 30 | - name: Set up Docker Buildx 31 | uses: docker/setup-buildx-action@d70bba72b1f3fd22344832f00baa16ece964efeb # pin@v3 32 | 33 | # Tags are defined here based on workflow triggers 34 | - name: Define metadata 35 | id: meta 36 | uses: docker/metadata-action@8e5442c4ef9f78752691e2d8f8d19755c6f78e81 # pin@v5 37 | with: 38 | images: ${{ inputs.image }} 39 | tags: | 40 | type=semver,pattern=v{{major}},enable=${{ !startsWith(inputs.release_version, 'v0.') }},value=${{ inputs.release_version }} 41 | type=semver,pattern=v{{major}}.{{minor}},value=${{ inputs.release_version }} 42 | type=semver,pattern=v{{version}},value=${{ inputs.release_version }} 43 | type=raw,value=${{ inputs.release_version }}-{{branch}}-{{sha}},enable=${{ github.event_name == 'workflow_dispatch' }} 44 | type=schedule,pattern={{date 'YYYYMMDD'}},prefix=${{ inputs.release_version }}. 45 | flavor: | 46 | latest=false 47 | 48 | - name: Build and export to Docker 49 | uses: docker/build-push-action@5176d81f87c23d6fc96624dfdbcd9f3830bbe445 # pin@v5 50 | id: build-and-export 51 | with: 52 | context: "." 53 | load: true 54 | no-cache: ${{ inputs.no_cache == 'true' }} 55 | cache-from: type=gha 56 | cache-to: type=gha,mode=max 57 | tags: ${{ steps.meta.outputs.tags }} 58 | labels: ${{ steps.meta.outputs.labels }} 59 | 60 | - name: Pre-push Image Scan 61 | uses: aquasecurity/trivy-action@6e7b7d1fd3e4fef0c5fa8cce1229c54b2c9bd0d8 # pin@0.24.0 62 | with: 63 | image-ref: ${{ inputs.image }}:${{ steps.meta.outputs.version }} 64 | exit-code: 1 65 | skip-files: "**/.venv/lib/**/METADATA" 66 | scanners: secret 67 | severity: HIGH,CRITICAL,MEDIUM 68 | 69 | - name: Pre-push testing 70 | if: ${{ inputs.skip_tests == 'false' }} 71 | uses: ./.github/actions/e2e-testing 72 | with: 73 | image: "docker-daemon:${{ inputs.image }}:${{ steps.meta.outputs.version }}" 74 | build: false 75 | 76 | # Does not rebuild. Uses internal cache from previous step. 77 | - name: Build and Push 78 | uses: docker/build-push-action@5176d81f87c23d6fc96624dfdbcd9f3830bbe445 # pin@v5 79 | id: build-and-push 80 | with: 81 | context: "." 82 | push: true 83 | tags: ${{ steps.meta.outputs.tags }} 84 | labels: ${{ steps.meta.outputs.labels }} 85 | -------------------------------------------------------------------------------- /.github/actions/setup-poetry/action.yml: -------------------------------------------------------------------------------- 1 | name: "setup-poetry" 2 | description: "Composite action to setup poetry." 3 | 4 | inputs: 5 | poetry-version: 6 | required: false 7 | description: "The poetry version to use" 8 | default: "1.7.1" 9 | python-version: 10 | required: false 11 | description: "The python version to use" 12 | default: "3.11" 13 | 14 | runs: 15 | using: "composite" 16 | steps: 17 | - name: Install Poetry 18 | run: pipx install poetry==${{ inputs.poetry-version }} 19 | shell: bash 20 | 21 | - name: Set up Python 22 | uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55 # pin@v5 23 | with: 24 | python-version: ${{ inputs.python-version }} 25 | cache: poetry 26 | cache-dependency-path: poetry.lock 27 | 28 | - name: Set Poetry environment 29 | run: poetry env use ${{ inputs.python-version }} 30 | shell: bash 31 | 32 | - name: Install dependencies 33 | run: poetry install --with tests,dev --no-interaction --no-root 34 | shell: bash 35 | -------------------------------------------------------------------------------- /.github/actions/test-integration/action.yml: -------------------------------------------------------------------------------- 1 | name: "integration test" 2 | description: "Composite action for trestle-bot end-to-end tests." 3 | 4 | inputs: 5 | build: 6 | description: "Whether to build the image before testing." 7 | required: false 8 | default: "true" 9 | image: 10 | description: | 11 | "Name of the trestlebot image you want to test if pre-existing. Required if build is false." 12 | required: false 13 | 14 | runs: 15 | using: "composite" 16 | steps: 17 | - name: Set up poetry and install 18 | uses: ./.github/actions/setup-poetry 19 | with: 20 | python-version: "3.9" # least common denominator 21 | 22 | - name: Run tests 23 | run: make test-integration 24 | shell: bash 25 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | 4 | - package-ecosystem: "github-actions" 5 | directories: 6 | - '/' 7 | - '/.github/actions/e2e-testing' 8 | - '/.github/actions/publish-image' 9 | - '/.github/actions/setup-poetry' 10 | schedule: 11 | interval: "weekly" 12 | commit-message: 13 | prefix: build 14 | include: scope 15 | 16 | - package-ecosystem: "pip" 17 | directory: "/" 18 | schedule: 19 | interval: "weekly" 20 | allow: 21 | - dependency-type: "all" 22 | commit-message: 23 | prefix: build 24 | include: scope 25 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Summary 2 | _Please include a summary of the changes and the context of this PR._ 3 | 4 | ## Related Issues 5 | _Inform any issues relevant to this PR. For example:_ 6 | 7 | - _Closes #ISSUE_NUMBER_ 8 | 9 | ## Review Hints 10 | 11 | - _Review hints here. Replace this text. Don't use the italics format!_ 12 | 13 | - _Use this optional section to give any relevant information that could help the reviewer to more quickly and assertively understand and test the changes._ 14 | 15 | - _Good examples are useful commands, if it is better to review all commits together or in a suggested sequence, any relevant discussion in other PRs or issues, etc._ 16 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: CI 3 | 4 | on: 5 | push: 6 | branches: 7 | - main 8 | pull_request: 9 | branches: 10 | - main 11 | 12 | concurrency: 13 | group: ${{ github.ref }}-${{ github.workflow }}-ci 14 | cancel-in-progress: true 15 | 16 | jobs: 17 | megalinter: 18 | name: Run linters 19 | runs-on: ubuntu-latest 20 | permissions: 21 | issues: write 22 | steps: 23 | - name: Checkout Code 24 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # pin@v4 25 | 26 | - name: MegaLinter 27 | id: ml 28 | uses: oxsecurity/megalinter@146333030da68e2e58c6ff826633824fabe01eaf # pin@v7 29 | env: 30 | VALIDATE_ALL_CODEBASE: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} 31 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 32 | 33 | project_checks: 34 | name: Run project checks 35 | runs-on: ubuntu-latest 36 | steps: 37 | - name: Checkout Code 38 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # pin@v4 39 | 40 | - name: Set up poetry and install 41 | uses: ./.github/actions/setup-poetry 42 | 43 | - name: Pre-commit install 44 | run: make pre-commit 45 | 46 | - name: Run linting checks 47 | run: make lint 48 | 49 | - name: Run security checks 50 | run: make security-check 51 | 52 | - name: Check dependencies 53 | run: make dep-cve-check 54 | 55 | test: 56 | runs-on: ubuntu-latest 57 | strategy: 58 | matrix: 59 | python-version: [ '3.8', '3.9', '3.10', '3.11' ] 60 | fail-fast: false 61 | steps: 62 | - name: Check out 63 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # pin@v4 64 | 65 | - name: Set up poetry and install 66 | uses: ./.github/actions/setup-poetry 67 | with: 68 | python-version: ${{ matrix.python-version }} 69 | 70 | - name: Run tests 71 | run: make test 72 | 73 | e2e-test: 74 | runs-on: 'ubuntu-24.04' 75 | permissions: 76 | contents: read 77 | steps: 78 | - name: Check out 79 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # pin@v4 80 | - uses: ./.github/actions/e2e-testing 81 | 82 | test-integration: 83 | runs-on: 'ubuntu-24.04' 84 | permissions: 85 | contents: read 86 | steps: 87 | - name: Check out 88 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # pin@v4 89 | - uses: ./.github/actions/test-integration 90 | -------------------------------------------------------------------------------- /.github/workflows/codecov.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Code Coverage Check 3 | 4 | on: 5 | push: 6 | branches: 7 | - main 8 | pull_request: 9 | paths: 10 | - '**.py' 11 | 12 | jobs: 13 | test: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Check out 17 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # pin@v4 18 | 19 | - name: Set up poetry and install 20 | uses: ./.github/actions/setup-poetry 21 | with: 22 | python-version: "3.9" 23 | 24 | - name: Run tests 25 | run: make test-code-cov 26 | 27 | - name: Upload artifact 28 | uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # pin@v4 29 | with: 30 | name: coverage 31 | path: coverage.xml 32 | sonarcloud: 33 | if: ${{ github.event.pull_request.base.repo.url == github.event.pull_request.head.repo.url }} 34 | name: SonarCloud 35 | runs-on: ubuntu-latest 36 | needs: test 37 | steps: 38 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # pin@v4 39 | with: 40 | fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis 41 | - name: Get coverage 42 | uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # pin@v4 43 | with: 44 | name: coverage 45 | - name: SonarCloud Scan 46 | uses: SonarSource/sonarqube-scan-action@f932b663acf3c4b8b27c673927b5ac744638b17b # pin@f932b663acf3c4b8b27c673927b5ac744638b17b 47 | env: 48 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any 49 | SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} 50 | with: 51 | args: > 52 | -Dsonar.python.coverage.reportPaths=coverage.xml -Dsonar.tests=tests/ -Dsonar.sources=trestlebot/ -Dsonar.python.version=3.10 -Dsonar.projectKey=rh-psce_trestle-bot -Dsonar.organization=rh-psce 53 | - name: SonarQube Quality Gate check 54 | uses: sonarsource/sonarqube-quality-gate-action@df914238f99aa5d81f4490aeea80f205c7ed9600 # pin@f9fe214a5be5769c40619de2fff2726c36d2d5eb 55 | # Force to fail step after specific time 56 | timeout-minutes: 5 57 | env: 58 | SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} 59 | -------------------------------------------------------------------------------- /.github/workflows/conventional-title.yml: -------------------------------------------------------------------------------- 1 | name: Lint PR title 2 | 3 | on: 4 | pull_request: 5 | types: 6 | - opened 7 | - edited 8 | - synchronize 9 | branches: 10 | - 'main' 11 | 12 | jobs: 13 | lint: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout code 17 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # pin@v4 18 | 19 | - name: Install dependencies 20 | run: npm install @commitlint/cli @commitlint/config-conventional 21 | 22 | - name: Validate PR title 23 | env: 24 | PR_TITLE: ${{ github.event.pull_request.title }} 25 | run: echo "$PR_TITLE" | npx commitlint --config commitlint.config.js -------------------------------------------------------------------------------- /.github/workflows/mkdocs.yml: -------------------------------------------------------------------------------- 1 | name: publish-docs 2 | on: 3 | push: 4 | branches: 5 | - main 6 | paths: 7 | - 'mkdocs.yml' 8 | - 'docs/**' 9 | 10 | permissions: 11 | contents: write 12 | 13 | jobs: 14 | gh-deploy: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # pin@v4 18 | - uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55 # pin@v5 19 | with: 20 | python-version: '3.11' 21 | - uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # pin@v4 22 | with: 23 | key: ${{ github.ref }} 24 | path: .cache 25 | - run: pip install mkdocs-material markdown-include 26 | - run: mkdocs gh-deploy --force 27 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release PR 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | release-please: 8 | runs-on: ubuntu-latest 9 | permissions: 10 | contents: write 11 | pull-requests: write 12 | steps: 13 | - uses: googleapis/release-please-action@a02a34c4d625f9be7cb89156071d8567266a2445 # 4.2.0 14 | -------------------------------------------------------------------------------- /.github/workflows/scorecard.yml: -------------------------------------------------------------------------------- 1 | name: Scorecard analysis workflow 2 | on: 3 | push: 4 | # Only the default branch is supported. 5 | branches: 6 | - main 7 | schedule: 8 | # Weekly on Saturdays. 9 | - cron: '30 1 * * 6' 10 | 11 | permissions: read-all 12 | 13 | jobs: 14 | analysis: 15 | if: github.repository == 'RedHatProductSecurity/trestle-bot' 16 | name: Scorecard analysis 17 | runs-on: ubuntu-latest 18 | permissions: 19 | # Needed for Code scanning upload 20 | security-events: write 21 | # Needed for GitHub OIDC token if publish_results is true 22 | id-token: write 23 | 24 | steps: 25 | - name: Checkout code 26 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # pin@v4 27 | with: 28 | persist-credentials: false 29 | 30 | - name: Run analysis 31 | uses: ossf/scorecard-action@f49aabe0b5af0936a0987cfb85d86b75731b0186 # v2.4.1 32 | with: 33 | results_file: results.sarif 34 | results_format: sarif 35 | publish_results: true 36 | 37 | - name: Upload artifact 38 | uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # pin@v4 39 | with: 40 | name: SARIF file 41 | path: results.sarif 42 | retention-days: 5 43 | 44 | # Upload the results to GitHub's code scanning dashboard (optional). 45 | # Commenting out will disable upload of results to your repo's Code Scanning dashboard 46 | - name: Upload to code-scanning 47 | uses: github/codeql-action/upload-sarif@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18 48 | with: 49 | sarif_file: results.sarif -------------------------------------------------------------------------------- /.github/workflows/validate-adrs.yml: -------------------------------------------------------------------------------- 1 | 2 | name: Validate ADRs 3 | 4 | on: 5 | pull_request: 6 | branches: 7 | - main 8 | paths: 9 | - 'docs/architecture/decisions' 10 | 11 | jobs: 12 | validate: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout Code 16 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # pin@v4 17 | 18 | - name: Set up poetry and install 19 | uses: ./.github/actions/setup-poetry 20 | with: 21 | python-version: '3.11' 22 | 23 | - name: Run validate adrs 24 | run: trestle author docs validate -tn decisions -hv -tr docs/architecture 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | *reports/ 54 | 55 | # Translations 56 | *.mo 57 | *.pot 58 | 59 | # Django stuff: 60 | *.log 61 | local_settings.py 62 | db.sqlite3 63 | db.sqlite3-journal 64 | 65 | # Flask stuff: 66 | instance/ 67 | .webassets-cache 68 | 69 | # Scrapy stuff: 70 | .scrapy 71 | 72 | # Sphinx documentation 73 | docs/_build/ 74 | 75 | # PyBuilder 76 | .pybuilder/ 77 | target/ 78 | 79 | # Jupyter Notebook 80 | .ipynb_checkpoints 81 | 82 | # IPython 83 | profile_default/ 84 | ipython_config.py 85 | 86 | # pyenv 87 | # For a library or package, you might want to ignore these files since the code is 88 | # intended to run in multiple environments; otherwise, check them in: 89 | # .python-version 90 | 91 | # pipenv 92 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 93 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 94 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 95 | # install all needed dependencies. 96 | #Pipfile.lock 97 | 98 | # poetry 99 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 100 | # This is especially recommended for binary packages to ensure reproducibility, and is more 101 | # commonly ignored for libraries. 102 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 103 | #poetry.lock 104 | 105 | # pdm 106 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 107 | #pdm.lock 108 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 109 | # in version control. 110 | # https://pdm.fming.dev/#use-with-ide 111 | .pdm.toml 112 | 113 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 114 | __pypackages__/ 115 | 116 | # Celery stuff 117 | celerybeat-schedule 118 | celerybeat.pid 119 | 120 | # SageMath parsed files 121 | *.sage.py 122 | 123 | # Environments 124 | .env 125 | .venv 126 | env/ 127 | venv/ 128 | ENV/ 129 | env.bak/ 130 | venv.bak/ 131 | 132 | # Spyder project settings 133 | .spyderproject 134 | .spyproject 135 | 136 | # Rope project settings 137 | .ropeproject 138 | 139 | # mkdocs documentation 140 | /site 141 | 142 | # mypy 143 | .mypy_cache/ 144 | .dmypy.json 145 | dmypy.json 146 | 147 | # Pyre type checker 148 | .pyre/ 149 | 150 | # pytype static type analyzer 151 | .pytype/ 152 | 153 | # Cython debug symbols 154 | cython_debug/ 155 | 156 | # PyCharm 157 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 158 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 159 | # and can be added to the global gitignore or merged into this file. For a more nuclear 160 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 161 | #.idea/ 162 | 163 | # Local VSCode 164 | .vscode/ 165 | # local jetbrains IDE 166 | .idea -------------------------------------------------------------------------------- /.mega-linter.yml: -------------------------------------------------------------------------------- 1 | 2 | ENABLE_LINTERS: 3 | - REPOSITORY_GITLEAKS 4 | - REPOSITORY_KICS 5 | - ACTION_ACTIONLINT 6 | - MARKDOWN_MARKDOWNLINT 7 | - BASH_SHELLCHECK 8 | 9 | REPOSITORY_KICS_ARGUMENTS: "--fail-on high" -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/returntocorp/semgrep 3 | rev: v1.51.0 4 | hooks: 5 | - id: semgrep 6 | name: Semgrep Python 7 | types: [python] 8 | args: ["--config", "p/python", "--error", --metrics=off] 9 | - id: semgrep 10 | name: Semgrep Bandit 11 | types: [python] 12 | exclude: "^tests/.+$" 13 | args: ["--config", "p/bandit", "--error", --metrics=off] 14 | - repo: local 15 | hooks: 16 | - id: lint 17 | language: system 18 | name: Check formatting and lint 19 | entry: make lint 20 | stages: [commit] 21 | types: [python] -------------------------------------------------------------------------------- /.release-please-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | ".": "0.13.0" 3 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Use the UBI 8 minimal base image 2 | # kics-scan disable=fd54f200-402c-4333-a5a4-36ef6709af2f 3 | FROM registry.access.redhat.com/ubi8/ubi-minimal:latest AS python-base 4 | 5 | ENV PYTHONUNBUFFERED=1 \ 6 | PYTHONDONTWRITEBYTECODE=1 \ 7 | PIP_DISABLE_PIP_VERSION_CHECK=on \ 8 | PIP_DEFAULT_TIMEOUT=100 \ 9 | # Paths for the virtual environment and working directory 10 | PYSETUP_PATH="/trestle-bot" \ 11 | VENV_PATH="/trestle-bot/.venv" 12 | 13 | LABEL maintainer="Red Hat Product Security" \ 14 | summary="Trestle Bot" 15 | 16 | # Ensure we use the virtualenv 17 | ENV PATH="$VENV_PATH/bin:$PATH" 18 | 19 | RUN microdnf update -y \ 20 | && microdnf install -y python3.9 git \ 21 | && microdnf clean all \ 22 | && rm -rf /var/lib/apt/lists/* 23 | 24 | FROM python-base AS dependencies 25 | 26 | ARG POETRY_VERSION=1.7.1 27 | ARG INSTALL_PLUGINS=true 28 | 29 | # https://python-poetry.org/docs/configuration/#using-environment-variables 30 | ENV POETRY_VIRTUALENVS_IN_PROJECT=true \ 31 | POETRY_NO_INTERACTION=1 32 | 33 | # install poetry globally just for this intermediate build stage 34 | RUN python3.9 -m pip install --no-cache-dir --upgrade pip setuptools && \ 35 | python3.9 -m pip install --no-cache-dir "poetry==$POETRY_VERSION" 36 | 37 | WORKDIR "/build" 38 | COPY . "/build" 39 | 40 | # Install runtime deps and install the project in non-editable mode. 41 | # Ensure pip and setuptools are updated in the virtualenv as well. 42 | RUN python3.9 -m venv "$VENV_PATH" && \ 43 | . "$VENV_PATH"/bin/activate && \ 44 | python3.9 -m pip install --no-cache-dir --upgrade pip setuptools && \ 45 | if [ "$INSTALL_PLUGINS" == "true" ]; then \ 46 | poetry install --with plugins --no-root; \ 47 | else \ 48 | poetry install --no-root; \ 49 | fi 50 | 51 | RUN python3.9 -m venv "$VENV_PATH" && \ 52 | . "$VENV_PATH"/bin/activate && \ 53 | poetry build -f wheel -n && \ 54 | pip install --no-cache-dir --no-deps dist/*.whl && \ 55 | rm -rf dist ./*.egg-info 56 | 57 | FROM python-base AS final 58 | 59 | COPY --from=dependencies $PYSETUP_PATH $PYSETUP_PATH 60 | 61 | # Add wrappers for entrypoints that provide support for the actions 62 | COPY ./actions/common.sh / 63 | COPY ./actions/autosync/auto-sync-entrypoint.sh / 64 | COPY ./actions/rules-transform/rules-transform-entrypoint.sh / 65 | COPY ./actions/create-cd/create-cd-entrypoint.sh / 66 | COPY ./actions/sync-upstreams/sync-upstreams-entrypoint.sh / 67 | 68 | RUN chmod +x /auto-sync-entrypoint.sh /rules-transform-entrypoint.sh /create-cd-entrypoint.sh /sync-upstreams-entrypoint.sh 69 | 70 | ENTRYPOINT ["python3.9", "-m" , "trestlebot"] 71 | CMD ["--help"] 72 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | PYMODULE := trestlebot 2 | E2E := e2e 3 | TESTS := tests 4 | 5 | all: develop lint test 6 | .PHONY: all 7 | 8 | develop: pre-commit 9 | @poetry install --with tests,dev 10 | @poetry shell 11 | .PHONY: develop 12 | 13 | pre-commit: 14 | @poetry run pre-commit install 15 | 16 | lint: 17 | @poetry check --lock 18 | @poetry run isort --profile=black --lines-after-imports=2 \ 19 | --check-only $(TESTS) $(PYMODULE) 20 | @poetry run black --check $(TESTS) $(PYMODULE) --diff 21 | @poetry run mypy $(PYMODULE) $(TESTS) 22 | @poetry run flake8 23 | .PHONY: lint 24 | 25 | format: 26 | @poetry run isort --profile=black --lines-after-imports=2 $(TESTS) $(PYMODULE) 27 | @poetry run black $(TESTS) $(PYMODULE) 28 | .PHONY: format 29 | 30 | test: 31 | @poetry run pytest --cov --cov-config=pyproject.toml --cov-report=xml 32 | .PHONY: test 33 | 34 | test-slow: 35 | @poetry run pytest --slow --cov --cov-config=pyproject.toml --cov-report=xml 36 | .PHONY: test-slow 37 | 38 | test-e2e: 39 | @poetry run pytest $(TESTS)/$(E2E) --slow --cov --cov-config=pyproject.toml --cov-report=xml 40 | .PHONY: test-e2e 41 | 42 | test-integration: 43 | @poetry run pytest tests/integration --slow --cov --cov-config=pyproject.toml --cov-report=xml 44 | .PHONY: test-integration 45 | 46 | test-code-cov: 47 | @poetry run pytest --cov=trestlebot --exitfirst --cov-config=pyproject.toml --cov-report=xml --cov-fail-under=80 48 | .PHONY: test-code-cov 49 | 50 | # https://github.com/python-poetry/poetry/issues/994#issuecomment-831598242 51 | # Check for CVEs locally. For continuous dependency updates, we use dependabot. 52 | dep-cve-check: 53 | @poetry export -f requirements.txt --without-hashes | poetry run safety check --continue-on-error --stdin 54 | .PHONY: dep-cve-check 55 | 56 | security-check: 57 | @poetry run pre-commit run semgrep --all-files 58 | .PHONY: security-check 59 | 60 | build: clean-build 61 | @poetry build 62 | .PHONY: build 63 | 64 | clean-build: 65 | @rm -rf dist 66 | .PHONY: clean-build 67 | 68 | clean: 69 | @git clean -Xdf 70 | .PHONY: clean 71 | 72 | publish: 73 | @poetry config pypi-token.pypi $(PYPI_TOKEN) 74 | @poetry publish --dry-run 75 | @poetry publish 76 | .PHONY: publish 77 | 78 | build-and-publish: build publish 79 | .PHONY: build-and-publish 80 | 81 | update-action-readmes: 82 | @poetry run python scripts/update_action_readmes.py 83 | .PHONY: update-action-readmes 84 | -------------------------------------------------------------------------------- /TEMPLATES/github/trestlebot-create-component-definition.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Trestle-bot create component-definition 3 | 4 | on: 5 | workflow_dispatch: 6 | inputs: 7 | profile_name: 8 | description: Name of the Trestle profile to use for the component definition 9 | required: true 10 | component_definition_name: 11 | description: Name of the component definition to create 12 | required: true 13 | component_title: 14 | description: Name of the component to create in the generated component definition 15 | required: true 16 | component_type: 17 | description: Type of the component (e.g. service, policy, physical, validation, etc.) 18 | required: false 19 | default: "service" 20 | component_description: 21 | description: Description of the component to create 22 | required: true 23 | 24 | jobs: 25 | create-component-definition: 26 | name: Create component definition 27 | runs-on: ubuntu-latest 28 | permissions: 29 | contents: write 30 | pull-requests: write 31 | steps: 32 | - name: Checkout repository 33 | uses: actions/checkout@v4 34 | - name: Create component definition and open pull request 35 | id: generate-cd 36 | uses: RedHatProductSecurity/trestle-bot/actions/create-cd@main 37 | with: 38 | profile_name: ${{ github.event.inputs.profile_name }} 39 | component_definition_name: ${{ github.event.inputs.component_definition_name}} 40 | component_title: ${{ github.event.inputs.component_title }} 41 | component_type: ${{ github.event.inputs.component_type }} 42 | component_description: ${{ github.event.inputs.component_description }} 43 | markdown_dir: "markdown/component-definitions" 44 | branch: "create-component-definition-${{ github.run_id }}" 45 | target_branch: "main" 46 | file_patterns: "*.json,markdown/*,rules/*" 47 | commit_message: "adds component ${{ github.event.inputs.component_title }} in ${{ github.event.inputs.component_definition_name }}" 48 | github_token: ${{ secrets.GITHUB_TOKEN }} 49 | -------------------------------------------------------------------------------- /TEMPLATES/github/trestlebot-rules-transform.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Trestle-bot rules-transform and autosync 3 | 4 | on: 5 | push: 6 | branches: 7 | - main 8 | paths: 9 | - 'profiles/**' 10 | - 'catalogs/**' 11 | - 'component-definitions/**' 12 | - 'markdown/**' 13 | - 'rules/**' 14 | 15 | concurrency: 16 | group: ${{ github.ref }}-${{ github.workflow }} 17 | cancel-in-progress: true 18 | 19 | jobs: 20 | check_rules: 21 | runs-on: ubuntu-latest 22 | outputs: 23 | rules_changed: ${{ steps.changes.outputs.rules }} 24 | steps: 25 | - uses: actions/checkout@v4 26 | - uses: dorny/paths-filter@v3 27 | id: changes 28 | with: 29 | filters: | 30 | rules: 31 | - 'rules/**' 32 | rules-transform-and-autosync: 33 | name: Rules Transform and AutoSync 34 | runs-on: ubuntu-latest 35 | permissions: 36 | contents: write 37 | needs: check_rules 38 | steps: 39 | - name: Checkout repository 40 | uses: actions/checkout@v4 41 | - name: AutoSync 42 | id: autosync 43 | uses: RedHatProductSecurity/trestle-bot/actions/autosync@main 44 | with: 45 | markdown_dir: "markdown/component-definitions" 46 | oscal_model: "compdef" 47 | commit_message: "Autosync component definition content [skip ci]" 48 | - name: Rules Transform 49 | if: needs.check_rules.outputs.rules_changed == 'true' 50 | uses: RedHatProductSecurity/trestle-bot/actions/rules-transform@main 51 | with: 52 | markdown_dir: "markdown/component-definitions" 53 | commit_message: "Auto-transform rules [skip ci]" 54 | -------------------------------------------------------------------------------- /TROUBLESHOOTING.md: -------------------------------------------------------------------------------- 1 | # Troubleshooting 2 | 3 | 4 | ## Action does not commit changes back to the correct branch 5 | 6 | Verify the trigger you are using. The default branch is set to `github.ref_name`. If triggered on a pull request, you may notice this set to `pr-number/merge`. Set the branch field to `github.heaf_ref` which is set during pull request triggered workflows. 7 | 8 | ## Action does not have permission to commit/pull_request 9 | 10 | If your workflow requires that this action make changes to your branch, ensure the token being used has `content: write` permissions and the token is being set. 11 | 12 | ```yaml 13 | ## Defaults to ${{ github.token }} 14 | - uses: actions/checkout@v3 15 | with: 16 | token: ${{ secrets.TOKEN }} 17 | ``` 18 | 19 | If your workflow requires that this action create a pull request (`target_branch` is set), ensure the token being used has `pull_request: write` permissions and the token is being set. 20 | 21 | ```yaml 22 | # github_token has no default. 23 | # To use default token use ${{ secrets.GITHUB_TOKEN }} 24 | - uses: RedHatProductSecurity/trestle-bot@main 25 | with: 26 | markdown_path: "markdown/profiles" 27 | assemble_model: "profile" 28 | target_branch: "main" 29 | github_token: ${{ secrets.TOKEN }} 30 | ``` 31 | 32 | > Note: Using the GitHub token provided with GitHub Actions to commit to a branch will [NOT trigger additional workflows](https://docs.github.com/en/actions/security-guides/automatic-token-authentication#using-the-github_token-in-a-workflow). -------------------------------------------------------------------------------- /actions/autosync/action.yml: -------------------------------------------------------------------------------- 1 | name: "trestle-bot-autosync" 2 | author: "Red Hat Product Security" 3 | description: "An action to perform automatic synchronization of Trestle markdown files to OSCAL." 4 | 5 | inputs: 6 | markdown_dir: 7 | description: Path relative to the repository path where the Trestle markdown files are located. See action README.md for more information. 8 | required: true 9 | oscal_model: 10 | description: OSCAL Model type to assemble. Values can be catalog, profile, compdef, or ssp. 11 | required: true 12 | dry_run: 13 | description: "Runs tasks without pushing changes to the repository." 14 | required: false 15 | default: "false" 16 | github_token: 17 | description: | 18 | "GitHub token used to make authenticated API requests. 19 | Note: You should use a defined secret like "secrets.GITHUB_TOKEN" in your workflow file, do not hardcode the token." 20 | required: false 21 | version: 22 | description: "Version of the OSCAL model to set during assembly into JSON." 23 | required: false 24 | skip_assemble: 25 | description: "Skip assembly task. Defaults to false" 26 | required: false 27 | default: "false" 28 | skip_regenerate: 29 | description: "Skip regenerate task. Defaults to false." 30 | required: false 31 | default: "false" 32 | skip_items: 33 | description: "Comma-separated glob patterns list of content by trestle name to skip during task execution. For example `profile_x,profile_y*,`." 34 | required: false 35 | ssp_index_file: 36 | description: JSON file relative to the repository path where the ssp index is located. See action README.md for information about the ssp index. 37 | required: false 38 | default: "ssp-index.json" 39 | commit_message: 40 | description: Custom commit message 41 | required: false 42 | default: "Sync automatic updates" 43 | branch: 44 | description: Name of the Git branch to which modifications should be pushed. Required if Action is used on the `pull_request` event. 45 | required: false 46 | default: ${{ github.ref_name }} 47 | target_branch: 48 | description: Target branch (or base branch) to create a pull request against. If unset, no pull request will be created. If set, a pull request will be created using the `branch` field as the head branch. 49 | required: false 50 | file_patterns: 51 | description: Comma separated file pattern list used for `git add`. For example `component-definitions/*,*json`. Defaults to (`.`) 52 | required: false 53 | default: '.' 54 | repo_path: 55 | description: Local file path to the git repository with a valid trestle project root relative to the GitHub workspace. 56 | required: false 57 | default: '.' 58 | commit_user_name: 59 | description: Name used for the commit user. 60 | required: false 61 | default: github-actions[bot] 62 | commit_user_email: 63 | description: Email address used for the commit user 64 | required: false 65 | default: 41898282+github-actions[bot]@users.noreply.github.com 66 | commit_author_name: 67 | description: Name used for the commit author. Defaults to the username of whoever triggered this workflow run. 68 | required: false 69 | default: ${{ github.actor }} 70 | commit_author_email: 71 | description: Email address used for the commit author. 72 | required: false 73 | default: ${{ github.actor }}@users.noreply.github.com 74 | debug: 75 | description: Enable debug logging messages. 76 | required: false 77 | default: "false" 78 | config: 79 | description: Path to trestlebot configuration file. 80 | required: false 81 | default: ".trestlebot/config.yml" 82 | 83 | outputs: 84 | changes: 85 | description: Value is "true" if changes were committed back to the repository. 86 | commit: 87 | description: Full hash of the created commit. Only present if the "changes" output is "true". 88 | pr_number: 89 | description: Number of the submitted pull request. Only present if a pull request is submitted. 90 | 91 | runs: 92 | using: "docker" 93 | image: "../../Dockerfile" 94 | entrypoint: "/auto-sync-entrypoint.sh" 95 | env: 96 | TRESTLEBOT_REPO_ACCESS_TOKEN: ${{ inputs.github_token }} 97 | 98 | branding: 99 | icon: "check" 100 | color: "green" 101 | -------------------------------------------------------------------------------- /actions/autosync/auto-sync-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -eu 4 | 5 | # shellcheck disable=SC1091 6 | source /common.sh 7 | 8 | set_git_safe_directory 9 | 10 | # Initialize the command variable 11 | command="trestlebot autosync \ 12 | --markdown-dir=\"${INPUT_MARKDOWN_DIR}\" \ 13 | --oscal-model=\"${INPUT_OSCAL_MODEL}\" \ 14 | --ssp-index-file=\"${INPUT_SSP_INDEX_FILE}\" \ 15 | --commit-message=\"${INPUT_COMMIT_MESSAGE}\" \ 16 | --branch=\"${INPUT_BRANCH}\" \ 17 | --file-patterns=\"${INPUT_FILE_PATTERNS}\" \ 18 | --committer-name=\"${INPUT_COMMIT_USER_NAME}\" \ 19 | --committer-email=\"${INPUT_COMMIT_USER_EMAIL}\" \ 20 | --author-name=\"${INPUT_COMMIT_AUTHOR_NAME}\" \ 21 | --author-email=\"${INPUT_COMMIT_AUTHOR_EMAIL}\" \ 22 | --repo-path=\"${INPUT_REPO_PATH}\" \ 23 | --target-branch=\"${INPUT_TARGET_BRANCH}\" \ 24 | --skip-items=\"${INPUT_SKIP_ITEMS}\" \ 25 | --version=\"${INPUT_VERSION}\" 26 | --config=\"${INPUT_CONFIG}\"" 27 | 28 | # Conditionally include flags 29 | if [[ ${INPUT_SKIP_ASSEMBLE} == true ]]; then 30 | command+=" --skip-assemble" 31 | fi 32 | 33 | if [[ ${INPUT_SKIP_REGENERATE} == true ]]; then 34 | command+=" --skip-regenerate" 35 | fi 36 | 37 | if [[ ${INPUT_DRY_RUN} == true ]]; then 38 | command+=" --dry-run" 39 | fi 40 | 41 | if [[ ${INPUT_DEBUG} == true ]]; then 42 | command+=" --debug" 43 | fi 44 | 45 | eval "${command}" 46 | -------------------------------------------------------------------------------- /actions/common.sh: -------------------------------------------------------------------------------- 1 | # shellcheck disable=SC2148 2 | 3 | # common.sh 4 | # This file is sourced by other scripts and contains common functions 5 | 6 | # Manage newest git versions (related to CVE https://github.blog/2022-04-12-git-security-vulnerability-announced/) 7 | # 8 | function set_git_safe_directory() { 9 | if [[ -z "${GITHUB_WORKSPACE}" ]]; then 10 | echo "GITHUB_WORKSPACE is not set. Exiting..." 11 | exit 1 12 | else 13 | echo "Setting git safe.directory GITHUB_WORKSPACE: $GITHUB_WORKSPACE ..." 14 | git config --global --add safe.directory "$GITHUB_WORKSPACE" 15 | fi 16 | } 17 | 18 | -------------------------------------------------------------------------------- /actions/create-cd/action.yml: -------------------------------------------------------------------------------- 1 | name: "trestle-bot-create-cd" 2 | author: "Red Hat Product Security" 3 | description: "An action for component definition bootstrapping" 4 | 5 | inputs: 6 | markdown_dir: 7 | description: Path relative to the repository path to create markdown files. See action README.md for more information. 8 | required: true 9 | profile_name: 10 | description: Name of the Trestle profile to use for the component definition 11 | required: true 12 | component_definition_name: 13 | description: Name of the component definition to create 14 | required: true 15 | component_title: 16 | description: Name of the component to create 17 | required: true 18 | component_type: 19 | description: Type of the component to create. Values can be interconnection, software, hardware, service, policy, physical, process-procedure, plan, guidance, standard, or validation 20 | required: false 21 | default: "service" 22 | component_description: 23 | description: Description of the component to create 24 | required: true 25 | filter_by_profile: 26 | description: Name of the profile in the workspace to filter controls by 27 | required: false 28 | dry_run: 29 | description: "Runs tasks without pushing changes to the repository." 30 | required: false 31 | default: "false" 32 | github_token: 33 | description: | 34 | "GitHub token used to make authenticated API requests. 35 | Note: You should use a defined secret like "secrets.GITHUB_TOKEN" in your workflow file, do not hardcode the token." 36 | required: false 37 | commit_message: 38 | description: Commit message 39 | required: false 40 | default: "Sync automatic updates" 41 | branch: 42 | description: Name of the Git branch to which modifications should be pushed. Required if Action is used on the `pull_request` event. 43 | required: false 44 | default: ${{ github.ref_name }} 45 | target_branch: 46 | description: Target branch (or base branch) to create a pull request against. If unset, no pull request will be created. If set, a pull request will be created using the `branch` field as the head branch. 47 | required: false 48 | file_patterns: 49 | description: Comma separated file pattern list used for `git add`. For example `component-definitions/*,*json`. Defaults to (`.`) 50 | required: false 51 | default: '.' 52 | repo_path: 53 | description: Local file path to the git repository with a valid trestle project root relative to the GitHub workspace. Defaults to the current directory (`.`) 54 | required: false 55 | default: '.' 56 | commit_user_name: 57 | description: Name used for the commit user 58 | required: false 59 | default: github-actions[bot] 60 | commit_user_email: 61 | description: Email address used for the commit user 62 | required: false 63 | default: 41898282+github-actions[bot]@users.noreply.github.com 64 | commit_author_name: 65 | description: Name used for the commit author. Defaults to the username of whoever triggered this workflow run. 66 | required: false 67 | default: ${{ github.actor }} 68 | commit_author_email: 69 | description: Email address used for the commit author. 70 | required: false 71 | default: ${{ github.actor }}@users.noreply.github.com 72 | debug: 73 | description: Enable debug logging messages. 74 | required: false 75 | default: "false" 76 | config: 77 | description: Path to trestlebot configuration file. 78 | required: false 79 | default: ".trestlebot/config.yml" 80 | 81 | outputs: 82 | changes: 83 | description: Value is "true" if changes were committed back to the repository. 84 | commit: 85 | description: Full hash of the created commit. Only present if the "changes" output is "true". 86 | pr_number: 87 | description: Number of the submitted pull request. Only present if a pull request is submitted. 88 | 89 | runs: 90 | using: "docker" 91 | image: "../../Dockerfile" 92 | entrypoint: "/create-cd-entrypoint.sh" 93 | env: 94 | TRESTLEBOT_REPO_ACCESS_TOKEN: ${{ inputs.github_token }} 95 | 96 | branding: 97 | icon: "check" 98 | color: "green" 99 | -------------------------------------------------------------------------------- /actions/create-cd/create-cd-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -eu 4 | 5 | # shellcheck disable=SC1091 6 | source /common.sh 7 | 8 | set_git_safe_directory 9 | 10 | # Initialize the command variable 11 | command="trestlebot create compdef \ 12 | --profile-name=\"${INPUT_PROFILE_NAME}\" \ 13 | --compdef-name=\"${INPUT_COMPONENT_DEFINITION_NAME}\" \ 14 | --component-title=\"${INPUT_COMPONENT_TITLE}\" \ 15 | --component-description=\"${INPUT_COMPONENT_DESCRIPTION}\" \ 16 | --component-definition-type=\"${INPUT_COMPONENT_TYPE}\" \ 17 | --markdown-dir=\"${INPUT_MARKDOWN_DIR}\" \ 18 | --commit-message=\"${INPUT_COMMIT_MESSAGE}\" \ 19 | --filter-by-profile=\"${INPUT_FILTER_BY_PROFILE}\" \ 20 | --branch=\"${INPUT_BRANCH}\" \ 21 | --file-patterns=\"${INPUT_FILE_PATTERNS}\" \ 22 | --committer-name=\"${INPUT_COMMIT_USER_NAME}\" \ 23 | --committer-email=\"${INPUT_COMMIT_USER_EMAIL}\" \ 24 | --author-name=\"${INPUT_COMMIT_AUTHOR_NAME}\" \ 25 | --author-email=\"${INPUT_COMMIT_AUTHOR_EMAIL}\" \ 26 | --repo-path=\"${INPUT_REPO_PATH}\" \ 27 | --target-branch=\"${INPUT_TARGET_BRANCH}\" 28 | --config=\"${INPUT_CONFIG}\"" 29 | 30 | # Conditionally include flags 31 | if [[ ${INPUT_DRY_RUN} == true ]]; then 32 | command+=" --dry-run" 33 | fi 34 | 35 | if [[ ${INPUT_DEBUG} == true ]]; then 36 | command+=" --debug" 37 | fi 38 | 39 | eval "${command}" 40 | -------------------------------------------------------------------------------- /actions/rules-transform/action.yml: -------------------------------------------------------------------------------- 1 | name: "trestle-bot-rules-transform" 2 | author: "Red Hat Product Security" 3 | description: "A rules transform action to convert trestle rules in YAML format to OSCAL and propagates changes to Markdown." 4 | 5 | inputs: 6 | markdown_dir: 7 | description: Path relative to the repository path to create markdown files. See action README.md for more information. 8 | required: true 9 | rules_view_dir: 10 | description: Path relative to the repository path where the Trestle rules view files are located. Defaults to `rules/`. 11 | required: false 12 | default: "rules/" 13 | dry_run: 14 | description: "Runs tasks without pushing changes to the repository." 15 | required: false 16 | default: "false" 17 | github_token: 18 | description: | 19 | "GitHub token used to make authenticated API requests. 20 | Note: You should use a defined secret like "secrets.GITHUB_TOKEN" in your workflow file, do not hardcode the token." 21 | required: false 22 | skip_items: 23 | description: "Comma-separated glob patterns list of content by Trestle name to skip during task execution. For example `compdef_x,compdef_y*,`." 24 | required: false 25 | commit_message: 26 | description: Commit message 27 | required: false 28 | default: "Sync automatic updates" 29 | branch: 30 | description: Name of the Git branch to which modifications should be pushed. Required if Action is used on the `pull_request` event. 31 | required: false 32 | default: ${{ github.ref_name }} 33 | target_branch: 34 | description: Target branch (or base branch) to create a pull request against. If unset, no pull request will be created. If set, a pull request will be created using the `branch` field as the head branch. 35 | required: false 36 | file_patterns: 37 | description: Comma separated file pattern list used for `git add`. For example `component-definitions/*,*json`. Defaults to (`.`) 38 | required: false 39 | default: '.' 40 | repo_path: 41 | description: Local file path to the git repository with a valid trestle project root relative to the GitHub workspace. Defaults to the current directory (`.`) 42 | required: false 43 | default: '.' 44 | commit_user_name: 45 | description: Name used for the commit user 46 | required: false 47 | default: github-actions[bot] 48 | commit_user_email: 49 | description: Email address used for the commit user 50 | required: false 51 | default: 41898282+github-actions[bot]@users.noreply.github.com 52 | commit_author_name: 53 | description: Name used for the commit author. Defaults to the username of whoever triggered this workflow run. 54 | required: false 55 | default: ${{ github.actor }} 56 | commit_author_email: 57 | description: Email address used for the commit author. 58 | required: false 59 | default: ${{ github.actor }}@users.noreply.github.com 60 | debug: 61 | description: Enable debug logging messages. 62 | required: false 63 | default: "false" 64 | config: 65 | description: Path to trestlebot configuration file. 66 | required: false 67 | default: ".trestlebot/config.yml" 68 | 69 | outputs: 70 | changes: 71 | description: Value is "true" if changes were committed back to the repository. 72 | commit: 73 | description: Full hash of the created commit. Only present if the "changes" output is "true". 74 | pr_number: 75 | description: Number of the submitted pull request. Only present if a pull request is submitted. 76 | 77 | runs: 78 | using: "docker" 79 | image: "../../Dockerfile" 80 | entrypoint: "/rules-transform-entrypoint.sh" 81 | env: 82 | TRESTLEBOT_REPO_ACCESS_TOKEN: ${{ inputs.github_token }} 83 | 84 | branding: 85 | icon: "check" 86 | color: "green" 87 | -------------------------------------------------------------------------------- /actions/rules-transform/rules-transform-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -eu 4 | 5 | # shellcheck disable=SC1091 6 | source /common.sh 7 | 8 | set_git_safe_directory 9 | 10 | # Initialize the command variable 11 | command="trestlebot rules-transform \ 12 | --markdown-dir=\"${INPUT_MARKDOWN_DIR}\" \ 13 | --rules-view-dir=\"${INPUT_RULES_VIEW_DIR}\" \ 14 | --commit-message=\"${INPUT_COMMIT_MESSAGE}\" \ 15 | --branch=\"${INPUT_BRANCH}\" \ 16 | --file-patterns=\"${INPUT_FILE_PATTERNS}\" \ 17 | --committer-name=\"${INPUT_COMMIT_USER_NAME}\" \ 18 | --committer-email=\"${INPUT_COMMIT_USER_EMAIL}\" \ 19 | --author-name=\"${INPUT_COMMIT_AUTHOR_NAME}\" \ 20 | --author-email=\"${INPUT_COMMIT_AUTHOR_EMAIL}\" \ 21 | --repo-path=\"${INPUT_REPO_PATH}\" \ 22 | --target-branch=\"${INPUT_TARGET_BRANCH}\" \ 23 | --skip-items=\"${INPUT_SKIP_ITEMS}\" 24 | --version=\"${INPUT_VERSION}\" 25 | --config=\"${INPUT_CONFIG}\"" 26 | 27 | # Conditionally include flags 28 | if [[ ${INPUT_DRY_RUN} == true ]]; then 29 | command+=" --dry-run" 30 | fi 31 | 32 | if [[ ${INPUT_DEBUG} == true ]]; then 33 | command+=" --debug" 34 | fi 35 | 36 | eval "${command}" 37 | -------------------------------------------------------------------------------- /actions/sync-upstreams/action.yml: -------------------------------------------------------------------------------- 1 | name: "trestle-bot-sync-upstreams" 2 | author: "Red Hat Product Security" 3 | description: "An action to sync and validate OSCAL content from upstream repositories." 4 | 5 | inputs: 6 | sources: 7 | description: "A newline separated list of upstream sources to sync with a repo@branch format. For example, `https://github.com/myorg/myprofiles@main`" 8 | required: true 9 | dry_run: 10 | description: "Runs tasks without pushing changes to the repository." 11 | required: false 12 | default: "false" 13 | github_token: 14 | description: | 15 | "GitHub token used to make authenticated API requests. 16 | Note: You should use a defined secret like "secrets.GITHUB_TOKEN" in your workflow file, do not hardcode the token." 17 | required: false 18 | include_models: 19 | description: "Comma-separated glob pattern list of model names (i.e. trestle directory name) to include in the sync. For example, `*framework-v2`. Defaults to include all model names." 20 | required: false 21 | exclude_models: 22 | description: "Comma-separated glob pattern of model names (i.e. trestle directory name) to exclude from the sync. For example, `*framework-v1`. Defaults to skip no model names." 23 | required: false 24 | skip_validation: 25 | description: "Skip validation of the upstream OSCAL content. Defaults to false" 26 | required: false 27 | default: "false" 28 | commit_message: 29 | description: Commit message 30 | required: false 31 | default: "Sync automatic updates" 32 | branch: 33 | description: Name of the Git branch to which modifications should be pushed. Required if Action is used on the `pull_request` event. 34 | required: false 35 | default: ${{ github.ref_name }} 36 | target_branch: 37 | description: Target branch (or base branch) to create a pull request against. If unset, no pull request will be created. If set, a pull request will be created using the `branch` field as the head branch. 38 | required: false 39 | file_patterns: 40 | description: Comma-separated file pattern list used for `git add`. For example `component-definitions/*,*json`. Defaults to (`.`) 41 | required: false 42 | default: '.' 43 | repo_path: 44 | description: Local file path to the git repository with a valid trestle project root relative to the GitHub workspace. Defaults to the current directory (`.`) 45 | required: false 46 | default: '.' 47 | commit_user_name: 48 | description: Name used for the commit user 49 | required: false 50 | default: github-actions[bot] 51 | commit_user_email: 52 | description: Email address used for the commit user 53 | required: false 54 | default: 41898282+github-actions[bot]@users.noreply.github.com 55 | commit_author_name: 56 | description: Name used for the commit author. Defaults to the username of whoever triggered this workflow run. 57 | required: false 58 | default: ${{ github.actor }} 59 | commit_author_email: 60 | description: Email address used for the commit author. 61 | required: false 62 | default: ${{ github.actor }}@users.noreply.github.com 63 | debug: 64 | description: Enable debug logging messages. 65 | required: false 66 | default: "false" 67 | config: 68 | description: Path to trestlebot configuration file. 69 | required: false 70 | default: ".trestlebot/config.yml" 71 | 72 | outputs: 73 | changes: 74 | description: Value is "true" if changes were committed back to the repository. 75 | commit: 76 | description: Full hash of the created commit. Only present if the "changes" output is "true". 77 | pr_number: 78 | description: Number of the submitted pull request. Only present if a pull request is submitted. 79 | 80 | runs: 81 | using: "docker" 82 | image: "../../Dockerfile" 83 | entrypoint: "/sync-upstreams-entrypoint.sh" 84 | env: 85 | TRESTLEBOT_REPO_ACCESS_TOKEN: ${{ inputs.github_token }} 86 | 87 | branding: 88 | icon: "check" 89 | color: "green" 90 | -------------------------------------------------------------------------------- /actions/sync-upstreams/sync-upstreams-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -eu 4 | 5 | # shellcheck disable=SC1091 6 | source /common.sh 7 | 8 | set_git_safe_directory 9 | 10 | # Transform the input sources into a comma separated list 11 | INPUT_SOURCES=$(echo "${INPUT_SOURCES}" | tr '\n' ' ' | tr -s ' ' | sed 's/ *$//' | tr ' ' ',') 12 | 13 | # Initialize the command variable 14 | command="trestlebot sync-upstreams \ 15 | --sources=\"${INPUT_SOURCES}\" \ 16 | --include-models=\"${INPUT_INCLUDE_MODELS}\" \ 17 | --exclude-models=\"${INPUT_EXCLUDE_MODELS}\" \ 18 | --commit-message=\"${INPUT_COMMIT_MESSAGE}\" \ 19 | --branch=\"${INPUT_BRANCH}\" \ 20 | --file-patterns=\"${INPUT_FILE_PATTERNS}\" \ 21 | --committer-name=\"${INPUT_COMMIT_USER_NAME}\" \ 22 | --committer-email=\"${INPUT_COMMIT_USER_EMAIL}\" \ 23 | --author-name=\"${INPUT_COMMIT_AUTHOR_NAME}\" \ 24 | --author-email=\"${INPUT_COMMIT_AUTHOR_EMAIL}\" \ 25 | --repo-path=\"${INPUT_REPO_PATH}\" \ 26 | --target-branch=\"${INPUT_TARGET_BRANCH}\" 27 | --config=\"${INPUT_CONFIG}\"" 28 | 29 | # Conditionally include flags 30 | if [[ ${INPUT_SKIP_VALIDATION} == true ]]; then 31 | command+=" --skip-validation" 32 | fi 33 | 34 | if [[ ${INPUT_DRY_RUN} == true ]]; then 35 | command+=" --dry-run" 36 | fi 37 | 38 | if [[ ${INPUT_DEBUG} == true ]]; then 39 | command+=" --debug" 40 | fi 41 | 42 | eval "${command}" 43 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['@commitlint/config-conventional'], 3 | rules: { 4 | 'subject-case': [0, 'always', 'lower-case'], 5 | } 6 | }; 7 | -------------------------------------------------------------------------------- /docs/architecture/.trestle/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/complytime/trestle-bot/6c95b78310d2088063c052726a934742ca4b5759/docs/architecture/.trestle/.keep -------------------------------------------------------------------------------- /docs/architecture/.trestle/author/decisions/0.0.1/template.md: -------------------------------------------------------------------------------- 1 | --- 2 | x-trestle-template-version: 0.0.1 3 | title: 4 | status: proposed # Valid statuses are proposed, deferred, rejected, withdrawn or replaced 5 | --- 6 | 7 | ## Context 8 | 9 | 10 | 11 | ## Decision 12 | 13 | 14 | 15 | ## Consequences 16 | 17 | -------------------------------------------------------------------------------- /docs/architecture/decisions/implement-cli-framework_001.md: -------------------------------------------------------------------------------- 1 | --- 2 | x-trestle-template-version: 0.0.1 3 | title: Implement CLI Framework 4 | status: accepted 5 | --- 6 | 7 | # ADR 001 - Implement CLI Framework 8 | 9 | ## Context 10 | 11 | 12 | The primary motivation for this ADR is to enhance the user experience by implementing a more robust CLI framework within the trestlebot codebase. This will address the requirements of [Issue #295](https://github.com/RedHatProductSecurity/trestle-bot/issues/295) and [Issue #342](https://github.com/RedHatProductSecurity/trestle-bot/issues/342) and enable future development of more complex CLI scenarios. Currently entrypoints leverage the argparse library as the core CLI framework. However advanced patterns such as command chaining, subcommands, and dependencies between arguments can be difficult to implement. Moving to the [Click](https://click.palletsprojects.com/en/5.x/) CLI framework will address these challenges and support more complex requirements in the future. In addition, Click will provide a universal command syntax that can be used in the Python CLI app and container execution. 13 | 14 | This ADR also outlines the adoption of environment variables and a configuration file within the CLI. These will provides alternatives methods of passing arguments to the CLI beyond just command flags. This provides users with flexibility in how they pass arguments to the CLI and creates a more static option for arguments that tend to remain unchanged between command executions. 15 | 16 | 17 | ## Decision 18 | 19 | The trestlebot module will be refactored to remove the use of `argparse` in favor of Click as the CLI framework. The code contained in `entrypoints` will be converted into Click commands under the `trestlebot` CLI application. A new `cli.py` module will be created as the main entrypoint. 20 | 21 | In addition, support will be added for using a configuration file and environment variables as CLI inputs. The CLI will prioritize arguments passed as command flags. If no argument is passed, the CLI will check for an environment variable. Finally, if no enviroment variable is found, it will look to the configuration file. Click natively supports loading command arguments from environment variables, including a constant prefix. All environment variables will have a `TRESTLEBOT_` prefix. 22 | 23 | The configuration file will be broken into two primary categories, `global` and `model specific`. Global configuration will apply across all models and include values such as git provider, markdown directories, etc. Model specific configuration will apply to the given OSCAL model only. While it is expected that most repos will be used for authoring a single OSCAL model, the possiblity of authoring more than one model would be supported. 24 | 25 | The configuration file would be initialized at a default location during the `trestlebot init` command. Manual creation and editing is also possible. The path to the configuration file can be passed using the `--config | -c` flag. This would not be required if using the default file location. 26 | 27 | Default behaviors: 28 | - the default configuration file location will be `.trestlebot/config.yaml` 29 | - if a command only supports a single OSCAL model then `--oscal-model` will default to that value. (ex: `rules-transform` only supports compdef) 30 | - if the config file only contains a single OSCAL model then that will be used as the default value for `--oscal-model` 31 | 32 | #### Example config: 33 | 34 | ```yaml 35 | --- 36 | version: 1 37 | working-dir: "." 38 | upstream-sources: [] 39 | ssp-index-path: ssp-index.json 40 | git-provider-type: github 41 | git-provider-url: github.com 42 | git-committer-name: "Foo Bar" 43 | git-committer-email: foo@bar.com 44 | models: 45 | # we could allow for multiple or keep this as one 46 | - oscal-model: ssp 47 | markdown-path: markdown/system-security-plans 48 | skip-items: [...] 49 | skip-assemble: true 50 | - oscal-model: compdef 51 | markdown-path: markdown/component-definitions 52 | skip-items: [...] 53 | skip-assemble: true 54 | ``` 55 | 56 | 57 | ## Consequences 58 | 59 | - The existing command syntax will be updated to evolve from a set of independent entrypoint commants to a unified `trestlebot` CLI with multiple "subcommands". For example, `trestlebot-autosync ` becomes `trestlebot autosync `. 60 | - The container entrypoints will be collapsed into a single entrypoint leveraging the Click CLI application. 61 | - CLI command arguments will be passed via flags, environment variables, or configuration file. 62 | -------------------------------------------------------------------------------- /docs/architecture/decisions/record-architecture-decisions_000.md: -------------------------------------------------------------------------------- 1 | --- 2 | x-trestle-template-version: 0.0.1 3 | title: Record architecture decisions 4 | status: accepted # Valid statuses are proposed, accepted, deferred, rejected, withdrawn, or replaced 5 | --- 6 | 7 | ## Context 8 | 9 | We need to record the architectural decisions made on this project. 10 | 11 | ## Decision 12 | 13 | We will use Architecture Decision Records, as [described by Michael Nygard](http://thinkrelevance.com/blog/2011/11/15/documenting-architecture-decisions). 14 | 15 | ## Consequences 16 | 17 | See Michael Nygard's article, linked above. `trestle` will be used to create and validate these decisions. 18 | -------------------------------------------------------------------------------- /docs/architecture/diagrams/c4.md: -------------------------------------------------------------------------------- 1 | ## Context 2 | 3 | ```mermaid 4 | graph LR 5 | subgraph External 6 | User 7 | end 8 | 9 | subgraph Container 10 | ContainerImage("Container Image") 11 | TrestlebotContainer("Trestlebot Container") 12 | end 13 | 14 | subgraph GitHub 15 | OSCALRepo("OSCAL Content Repository") 16 | GithubActions("GitHub Actions") 17 | CustomAction("Custom GitHub Action") 18 | end 19 | 20 | User -- Uses --> OSCALRepo 21 | GithubActions -- Triggers --> CustomAction 22 | CustomAction -- Builds --> ContainerImage 23 | ContainerImage -- Runs --> TrestlebotContainer 24 | OSCALRepo -- Uses --> GithubActions 25 | User -- Uses --> GithubActions 26 | ``` 27 | 28 | ## Container 29 | 30 | ```mermaid 31 | graph LR 32 | subgraph Container 33 | ContainerImage("Container Image") 34 | TrestlebotCLI("Trestlebot CLI") 35 | end 36 | subgraph GitHub 37 | GithubActions("GitHub Actions") 38 | CustomAction("Custom GitHub Action") 39 | end 40 | 41 | 42 | GithubActions -- Triggers --> CustomAction 43 | CustomAction -- Builds --> ContainerImage 44 | ContainerImage -- Distributes --> TrestlebotCLI 45 | ``` 46 | 47 | ## Component 48 | 49 | ```mermaid 50 | graph TD 51 | subgraph Container 52 | TrestlebotCLI("Trestlebot CLI") 53 | Entrypoint("Entrypoint script") 54 | end 55 | 56 | subgraph Runtime 57 | EnvironmentVariables("Environment Variables") 58 | GitRepo("Git Local Repository") 59 | end 60 | 61 | subgraph GitHub 62 | GitHubAction("GitHub Action") 63 | GitHubAPI("GitHub API") 64 | end 65 | 66 | GitHubAction -- Sets --> EnvironmentVariables 67 | GitHubAction -- Uses --> Entrypoint 68 | TrestlebotCLI -- Reads content --> GitRepo 69 | Entrypoint -- Runs --> TrestlebotCLI 70 | Entrypoint -- Reads --> EnvironmentVariables 71 | TrestlebotCLI -- Update content --> GitHubAPI 72 | ``` 73 | -------------------------------------------------------------------------------- /docs/contributing.md: -------------------------------------------------------------------------------- 1 | {!CONTRIBUTING.md!} 2 | -------------------------------------------------------------------------------- /docs/troubleshooting.md: -------------------------------------------------------------------------------- 1 | {!TROUBLESHOOTING.md!} 2 | -------------------------------------------------------------------------------- /docs/tutorials/authoring.md: -------------------------------------------------------------------------------- 1 | # Authoring Tutorial 2 | 3 | This tutorial provides an overview of the authoring process using `trestlebot`. We will use the component definition created in the [GitHub tutorial](https://redhatproductsecurity.github.io/trestle-bot/tutorials/github/) as our starting point. This tutorial will demonstrate the workflow for updating Markdown content and syncing those changes to OSCAL. 4 | 5 | ## 1. Prerequisites 6 | 7 | - Complete the [GitHub tutorial](https://complytime.github.io/trestle-bot/tutorials/github/) 8 | 9 | 10 | ## 2. Edit in Markdown 11 | 12 | We will begin where we left off at the end of the [GitHub tutorial](https://redhatproductsecurity.github.io/trestle-bot/tutorials/github/). Our repository has a newly created component definition named `my-first-compdef` with corresponding content in the `markdown/` and `component-definitions/` directories. We will now demonstrate how to author changes in Markdown and produce updated OSCAL content. 13 | 14 | 1. Navigate to the `markdown/component-definitions/my-first-compdef/test-component/nist_rev5_800_53/ac` directory and select the `ac-1.md` file. 15 | 2. Click the `Edit this file` (pencil) icon. 16 | 3. Scroll down to the section titled `## What is the solution and how is it implemented?` and add a new line of text with a brief comment. For example: 17 | 18 | ``` 19 | ## What is the solution and how is it implemented? 20 | 21 | Here is where details should be added by the author. 22 | ``` 23 | 24 | 4. Click the `Commit changes..` button 25 | 5. Select the `Create a new branch for this commit and start a pull request` radio button 26 | 6. Click `Propose changes` 27 | 28 | 29 | The `Open a pull request` page now opens. Enter any additional details about your changes into the description box. 30 | 31 | 7. Click `Create pull request` 32 | 8. For demo purposes, we will go ahead and merge the pull request ourselves. In a production setting the pull request process should be used for review, discussion and approval of the proposed changes. Click `Merge pull request` and then `Confirm merge`. 33 | 34 | 35 | ## Autosync 36 | 37 | Once the pull request has been merged the `Trestle-bot rules-transform and autosync` GitHub action will be triggered. We will now validate that action was successful. 38 | 39 | 1. Navigate to the `Actions` tab of your GitHub repository. 40 | 2. The top entry in the list of workflow runs should be titled `Merge pull request # from `. This action should be either running or have just successfully completed. 41 | 3. [Optional] Clicking this entry will allow you to view the detailed steps and log output. 42 | 4. Once the action is completed successfully, navigate back to the source code by clicking the `Code` tab of the repo. 43 | 5. Click the `component-definitions` folder and navigate to `my-first-compdef/component-definition.json`. 44 | 5. The `Last commit date` should align with the time the action completed. 45 | 6. Click the `component-definitions.json` file and then click the `History` icon to view the commit history. 46 | 7. Ensure the latest commit performed by the GitHub action reflects the changes made in Markdown as shown below: 47 | 48 | ``` 49 | "description": "", 50 | "description": "Here is where details should be added by the author", 51 | ``` 52 | 53 | You will also notice the `"last-modified"` timestamp has been updated. 54 | 55 | 56 | Congrats! You've successfully authored a change by modifying a Markdown file and letting trestle-bot sync those changes back to the OSCAL content. 57 | 58 | -------------------------------------------------------------------------------- /docs/tutorials/sync-cac-content.md: -------------------------------------------------------------------------------- 1 | # The trestlebot command line sync-cac-content Tutorial 2 | 3 | This tutorial provides how to use `trestlebot sync-cac-content` transform [Cac content](https://github.com/ComplianceAsCode/content) to OSCAL models. 4 | This command has two sub-commands `component-definition` and `profile` 5 | 6 | ## component-definition 7 | 8 | This command is to create OSCAL Component Definitions by transforming CaC content control files. 9 | 10 | The CLI performs the following transformations: 11 | 12 | - Populate CaC product information to OSCAL component title and description 13 | - Ensure OSCAL component control mappings are populated with rule and rule parameter data from CaC control files 14 | - Create a validation component from SSG rules to check mappings 15 | - Ensure OSCAL Component Definition implemented requirements are populated from control notes in the control file 16 | - Ensure implementation status of an implemented requirement in OSCAL Component Definitions are populated with the status from CaC control files 17 | 18 | ### 1. Prerequisites 19 | 20 | - Initialize the [trestlebot workspace](../tutorials/github.md#3-initialize-trestlebot-workspace). 21 | 22 | - Pull the [CacContent repository](https://github.com/ComplianceAsCode/content). 23 | 24 | ### 2. Run the CLI sync-cac-content component-definition 25 | ```shell 26 | poetry run trestlebot sync-cac-content component-definition \ 27 | --repo-path $trestlebot_workspace_directory \ 28 | --branch main \ 29 | --cac-content-root ~/content \ 30 | --cac-profile $high-rev-4 \ 31 | --oscal-profile $OSCAL-profile-name \ 32 | --committer-email test@redhat.com \ 33 | --committer-name tester \ 34 | --product $productname \ 35 | --dry-run \ 36 | --component-definition-type $type 37 | ``` 38 | 39 | For more details about these options and additional flags, you can use the `--help` flag: 40 | `poetry run trestlebot sync-cac-content component-definition --help` 41 | This will display a full list of available options and their descriptions. 42 | 43 | After running the CLI with the right options, you would successfully generate an OSCAL Component Definition under $trestlebot_workplace_directory/component-definitions/$product_name/$OSCAL-profile-name. 44 | 45 | ## profile 46 | 47 | This command is to generate OSCAL Profile according to content policy 48 | 49 | ### 1. Prerequisites 50 | 51 | - Initialize the [trestlebot workspace](../tutorials/github.md#3-initialize-trestlebot-workspace) if you do not have one. 52 | 53 | - Pull the [CacContent repository](https://github.com/ComplianceAsCode/content). 54 | 55 | ### 2. Run the CLI sync-cac-content profile 56 | ```shell 57 | poetry run trestlebot sync-cac-content profile \ 58 | --repo-path ~/trestlebot-workspace \ 59 | --dry-run \ 60 | --cac-content-root ~/content \ 61 | --product ocp4 \ 62 | --oscal-catalog nist_rev5_800_53 \ 63 | --cac-policy-id nist_ocp4 \ 64 | --committer-email test@redhat.com \ 65 | --committer-name test \ 66 | --branch main 67 | ``` 68 | 69 | For more details about these options and additional flags, you can use the `--help` flag: 70 | `poetry run trestlebot sync-cac-content profile --help` 71 | This will display a full list of available options and their descriptions. 72 | 73 | After running the CLI with the right options, you would successfully generate an OSCAL Profile under $trestlebot_workplace_directory/profiles. 74 | -------------------------------------------------------------------------------- /docs/tutorials/sync-oscal-content.md: -------------------------------------------------------------------------------- 1 | # The trestlebot command line sync-oscal-content Tutorial 2 | 3 | This tutorial provides how to use `trestlebot sync-oscal-content` sync OSCAL models to [CaC content](https://github.com/ComplianceAsCode/content). 4 | 5 | Currently, this command has two sub-command: `component-definition` and `profile` 6 | 7 | ## component-definition 8 | 9 | This command is to sync OSCAL Component Definition information to CaC content side. 10 | 11 | The CLI performs the following sync: 12 | 13 | - Sync OSCAL component definition parameters/rules changes to CaC content profile file 14 | - Sync OSCAL component definition parameters/rules changes to CaC content control file 15 | - Add a hint comment to the control file when a missing rule is found in the CaC content repo. 16 | - Sync OSCAL component definition control status changes to CaC content control file. Since status mapping between 17 | cac and OSCAL is many-to-many relationship, if status can not be determined when sync, then add a comment to let user 18 | decide. Discussion detail in [doc](https://github.com/complytime/trestle-bot/discussions/511) 19 | - Add new option to cac var file when found variable exists but missing the option we sync. 20 | - Sync OSCAL component definition statements field to CaC control notes field 21 | 22 | ### 1. Prerequisites 23 | 24 | - Initialize the [trestlebot workspace](../tutorials/github.md#3-initialize-trestlebot-workspace). 25 | 26 | - Pull the [CaC Content repository](https://github.com/ComplianceAsCode/content). 27 | 28 | - Has an OSCAL Component Definition file, (transformed from CaC content using `sync-cac-content component-definition` cmd) 29 | 30 | ### 2. Run the CLI sync-oscal-content component-definition 31 | ```shell 32 | poetry run trestlebot sync-oscal-content component-definition \ 33 | --branch main \ 34 | --cac-content-root $cac-content-dir \ 35 | --committer-name test \ 36 | --committer-email test@redhat.com \ 37 | --dry-run \ 38 | --repo-path $trestlebot-workspace-dir \ 39 | --product $product-name \ 40 | --oscal-profile $oscal-profile-name 41 | ``` 42 | 43 | For more details about these options and additional flags, you can use the --help flag: 44 | `poetry run trestlebot sync-oscal-content component-definition --help` 45 | This will display a full list of available options and their descriptions. 46 | 47 | 48 | ## profile 49 | 50 | This command is to sync OSCAL profile information to CaC content side. 51 | 52 | The CLI performs the following sync: 53 | 54 | - Sync OSCAL profile control levels change to CaC control files 55 | 56 | ### 1. Prerequisites 57 | 58 | - Initialize the [trestlebot workspace](../tutorials/github.md#3-initialize-trestlebot-workspace). 59 | 60 | - Pull the [CaC Content repository](https://github.com/ComplianceAsCode/content). 61 | 62 | - Have OSCAL profile file, (transformed from CaC content using `sync-cac-content profile` cmd) 63 | 64 | ### 2. Run the CLI sync-oscal-content profile 65 | ```shell 66 | poetry run trestlebot sync-oscal-content profile \ 67 | --dry-run \ 68 | --repo-path ~/trestlebot-workspace \ 69 | --committer-email test@redhat.com \ 70 | --committer-name test\ 71 | --branch main \ 72 | --cac-content-root ~/content \ 73 | --cac-policy-id cis_rhel8 \ 74 | --product rhel8 75 | ``` 76 | 77 | For more details about these options and additional flags, you can use the --help flag: 78 | `poetry run trestlebot sync-oscal-content profile --help` 79 | This will display a full list of available options and their descriptions. 80 | 81 | ## catalog 82 | 83 | This command is to sync OSCAL catalog information to CaC content side. 84 | 85 | The CLI performs the following sync: 86 | 87 | - Sync OSCAL catalog control parts field change to CaC control files control description field 88 | 89 | ### 1. Prerequisites 90 | 91 | - Initialize the [trestlebot workspace](../tutorials/github.md#3-initialize-trestlebot-workspace). 92 | 93 | - Pull the [CaC Content repository](https://github.com/ComplianceAsCode/content). 94 | 95 | - An OSCAL catalog file, (transformed from CaC content using `sync-cac-content catalog` cmd) 96 | 97 | ### 2. Run the CLI sync-oscal-content profile 98 | ```shell 99 | poetry run trestlebot sync-oscal-content catalog \ 100 | --cac-policy-id nist_ocp4 \ 101 | --cac-content-root ~/content \ 102 | --repo-path ~/trestlebot-workspace \ 103 | --committer-name test \ 104 | --committer-email test@redhat.com \ 105 | --branch main \ 106 | --dry-run 107 | ``` 108 | 109 | For more details about these options and additional flags, you can use the --help flag: 110 | `poetry run trestlebot sync-oscal-content catalog --help` 111 | This will display a full list of available options and their descriptions. -------------------------------------------------------------------------------- /docs/workflows/assemble_diagrams.md: -------------------------------------------------------------------------------- 1 | # Diagrams: Assemble 2 | 3 | ## Context 4 | 5 | ```mermaid 6 | graph LR 7 | User["User"] --> Assemble_Workflow["Assemble Workflow"] 8 | Assemble_Workflow --> Trestle_Bot["Trestle-Bot"] 9 | Trestle_Bot --> Branch["User's Git Branch"] 10 | ``` 11 | 12 | ## Container 13 | 14 | ```mermaid 15 | graph LR 16 | User["User"] --> GH_Action["GitHub Action"] 17 | GH_Action --> Trestle_Bot["Trestle-Bot"] 18 | Trestle_Bot --> Compliance_Trestle["Compliance-Trestle SDK"] 19 | Compliance_Trestle --> Git_Provider_API["Git Provider API"] 20 | Git_Provider_API --> Branch["User's Git Branch"] 21 | ``` -------------------------------------------------------------------------------- /docs/workflows/create_diagrams.md: -------------------------------------------------------------------------------- 1 | # Diagrams: Create Content 2 | 3 | ## Context 4 | 5 | ```mermaid 6 | graph LR 7 | User["User"] --> Workflow_Dispatch["Workflow Dispatch"] 8 | Workflow_Dispatch --> Trestle_Bot["Trestle-Bot"] 9 | Trestle_Bot --> New_Branch["New Branch"] 10 | New_Branch --> PR["Draft Pull Request"] 11 | 12 | ``` 13 | 14 | ## Container 15 | 16 | ```mermaid 17 | graph LR 18 | User["User"] --> GH_Action["GitHub Action"] 19 | GH_Action --> Trestle_Bot["Trestle-Bot"] 20 | Trestle_Bot --> Compliance_Trestle["Compliance-Trestle SDK"] 21 | Compliance_Trestle --> Git_Provider_API["Git Provider API"] 22 | Git_Provider_API --> Branch["New Branch"] 23 | Branch --> PR["Draft Pull Request"] 24 | ``` -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: trestle-bot 2 | site_description: Documentation for trestle-bot. 3 | repo_name: trestle-bot 4 | repo_url: https://github.com/RedHatProductSecurity/trestle-bot/ 5 | 6 | theme: 7 | name: material 8 | language: en 9 | features: 10 | - content.code.annotation 11 | - content.code.copy 12 | - content.tabs.link 13 | - navigation.sections 14 | - navigation.tabs 15 | - navigation.top 16 | - search.highlight 17 | - search.suggest 18 | - toc.integrate 19 | palette: 20 | - scheme: default 21 | toggle: 22 | icon: material/toggle-switch-off-outline 23 | name: Switch to dark mode 24 | - scheme: slate 25 | toggle: 26 | icon: material/toggle-switch 27 | name: Switch to light mode 28 | 29 | markdown_extensions: 30 | - markdown_include.include 31 | - md_in_html 32 | - toc: 33 | toc_depth: 2 34 | - pymdownx.superfences: 35 | custom_fences: 36 | - name: mermaid 37 | class: mermaid 38 | format: !!python/name:pymdownx.superfences.fence_code_format 39 | 40 | copyright: | 41 | © Copyright 2023 Red Hat, Inc. 42 | 43 | nav: 44 | - Overview: 45 | - QuickStart: index.md 46 | - Architecture: 47 | - Diagrams: architecture/diagrams/c4.md 48 | - Tutorials: 49 | - GitHub: tutorials/github.md 50 | - Troubleshooting: troubleshooting.md 51 | - Contributing: contributing.md 52 | 53 | 54 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ['poetry-core>=1.2.0', 'wheel',] 3 | build-backend = 'poetry.core.masonry.api' 4 | 5 | 6 | [tool.poetry] 7 | name = 'trestlebot' 8 | version = "0.13.0" 9 | description = "trestle-bot assists users in leveraging Compliance-Trestle in automated workflows of for OSCAL formatted compliance content management." 10 | 11 | authors = ["Jennifer Power ",] 12 | 13 | include = ['LICENSE'] 14 | exclude = ['tests/', 'docs/'] 15 | license = 'Apache-2.0' 16 | readme = 'README.md' 17 | 18 | repository = 'https://github.com/RedHatProductSecurity/trestle-bot' 19 | 20 | 21 | [tool.poetry.scripts] 22 | trestlebot = "trestlebot.cli.root:root_cmd" 23 | 24 | [tool.poetry.dependencies] 25 | python = '^3.8.1' 26 | gitpython = "^3.1.41" 27 | compliance-trestle = "^3.8.1" 28 | github3-py = "^4.0.1" 29 | python-gitlab = "^4.2.0" 30 | ruamel-yaml = "^0.18.5" 31 | pydantic = "^2.0.0" 32 | ssg = {git = "https://github.com/ComplianceasCode/content"} 33 | 34 | [tool.poetry.group.dev] 35 | optional = true 36 | 37 | [tool.poetry.group.dev.dependencies] 38 | flake8 = "^7.1.2" 39 | black = "^24.3.0" 40 | mypy = "^1.11.0" 41 | isort = "^5.13.2" 42 | safety = "^3.2.8" 43 | flake8-print = "^5.0.0" 44 | pre-commit = "^3.4.0" 45 | mkdocs-material = "^9.6.12" 46 | markdown-include = "^0.8.1" 47 | types-pyyaml = "^6.0.12.20240917" 48 | 49 | [tool.poetry.group.tests] 50 | optional = true 51 | 52 | [tool.poetry.group.tests.dependencies] 53 | pytest = "^8.3.5" 54 | pytest-cov = "^5.0.0" 55 | pytest-skip-slow = "^0.0.5" 56 | responses = "^0.25.7" 57 | 58 | [tool.poetry.group.plugins] 59 | optional = true 60 | 61 | [tool.poetry.group.plugins.dependencies] 62 | compliance-trestle-fedramp = "^0.4.0" 63 | 64 | [tool.coverage.run] 65 | branch = true 66 | relative_files = true 67 | omit = [ 68 | 'tests/*', 69 | ] 70 | 71 | [tool.pytest.ini_options] 72 | minversion = '6.0' 73 | addopts = """ 74 | --doctest-modules \ 75 | --cov=./ \ 76 | --cov-append \ 77 | --cov-report html:tests/reports/coverage-html \ 78 | --cov-report xml:tests/reports/coverage.xml \ 79 | --ignore=docs/ 80 | """ 81 | testpaths = [ 82 | 'tests', 83 | ] 84 | 85 | [tool.mypy] 86 | 87 | plugins = [ 88 | "pydantic.mypy" 89 | ] 90 | 91 | follow_imports = "skip" 92 | warn_redundant_casts = true 93 | disallow_any_generics = true 94 | check_untyped_defs = true 95 | no_implicit_reexport = true 96 | disallow_untyped_defs = true 97 | 98 | [tool.pydantic-mypy] 99 | init_forbid_extra = true 100 | init_typed = true 101 | warn_required_dynamic_aliases = true 102 | 103 | [[tool.mypy.overrides]] 104 | module = "github3.*" 105 | ignore_missing_imports = true 106 | 107 | [[tool.mypy.overrides]] 108 | module = "ruamel" 109 | ignore_missing_imports = true 110 | 111 | [[tool.mypy.overrides]] 112 | module = "responses" 113 | ignore_missing_imports = true 114 | 115 | [[tool.mypy.overrides]] 116 | module = "ssg.*" 117 | ignore_missing_imports = true 118 | -------------------------------------------------------------------------------- /release-please-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "packages": { 3 | ".": { 4 | "release-type": "python", 5 | "include-v-in-tag": true, 6 | "bump-minor-pre-major": true, 7 | "draft": true, 8 | "changelog-path": "CHANGELOG.md", 9 | "changelog-sections": [ 10 | { 11 | "type": "feat", 12 | "section": "Features", 13 | "hidden": false 14 | }, 15 | { 16 | "type": "fix", 17 | "section": "Bug Fixes", 18 | "hidden": false 19 | }, 20 | { 21 | "type": "chore", 22 | "section": "Maintenance", 23 | "hidden": false 24 | }, 25 | { 26 | "ci": "ci", 27 | "section": "Infrastructure", 28 | "hidden": false 29 | } 30 | ] 31 | } 32 | } 33 | } -------------------------------------------------------------------------------- /scripts/get_product_controls.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | # Copyright (c) 2025 Red Hat, Inc. 3 | import os 4 | import sys 5 | from typing import List 6 | from ssg.profiles import _load_yaml_profile_file, get_profiles_from_products 7 | 8 | import logging 9 | 10 | """ 11 | Description: 12 | This module is designed to get all the available controls for each product. 13 | 14 | It is particularly useful to determine which controls will impact the product. 15 | We could according to the available controls to update 16 | the related OSCAL profiles. 17 | 18 | The output is a set of controls, 19 | How to run it: 20 | python get_product_controls.py '' ''" 21 | E.g., $python get_product_controls.py "rhel8" "/path/of/cac-content" 22 | Output Format: 23 | The module produces an output dictionary with the following structure: 24 | 25 | {'pcidss_4', 'anssi', 'cis_rhel8'} 26 | """ 27 | 28 | logging.basicConfig(level=logging.INFO, format="%(message)s") 29 | 30 | 31 | def get_profile_controls(cac_profile) -> List: 32 | """Get the policy and levels""" 33 | profile_yaml = _load_yaml_profile_file(cac_profile) 34 | policy_ids = [] 35 | # Get the selections from the profile 36 | selections = profile_yaml.get("selections", []) 37 | # Process each selected policy 38 | for selected in selections: 39 | # Split the selected item into parts based on ":" 40 | if ":" in selected: 41 | parts = selected.split(":") 42 | policy_id = parts[0] # The policy ID is the first part 43 | if policy_id is not None: 44 | policy_ids.append(policy_id) 45 | return policy_ids 46 | 47 | 48 | def main(product, content_root_dir): 49 | profiles = get_profiles_from_products(content_root_dir, [f"{product}"], sorted=True) 50 | policy_ids = [] 51 | for profile in profiles: 52 | profile_name = profile.profile_id + ".profile" 53 | cac_profile = os.path.join( 54 | content_root_dir, 55 | "products", 56 | product, 57 | "profiles", 58 | profile_name 59 | ) 60 | policy_id = get_profile_controls(cac_profile) 61 | policy_ids.extend(policy_id) 62 | policy_ids = set(policy_ids) 63 | 64 | logging.info(" ".join(policy_ids)) 65 | 66 | 67 | if __name__ == "__main__": 68 | # Ensure that the script is run with the correct number of arguments 69 | if len(sys.argv) != 3: 70 | logging.warning("Usage: \ 71 | python get_product_controls.py '' ''") 72 | sys.exit(1) 73 | try: 74 | # Extract arguments 75 | product = sys.argv[1] # First argument is product 76 | content_root_dir = sys.argv[2] # Second argument is content_root_dir 77 | # Call the main function 78 | main(product, content_root_dir) 79 | except Exception as e: 80 | logging.error(f"An error occurred: {e}") 81 | sys.exit(1) 82 | -------------------------------------------------------------------------------- /scripts/get_rule_impacted_files.py: -------------------------------------------------------------------------------- 1 | import os 2 | import logging 3 | import sys 4 | from typing import List 5 | 6 | """ 7 | Description: 8 | This module is designed to get the controls and profiles which are impacted by 9 | the rule. It is particularly useful to determine how to sync the updated rule 10 | to OSCAL component-definition. 11 | 12 | The output is a set of controls or profiles which is easy used in shell. 13 | How to run it: 14 | $python get_rule_impacted_files.py "rhel8" "cac-content_root" "$rule" "control" 15 | Output Format: 16 | "cis_sle15 stig_ol9 cis_rhel10 cis_rhel8 ccn_ol9 ccn_rhel9" 17 | """ 18 | 19 | logging.basicConfig(level=logging.INFO, format="%(message)s") 20 | 21 | 22 | def find_files_with_string(directory, search_string) -> List: 23 | matching_files = [] 24 | # Walk through the directory and its subdirectories 25 | for dirpath, _, filenames in os.walk(directory): 26 | for filename in filenames: 27 | file_path = os.path.join(dirpath, filename) 28 | # Open each file and search for the string 29 | try: 30 | with open(file_path, 'r', encoding='utf-8') as file: 31 | content = file.read() 32 | if search_string in content: 33 | matching_files.append(file_path) 34 | except Exception as e: 35 | logging.error(f"An error occurred: {e}") 36 | continue 37 | items = [] 38 | for file in matching_files: 39 | filename = file.split('/')[-1] 40 | name = filename.split('.')[0] 41 | items.append(name) 42 | return items 43 | 44 | 45 | def main(product, content_root_dir, search_rule, file_type="control"): 46 | if file_type == "control": 47 | directory = f"{content_root_dir}/controls/" 48 | files = find_files_with_string(directory, search_rule) 49 | exclude_string = "SRG-" 50 | files = [file for file in files if exclude_string not in file] 51 | for i in range(1, len(files)): 52 | if "section-" in files[i]: 53 | files[i] = "cis_ocp_1_4_0" 54 | else: 55 | directory = f"{content_root_dir}/products/{product}/profiles" 56 | files = find_files_with_string(directory, search_rule) 57 | files = set(files) 58 | logging.info(" ".join(files)) 59 | 60 | 61 | if __name__ == "__main__": 62 | # Ensure that the script is run with the correct number of arguments 63 | if len(sys.argv) != 5: 64 | USAGE_MESSAGE = """ 65 | Usage: python get_rule_impacted_files.py '' \ 66 | '' '' ''" 67 | """ 68 | logging.warning(USAGE_MESSAGE) 69 | sys.exit(1) 70 | try: 71 | # Extract arguments 72 | product = sys.argv[1] # First argument is product 73 | content_root_dir = sys.argv[2] # Second argument is content_root_dir 74 | search_rule = sys.argv[3] # Third argument is the updated rule 75 | file_type = sys.argv[4] # Fourth argument is the control flag 76 | # Call the main function 77 | main(product, content_root_dir, search_rule, file_type) 78 | except Exception as e: 79 | logging.error(f"An error occurred: {e}") 80 | sys.exit(1) 81 | -------------------------------------------------------------------------------- /sonar-project.properties: -------------------------------------------------------------------------------- 1 | sonar.projectKey=rh-psce_trestle-bot 2 | sonar.organization=rh-psce 3 | 4 | # This is the name and version displayed in the SonarCloud UI. 5 | #sonar.projectName=trestle-bot 6 | #sonar.projectVersion=1.0 7 | 8 | 9 | # Path is relative to the sonar-project.properties file. Replace "\" by "/" on Windows. 10 | #sonar.sources=. 11 | 12 | # Encoding of the source code. Default is default system encoding 13 | #sonar.sourceEncoding=UTF-8 -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | # Copyright (c) 2023 Red Hat, Inc. 3 | 4 | """Test package.""" 5 | -------------------------------------------------------------------------------- /tests/data/content_dir/controls/1234-example.yml: -------------------------------------------------------------------------------- 1 | --- 2 | policy: 1234 Benchmark for securing systems with levels 3 | title: 1234 Benchmark for securing systems with levels 4 | id: 1234-levels 5 | version: 1.2.3 6 | source: https://www.abcd.com/linux.pdf 7 | levels: 8 | - id: low 9 | - id: medium 10 | inherits_from: 11 | - low 12 | - id: high 13 | inherits_from: 14 | - medium 15 | 16 | controls: 17 | - id: AC-1 18 | title: User session timeout 19 | 20 | - id: AC-2 21 | levels: 22 | - low 23 | rules: 24 | - var_password_pam_minlen=1 25 | 26 | - id: AC-2(3) 27 | levels: 28 | - medium 29 | 30 | - id: AC-2(5) 31 | levels: 32 | - high 33 | -------------------------------------------------------------------------------- /tests/data/content_dir/controls/abcd-levels.yml: -------------------------------------------------------------------------------- 1 | --- 2 | policy: ABCD Benchmark for securing Linux systems with levels 3 | title: ABCD Benchmark for securing Linux systems with levels 4 | id: abcd-levels 5 | version: 1.2.3 6 | source: https://www.abcd.com/linux.pdf 7 | levels: 8 | - id: low 9 | - id: medium 10 | inherits_from: 11 | - low 12 | - id: high 13 | inherits_from: 14 | - medium 15 | 16 | controls: 17 | - id: S1 18 | title: User session timeout 19 | 20 | - id: S2 21 | levels: 22 | - low 23 | rules: 24 | - var_password_pam_minlen=1 25 | 26 | - id: S3 27 | levels: 28 | - medium 29 | 30 | - id: S4 31 | title: Configure authentication 32 | controls: 33 | - id: S4.a 34 | title: Disable administrator accounts 35 | levels: 36 | - low 37 | 38 | - id: S4.b 39 | title: Enforce password quality standards 40 | levels: 41 | - high 42 | rules: 43 | - var_password_pam_minlen=2 44 | 45 | # S5, S6 and S7 are used to test if level inheritance is working correctly 46 | # when multiple levels select the same rule 47 | - id: S5 48 | title: Default Crypto Policy 49 | levels: 50 | - low 51 | rules: 52 | - configure_crypto_policy 53 | - var_system_crypto_policy=default_policy 54 | 55 | - id: S6 56 | title: FIPS Crypto Policy 57 | levels: 58 | - medium 59 | rules: 60 | - configure_crypto_policy 61 | - var_system_crypto_policy=fips 62 | 63 | - id: S7 64 | title: Future Crypto Policy 65 | levels: 66 | - high 67 | rules: 68 | - configure_crypto_policy 69 | - var_system_crypto_policy=future 70 | 71 | - id: AC-1 72 | status: automated 73 | notes: |- 74 | Section a: AC-1(a) is an organizational control outside the scope of OpenShift configuration. 75 | 76 | Section b: AC-1(b) is an organizational control outside the scope of OpenShift configuration. 77 | rules: [] 78 | title: >- 79 | AC-1 - ACCESS CONTROL POLICY AND PROCEDURES 80 | levels: 81 | - low 82 | 83 | - id: AC-2 84 | status: manual 85 | levels: 86 | - medium 87 | -------------------------------------------------------------------------------- /tests/data/content_dir/linux_os/guide/benchmark.yml: -------------------------------------------------------------------------------- 1 | --- 2 | documentation_complete: true 3 | 4 | title: Guide to the Secure Configuration of {{{ full_name }}} 5 | 6 | status: draft 7 | 8 | description: | 9 | This guide presents a catalog of security-relevant configuration settings for {{{ full_name }}}. 10 | It is a rendering of content structured in the eXtensible Configuration Checklist Description Format (XCCDF) 11 | in order to support security automation. The SCAP content is available in the scap-security-guide 12 | package which is developed at {{{ weblink(link="https://www.open-scap.org/security-policies/scap-security-guide") }}}. 13 | 14 | notice: 15 | id: terms_of_use 16 | description: | 17 | Do not attempt to implement any of the settings in this guide without first testing them 18 | in a non-operational environment. The creators of this guidance assume no responsibility 19 | whatsoever for its use by other parties, and makes no guarantees, expressed or implied, 20 | about its quality, reliability, or any other characteristic. 21 | 22 | front-matter: | 23 | The SCAP Security Guide Project
24 | {{{ weblink(link="https://www.open-scap.org/security-policies/scap-security-guide") }}} 25 | 26 | rear-matter: | 27 | Red Hat and Red Hat Enterprise Linux are either registered trademarks or trademarks of 28 | Red Hat, Inc. in the United States and other countries. All other names are registered 29 | trademarks or trademarks of their respective companies. 30 | -------------------------------------------------------------------------------- /tests/data/content_dir/linux_os/guide/test/configure_crypto_policy/rule.yml: -------------------------------------------------------------------------------- 1 | documentation_complete: true 2 | 3 | 4 | title: 'Test Configure System Cryptography Policy' 5 | 6 | description: |- 7 | This is a modified copy from original rule.yml. For testing purposes only. 8 | To configure the system cryptography policy to use ciphers only from the {{{ xccdf_value("var_system_crypto_policy") }}} 9 | 10 | rationale: |- 11 | Centralized cryptographic policies simplify applying secure ciphers across an operating 12 | system and the applications that run on that operating system. Use of weak or untested 13 | encryption algorithms undermines the purposes of utilizing encryption to protect data. 14 | 15 | severity: high 16 | 17 | identifiers: 18 | cce@rhcos4: CCE-82541-4 19 | cce@rhel8: CCE-80935-0 20 | cce@rhel9: CCE-83450-7 21 | cce@rhel10: CCE-89085-5 22 | cce@sle15: CCE-85776-3 23 | 24 | references: 25 | disa: CCI-000068,CCI-003123,CCI-002450,CCI-000877,CCI-002418,CCI-001453,CCI-002890 26 | hipaa: 164.308(a)(4)(i),164.308(b)(1),164.308(b)(3),164.312(e)(1),164.312(e)(2)(ii) 27 | nist: AC-17(a),AC-17(2),CM-6(a),MA-4(6),SC-13,SC-12(2),SC-12(3) 28 | ospp: FCS_COP.1(1),FCS_COP.1(2),FCS_COP.1(3),FCS_COP.1(4),FCS_CKM.1,FCS_CKM.2,FCS_TLSC_EXT.1 29 | srg: SRG-OS-000396-GPOS-00176,SRG-OS-000393-GPOS-00173,SRG-OS-000394-GPOS-00174 30 | stigid@ol8: OL08-00-010020 31 | stigid@rhel8: RHEL-08-010020 32 | 33 | ocil_clause: 'cryptographic policy is not configured or is configured incorrectly' 34 | 35 | ocil: |- 36 | To verify that cryptography policy has been configured correctly, run the following command: 37 |
$ update-crypto-policies --show
38 | The output should return
{{{ xccdf_value("var_system_crypto_policy") }}}
. 39 | 40 | warnings: 41 | - general: |- 42 | The system needs to be rebooted for these changes to take effect. 43 | 44 | fixtext: |- 45 | Configure {{{ full_name }}} to use system cryptography policy. 46 | Run the following command: 47 | 48 | $ sudo update-crypto-policies --set {{{ xccdf_value("var_system_crypto_policy") }}} 49 | 50 | srg_requirement: '{{{ full_name }}} must use {{{ xccdf_value("var_system_crypto_policy") }}} for the system cryptography policy.' 51 | -------------------------------------------------------------------------------- /tests/data/content_dir/linux_os/guide/test/file_groupownership_sshd_private_key/rule.yml: -------------------------------------------------------------------------------- 1 | documentation_complete: true 2 | 3 | title: 'Test Verify Group Ownership on SSH Server Private *_key Key Files' 4 | 5 | {{% set dedicated_ssh_groupname = groups.get("dedicated_ssh_keyowner", {}).get("name") %}} 6 | 7 | description: |- 8 | SSH server private keys, files that match the /etc/ssh/*_key glob, must be 9 | group-owned by {{{ dedicated_ssh_groupname if dedicated_ssh_groupname else 'root' }}} group. 10 | 11 | rationale: |- 12 | If an unauthorized user obtains the private SSH host key file, the host could be impersonated. 13 | 14 | severity: medium 15 | 16 | identifiers: 17 | cce@rhel8: CCE-86126-0 18 | cce@rhel9: CCE-86127-8 19 | cce@rhel10: CCE-90288-2 20 | 21 | ocil_clause: '{{{ ocil_clause_file_group_owner(file="/etc/ssh/*_key", group="root") }}}' 22 | 23 | ocil: |- 24 | {{{ ocil_file_group_owner(file="/etc/ssh/*_key", group="root") }}} 25 | 26 | template: 27 | name: file_groupowner 28 | vars: 29 | filepath: 30 | - /etc/ssh/ 31 | file_regex: 32 | - ^.*_key$ 33 | gid_or_name: '{{{ dedicated_ssh_groupname if dedicated_ssh_groupname else '0' }}}' 34 | 35 | warnings: 36 | - general: |- 37 | Remediation is not possible at bootable container build time because SSH host 38 | keys are generated post-deployment. 39 | -------------------------------------------------------------------------------- /tests/data/content_dir/linux_os/guide/test/group.yml: -------------------------------------------------------------------------------- 1 | documentation_complete: true 2 | 3 | title: Services 4 | 5 | description: |- 6 | The best protection against vulnerable software is running less software. This section 7 | describes how to review the software which {{{ full_name }}} installs on a system and disable 8 | software which is not needed. It then enumerates the software packages installed on a default 9 | {{{ full_name }}} system and provides guidance about which ones can be safely disabled. 10 | -------------------------------------------------------------------------------- /tests/data/content_dir/linux_os/guide/test/sshd_set_keepalive/rule.yml: -------------------------------------------------------------------------------- 1 | documentation_complete: true 2 | 3 | title: 'Test Set SSH Client Alive Count Max' 4 | 5 | description: |- 6 | The SSH server sends at most ClientAliveCountMax messages during a SSH session and 7 | waits for a response from the SSH client. The option ClientAliveInterval configures 8 | timeout after each ClientAliveCountMax message. 9 | 10 | rationale: |- 11 | This ensures a user login will be terminated as soon as the ClientAliveInterval 12 | is reached. 13 | 14 | severity: medium 15 | 16 | identifiers: 17 | cce@rhcos4: CCE-82464-9 18 | cce@rhel8: CCE-80907-9 19 | cce@rhel9: CCE-90805-3 20 | cce@rhel10: CCE-86794-5 21 | cce@sle12: CCE-83034-9 22 | cce@sle15: CCE-91228-7 23 | 24 | references: 25 | cis-csc: 1,12,13,14,15,16,18,3,5,7,8 26 | cis@sle15: 5.2.16 27 | cis@ubuntu2204: 5.2.22 28 | disa: CCI-001133,CCI-002361 29 | hipaa: 164.308(a)(4)(i),164.308(b)(1),164.308(b)(3),164.310(b),164.312(e)(1),164.312(e)(2)(ii) 30 | nist: AC-2(5),AC-12,AC-17(a),SC-10,CM-6(a) 31 | pcidss: Req-8.1.8 32 | srg: SRG-OS-000163-GPOS-00072,SRG-OS-000279-GPOS-00109 33 | stigid@ol8: OL08-00-010200 34 | stigid@rhel8: RHEL-08-010200 35 | stigid@sle15: SLES-15-010320 36 | stigid@ubuntu2204: UBTU-22-255030 37 | 38 | requires: 39 | - sshd_set_idle_timeout 40 | 41 | ocil_clause: 'it is commented out or not configured properly' 42 | 43 | ocil: |- 44 | To ensure ClientAliveInterval is set correctly, run the following command: 45 |
$ sudo grep ClientAliveCountMax /etc/ssh/sshd_config
46 | 47 | template: 48 | name: sshd_lineinfile 49 | vars: 50 | parameter: ClientAliveCountMax 51 | xccdf_variable: var_sshd_set_keepalive 52 | datatype: int 53 | -------------------------------------------------------------------------------- /tests/data/content_dir/linux_os/guide/test/var_password_pam_minlen.var: -------------------------------------------------------------------------------- 1 | documentation_complete: true 2 | 3 | title: minlen 4 | 5 | description: 'Minimum number of characters in password' 6 | 7 | type: number 8 | 9 | operator: equals 10 | 11 | interactive: false 12 | 13 | options: 14 | 10: 10 15 | 12: 12 16 | 14: 14 17 | 15: 15 18 | 17: 17 19 | 18: 18 20 | 20: 20 21 | 6: 6 22 | 7: 7 23 | 8: 8 24 | default: 15 25 | -------------------------------------------------------------------------------- /tests/data/content_dir/linux_os/guide/test/var_sshd_set_keepalive.var: -------------------------------------------------------------------------------- 1 | documentation_complete: true 2 | 3 | title: 'SSH Max Keep Alive Count' 4 | 5 | description: 'Specify the maximum number of idle message counts before session is terminated.' 6 | 7 | type: number 8 | 9 | operator: equals 10 | 11 | interactive: false 12 | 13 | options: 14 | 10: 10 15 | 3: 3 16 | 5: 5 17 | 0: 0 18 | 1: 1 19 | default: 0 20 | -------------------------------------------------------------------------------- /tests/data/content_dir/linux_os/guide/test/var_system_crypto_policy.var: -------------------------------------------------------------------------------- 1 | documentation_complete: true 2 | 3 | title: 'The system-provided crypto policies' 4 | 5 | description: |- 6 | Specify the crypto policy for the system. 7 | 8 | type: string 9 | 10 | operator: equals 11 | 12 | interactive: false 13 | 14 | options: 15 | default: DEFAULT 16 | default_policy: DEFAULT 17 | default_nosha1: "DEFAULT:NO-SHA1" 18 | fips: FIPS 19 | fips_ospp: "FIPS:OSPP" 20 | legacy: LEGACY 21 | future: FUTURE 22 | next: NEXT 23 | -------------------------------------------------------------------------------- /tests/data/content_dir/products/rhel8/product.yml: -------------------------------------------------------------------------------- 1 | product: rhel8 2 | full_name: Red Hat Enterprise Linux 8 3 | type: platform 4 | 5 | families: 6 | - rhel 7 | - rhel-like 8 | 9 | major_version_ordinal: 8 10 | 11 | benchmark_id: RHEL-8 12 | benchmark_root: "../../linux_os/guide" 13 | components_root: "../../components" 14 | 15 | profiles_root: "./profiles" 16 | 17 | pkg_manager: "yum" 18 | 19 | init_system: "systemd" 20 | 21 | # The fingerprints below are retrieved from https://access.redhat.com/security/team/key 22 | pkg_release: "4ae0493b" 23 | pkg_version: "fd431d51" 24 | aux_pkg_release: "5b32db75" 25 | aux_pkg_version: "d4082792" 26 | 27 | groups: 28 | dedicated_ssh_keyowner: 29 | name: ssh_keys 30 | 31 | faillock_path: "/var/log/faillock" 32 | 33 | cpes_root: "../../shared/applicability" 34 | cpes: 35 | - rhel8: 36 | name: "cpe:/o:redhat:enterprise_linux:8" 37 | title: "Red Hat Enterprise Linux 8" 38 | check_id: installed_OS_is_rhel8 39 | 40 | - rhel8.0: 41 | name: "cpe:/o:redhat:enterprise_linux:8.0" 42 | title: "Red Hat Enterprise Linux 8.0" 43 | check_id: installed_OS_is_rhel8_0 44 | 45 | - rhel8.1: 46 | name: "cpe:/o:redhat:enterprise_linux:8.1" 47 | title: "Red Hat Enterprise Linux 8.1" 48 | check_id: installed_OS_is_rhel8_1 49 | 50 | - rhel8.2: 51 | name: "cpe:/o:redhat:enterprise_linux:8.2" 52 | title: "Red Hat Enterprise Linux 8.2" 53 | check_id: installed_OS_is_rhel8_2 54 | 55 | - rhel8.3: 56 | name: "cpe:/o:redhat:enterprise_linux:8.3" 57 | title: "Red Hat Enterprise Linux 8.3" 58 | check_id: installed_OS_is_rhel8_3 59 | 60 | - rhel8.4: 61 | name: "cpe:/o:redhat:enterprise_linux:8.4" 62 | title: "Red Hat Enterprise Linux 8.4" 63 | check_id: installed_OS_is_rhel8_4 64 | 65 | - rhel8.5: 66 | name: "cpe:/o:redhat:enterprise_linux:8.5" 67 | title: "Red Hat Enterprise Linux 8.5" 68 | check_id: installed_OS_is_rhel8_5 69 | 70 | - rhel8.6: 71 | name: "cpe:/o:redhat:enterprise_linux:8.6" 72 | title: "Red Hat Enterprise Linux 8.6" 73 | check_id: installed_OS_is_rhel8_6 74 | 75 | - rhel8.7: 76 | name: "cpe:/o:redhat:enterprise_linux:8.7" 77 | title: "Red Hat Enterprise Linux 8.7" 78 | check_id: installed_OS_is_rhel8_7 79 | 80 | - rhel8.8: 81 | name: "cpe:/o:redhat:enterprise_linux:8.8" 82 | title: "Red Hat Enterprise Linux 8.8" 83 | check_id: installed_OS_is_rhel8_8 84 | 85 | - rhel8.9: 86 | name: "cpe:/o:redhat:enterprise_linux:8.9" 87 | title: "Red Hat Enterprise Linux 8.9" 88 | check_id: installed_OS_is_rhel8_9 89 | 90 | - rhel8.10: 91 | name: "cpe:/o:redhat:enterprise_linux:8.10" 92 | title: "Red Hat Enterprise Linux 8.10" 93 | check_id: installed_OS_is_rhel8_10 94 | 95 | # Mapping of CPE platform to package 96 | platform_package_overrides: 97 | login_defs: "shadow-utils" 98 | 99 | centos_pkg_release: "5ccc5b19" 100 | centos_pkg_version: "8483c65d" 101 | centos_major_version: "8" 102 | 103 | reference_uris: 104 | cis: 'https://www.cisecurity.org/benchmark/red_hat_linux/' 105 | 106 | journald_conf_dir_path: /etc/systemd/journald.conf.d 107 | -------------------------------------------------------------------------------- /tests/data/content_dir/products/rhel8/profiles/example.profile: -------------------------------------------------------------------------------- 1 | documentation_complete: true 2 | 3 | title: 'Sample Security Profile for Linux-like OSes' 4 | 5 | description: |- 6 | This profile is an sample for use in documentation and example content. 7 | The selected rules are standard and should pass quickly on most systems. 8 | 9 | selections: 10 | - abcd-levels:all:medium 11 | - file_groupownership_sshd_private_key 12 | - sshd_set_keepalive 13 | - var_sshd_set_keepalive=1 14 | -------------------------------------------------------------------------------- /tests/data/content_dir/shared/macros/test-macros.jinja: -------------------------------------------------------------------------------- 1 | {{# 2 | Create an XCCDF :code:`` element 3 | 4 | :param varname: The name of the variable to reference 5 | :type varname: str 6 | 7 | #}} 8 | {{% macro xccdf_value(varname) -%}} 9 | 10 | {{%- endmacro %}} 11 | 12 | 13 | {{# 14 | Creates an HTML :code:`` element for the given link and text. If no text is given the 15 | link will be the text 16 | 17 | :param link: The url the link should have 18 | :type link: str 19 | :param text: Optional, text for the link 20 | :type text: str 21 | 22 | #}} 23 | {{% macro weblink(link, text=none) -%}} 24 | {{% if text is not none -%}} 25 | {{{ text }}} 26 | {{%- else %}} 27 | {{{ link }}} 28 | {{%- endif %}} 29 | {{%- endmacro %}} 30 | 31 | 32 | {{# 33 | OCIL clause for file group owner 34 | 35 | :param file: File to change 36 | :type file: str 37 | :param group: the group owner for the file 38 | :type group: str 39 | 40 | #}} 41 | {{%- macro ocil_clause_file_group_owner(file, group) -%}} 42 | {{{ file }}} does not have a group owner of {{{ group }}} 43 | {{%- endmacro %}} 44 | 45 | 46 | {{# 47 | OCIL how to check the file group owner of a file. 48 | 49 | :param file: File to change 50 | :type file: str 51 | :param group: the group owner for the file 52 | :type group: str 53 | 54 | #}} 55 | {{%- macro ocil_file_group_owner(file, group) -%}} 56 | To check the group ownership of {{{ file }}}, 57 | {{% if product in ["ocp4", "rhcos4"] -%}} 58 | you'll need to log into a node in the cluster. 59 | {{{ rhcos_node_login_instructions() }}} 60 | Then, 61 | {{%- endif -%}} 62 | run the command: 63 |
$ ls -lL {{{ file }}}
64 | If properly configured, the output should indicate the following group-owner: 65 | {{{ group }}} 66 | {{%- endmacro %}} 67 | -------------------------------------------------------------------------------- /tests/data/json/invalid_test_ssp_index.json: -------------------------------------------------------------------------------- 1 | { 2 | "ssp-name": 3 | { 4 | "profile": "profile" 5 | } 6 | } -------------------------------------------------------------------------------- /tests/data/json/rhel8-abcd-levels-high.json: -------------------------------------------------------------------------------- 1 | { 2 | "profile": { 3 | "uuid": "54ea818f-2164-4524-b64d-9b664805571c", 4 | "metadata": { 5 | "title": "abcd-levels-high", 6 | "last-modified": "2025-04-15T17:17:47.842947+08:00", 7 | "version": "REPLACE_ME", 8 | "oscal-version": "1.1.3" 9 | }, 10 | "imports": [ 11 | { 12 | "href": "trestle://catalogs/simplified_nist_catalog/catalog.json", 13 | "include-controls": [ 14 | { 15 | "with-ids": [ 16 | "ac-1", 17 | "ac-2" 18 | ] 19 | } 20 | ] 21 | } 22 | ], 23 | "merge": { 24 | "combine": { 25 | "method": "merge" 26 | }, 27 | "as-is": true 28 | } 29 | } 30 | } -------------------------------------------------------------------------------- /tests/data/json/rhel8-abcd-levels-low.json: -------------------------------------------------------------------------------- 1 | { 2 | "profile": { 3 | "uuid": "b3597c30-5c16-4759-986b-33dc2ecd4691", 4 | "metadata": { 5 | "title": "abcd-levels-low", 6 | "last-modified": "2025-04-15T17:17:47.843987+08:00", 7 | "version": "REPLACE_ME", 8 | "oscal-version": "1.1.3" 9 | }, 10 | "imports": [ 11 | { 12 | "href": "trestle://catalogs/simplified_nist_catalog/catalog.json", 13 | "include-controls": [ 14 | { 15 | "with-ids": [ 16 | ] 17 | } 18 | ] 19 | } 20 | ], 21 | "merge": { 22 | "combine": { 23 | "method": "merge" 24 | }, 25 | "as-is": true 26 | } 27 | } 28 | } -------------------------------------------------------------------------------- /tests/data/json/rhel8-abcd-levels-medium.json: -------------------------------------------------------------------------------- 1 | { 2 | "profile": { 3 | "uuid": "5808a186-d425-43dd-9dbe-e4e6efb5c6cb", 4 | "metadata": { 5 | "title": "abcd-levels-medium", 6 | "last-modified": "2025-04-15T17:17:47.843580+08:00", 7 | "version": "REPLACE_ME", 8 | "oscal-version": "1.1.3" 9 | }, 10 | "imports": [ 11 | { 12 | "href": "trestle://catalogs/simplified_nist_catalog/catalog.json", 13 | "include-controls": [ 14 | { 15 | "with-ids": [ 16 | ] 17 | } 18 | ] 19 | } 20 | ], 21 | "merge": { 22 | "combine": { 23 | "method": "merge" 24 | }, 25 | "as-is": true 26 | } 27 | } 28 | } -------------------------------------------------------------------------------- /tests/data/json/simplified_filter_profile.json: -------------------------------------------------------------------------------- 1 | { 2 | "profile": { 3 | "uuid": "1019f424-1556-4aa3-9df3-337b97c2c857", 4 | "metadata": { 5 | "title": "Simple profile for filtering", 6 | "last-modified": "2021-06-08T13:57:34.337491-04:00", 7 | "version": "Final", 8 | "oscal-version": "1.0.0", 9 | "roles": [ 10 | { 11 | "id": "creator", 12 | "title": "Document Creator" 13 | }, 14 | { 15 | "id": "contact", 16 | "title": "Contact" 17 | } 18 | ], 19 | "parties": [ 20 | { 21 | "uuid": "cde369ce-57f8-4ec1-847f-2681a9a881e7", 22 | "type": "organization", 23 | "name": "Joint Task Force, Transformation Initiative", 24 | "email-addresses": [ 25 | "sec-cert@nist.gov" 26 | ], 27 | "addresses": [ 28 | { 29 | "addr-lines": [ 30 | "National Institute of Standards and Technology", 31 | "Attn: Computer Security Division", 32 | "Information Technology Laboratory", 33 | "100 Bureau Drive (Mail Stop 8930)" 34 | ], 35 | "city": "Gaithersburg", 36 | "state": "MD", 37 | "postal-code": "20899-8930" 38 | } 39 | ] 40 | } 41 | ], 42 | "responsible-parties": [ 43 | { 44 | "role-id": "creator", 45 | "party-uuids": [ 46 | "cde369ce-57f8-4ec1-847f-2681a9a881e7" 47 | ] 48 | }, 49 | { 50 | "role-id": "contact", 51 | "party-uuids": [ 52 | "cde369ce-57f8-4ec1-847f-2681a9a881e7" 53 | ] 54 | } 55 | ] 56 | }, 57 | "imports": [ 58 | { 59 | "href": "trestle://catalogs/simplified_nist_catalog/catalog.json", 60 | "include-controls": [ 61 | { 62 | "with-ids": [ 63 | "ac-1", 64 | "ac-2", 65 | "ac-2.1", 66 | "ac-2.2", 67 | "ac-2.3", 68 | "ac-2.4", 69 | "ac-2.5" 70 | ] 71 | } 72 | ] 73 | } 74 | ], 75 | "merge": { 76 | "as-is": true 77 | }, 78 | "modify": {} 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /tests/data/json/simplified_nist_profile.json: -------------------------------------------------------------------------------- 1 | { 2 | "profile": { 3 | "uuid": "1019f424-1556-4aa3-9df3-337b97c2c856", 4 | "metadata": { 5 | "title": "NIST Special Publication 800-53 Revision 5 MODERATE IMPACT BASELINE", 6 | "last-modified": "2021-06-08T13:57:34.337491-04:00", 7 | "version": "Final", 8 | "oscal-version": "1.0.0", 9 | "roles": [ 10 | { 11 | "id": "creator", 12 | "title": "Document Creator" 13 | }, 14 | { 15 | "id": "contact", 16 | "title": "Contact" 17 | } 18 | ], 19 | "parties": [ 20 | { 21 | "uuid": "cde369ce-57f8-4ec1-847f-2681a9a881e7", 22 | "type": "organization", 23 | "name": "Joint Task Force, Transformation Initiative", 24 | "email-addresses": [ 25 | "sec-cert@nist.gov" 26 | ], 27 | "addresses": [ 28 | { 29 | "addr-lines": [ 30 | "National Institute of Standards and Technology", 31 | "Attn: Computer Security Division", 32 | "Information Technology Laboratory", 33 | "100 Bureau Drive (Mail Stop 8930)" 34 | ], 35 | "city": "Gaithersburg", 36 | "state": "MD", 37 | "postal-code": "20899-8930" 38 | } 39 | ] 40 | } 41 | ], 42 | "responsible-parties": [ 43 | { 44 | "role-id": "creator", 45 | "party-uuids": [ 46 | "cde369ce-57f8-4ec1-847f-2681a9a881e7" 47 | ] 48 | }, 49 | { 50 | "role-id": "contact", 51 | "party-uuids": [ 52 | "cde369ce-57f8-4ec1-847f-2681a9a881e7" 53 | ] 54 | } 55 | ] 56 | }, 57 | "imports": [ 58 | { 59 | "href": "trestle://catalogs/simplified_nist_catalog/catalog.json", 60 | "include-controls": [ 61 | { 62 | "with-ids": [ 63 | "ac-1", 64 | "ac-2", 65 | "ac-2.1", 66 | "ac-2.2", 67 | "ac-2.3", 68 | "ac-2.4", 69 | "ac-2.5", 70 | "ac-2.13", 71 | "ac-3", 72 | "ac-4", 73 | "ac-4.4", 74 | "ac-5" 75 | ] 76 | } 77 | ] 78 | } 79 | ], 80 | "merge": { 81 | "as-is": true 82 | }, 83 | "modify": { 84 | "set-parameters": [ 85 | { 86 | "param_id": "ac-1_prm_1", 87 | "class": "newclassfromprof", 88 | "depends-on": "newdependsonfromprof", 89 | "usage": "new usage from prof", 90 | "props": [ 91 | { 92 | "name": "param_1_prop", 93 | "value": "prop value from prof" 94 | }, 95 | { 96 | "name": "param_1_prop_2", 97 | "value": "new prop value from prof" 98 | } 99 | ], 100 | "links": [ 101 | { 102 | "href": "#123456789", 103 | "text": "new text from prof" 104 | }, 105 | { 106 | "href": "#new_link", 107 | "text": "new link text" 108 | } 109 | ], 110 | "constraints": [ 111 | { 112 | "description": "new constraint" 113 | } 114 | ], 115 | "guidelines": [ 116 | { 117 | "prose": "new guideline" 118 | } 119 | ] 120 | }, 121 | { 122 | "param_id": "ac-4.4_prm_3", 123 | "values": [ 124 | "hacking the system" 125 | ] 126 | }, 127 | { 128 | "param_id": "loose_2", 129 | "values": [ 130 | "loose_2_val_from_prof" 131 | ] 132 | }, 133 | { 134 | "param_id": "bad_param_id", 135 | "values": [ 136 | "this will cause warning" 137 | ] 138 | } 139 | ] 140 | } 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /tests/data/json/test_ssp_index.json: -------------------------------------------------------------------------------- 1 | { 2 | "ssp-name": 3 | { 4 | "profile": "profile", 5 | "component_definitions": [ 6 | "comp-a" 7 | ], 8 | "leveraged_ssp": "leveraged-ssp-name", 9 | "yaml_header_path": "ssp-name.yaml" 10 | } 11 | } -------------------------------------------------------------------------------- /tests/data/yaml/extra_yaml_header.yaml: -------------------------------------------------------------------------------- 1 | x-trestle-evidence: 2 | named-evidence: location 3 | x-trestle-dependent-on: 4 | - control-id: 5 | profile: 6 | reviewed-by: 7 | - named: 8 | date: -------------------------------------------------------------------------------- /tests/data/yaml/test_complete_rule.yaml: -------------------------------------------------------------------------------- 1 | x-trestle-rule-info: 2 | name: example_rule_1 3 | description: My rule description for example rule 1 4 | parameter: 5 | name: prm_1 6 | description: prm_1 description 7 | alternative-values: 8 | default: 5% 9 | 5pc: 5% 10 | 10pc: 10% 11 | 15pc: 15% 12 | 20pc: 20% 13 | default-value: 5% 14 | check: 15 | name: my_check 16 | description: My check description 17 | profile: 18 | description: Simple NIST Profile 19 | href: trestle://profiles/simplified_nist_profile/profile.json 20 | include-controls: 21 | - id: ac-1 22 | x-trestle-component-info: 23 | name: Component 1 24 | description: Component 1 description 25 | type: service 26 | -------------------------------------------------------------------------------- /tests/data/yaml/test_complete_rule_multiple_controls.yaml: -------------------------------------------------------------------------------- 1 | x-trestle-rule-info: 2 | name: example_rule_3 3 | description: My rule description for example rule 3 4 | parameter: 5 | name: prm_1 6 | description: prm_1 description 7 | alternative-values: 8 | default: 5% 9 | 5pc: 5% 10 | 10pc: 10% 11 | 20pc: 20% 12 | default-value: 5% 13 | check: 14 | name: my_check 15 | description: My check description 16 | profile: 17 | description: Simple NIST Profile 18 | href: trestle://profiles/simplified_nist_profile/profile.json 19 | include-controls: 20 | - id: ac-1 21 | - id: ac-1_smt.a 22 | x-trestle-component-info: 23 | name: Component 1 24 | description: Component 1 description 25 | type: service 26 | -------------------------------------------------------------------------------- /tests/data/yaml/test_complete_rule_no_params.yaml: -------------------------------------------------------------------------------- 1 | x-trestle-rule-info: 2 | name: example_rule_2 3 | description: My rule description for example rule 2 4 | profile: 5 | description: Simple NIST Profile 6 | href: trestle://profiles/simplified_nist_profile/profile.json 7 | include-controls: 8 | - id: ac-1 9 | x-trestle-component-info: 10 | name: Component 2 11 | description: Component 2 description 12 | type: service -------------------------------------------------------------------------------- /tests/data/yaml/test_incomplete_rule.yaml: -------------------------------------------------------------------------------- 1 | x-trestle-rule-info: 2 | description: My rule description for example rule 1 3 | parameter: 4 | name: prm_1 5 | description: prm_1 description 6 | alternative-values: 7 | default: 5% 8 | 5pc: 5% 9 | 10pc: 10% 10 | 20pc: 20% 11 | default-value: 5% 12 | profile: 13 | description: Simple NIST Profile 14 | href: profiles/simplified_nist_profile/profile.json 15 | include-controls: 16 | - id: ac-2 17 | x-trestle-component-info: 18 | name: Component 1 19 | description: Component 1 description 20 | type: service 21 | -------------------------------------------------------------------------------- /tests/data/yaml/test_invalid_rule.yaml: -------------------------------------------------------------------------------- 1 | x-trestle-rule-info: 2 | name: example_rule_1 3 | description: My rule description for example rule 1 4 | parameter: 5 | name: prm_1 6 | description: prm_1 description 7 | alternative-values: "invalid" 8 | default-value: true 9 | profile: 10 | description: Simple NIST Profile 11 | href: profiles/simplified_nist_profile/profile.json 12 | include-controls: 13 | - id: ac-2 14 | x-trestle-component-info: 15 | name: Component 1 16 | description: Component 1 description 17 | type: service 18 | -------------------------------------------------------------------------------- /tests/data/yaml/test_rule_invalid_params.yaml: -------------------------------------------------------------------------------- 1 | x-trestle-rule-info: 2 | name: example_rule_1 3 | description: My rule description for example rule 1 4 | parameter: 5 | name: prm_1 6 | description: prm_1 description 7 | alternative-values: 8 | default: 10% 9 | 10pc: 10% 10 | 20pc: 20% 11 | default-value: 5% 12 | profile: 13 | href: profiles/simplified_nist_profile/profile.json 14 | include-controls: 15 | - id: ac-2 16 | x-trestle-component-info: 17 | name: Component 1 18 | description: Component 1 description 19 | type: service 20 | -------------------------------------------------------------------------------- /tests/e2e/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM docker.io/wiremock/wiremock:3.2.0-2 2 | 3 | COPY mappings/ /home/wiremock/mappings/ 4 | 5 | USER 1001 -------------------------------------------------------------------------------- /tests/e2e/README.md: -------------------------------------------------------------------------------- 1 | # End-to-End Testing 2 | 3 | End-to-end tests are used to verify the CLI functionality of trestle-bot from a user's perspective, running in a containerized environment. 4 | 5 | ## Prerequisites 6 | 7 | Before running the end-to-end tests, ensure you have the following prerequisites installed: 8 | 9 | - [Podman](https://podman.io/docs/installation) - Container management tool 10 | - [Python 3](https://www.python.org/downloads/) - Required for test automation 11 | - [Poetry](https://python-poetry.org/docs/#installation) - Dependency management 12 | 13 | ## Resources 14 | 15 | - **`mappings`**: This directory contains JSON mappings used with WireMock to mock the Git server endpoints. 16 | - **`play-kube.yml`**: This file includes Kubernetes resources for deploying the mock API server in a pod. 17 | - **`Dockerfile`**: The Dockerfile used to build the mock server container image. 18 | 19 | ## Running the Tests 20 | 21 | To run the end-to-end tests, follow these steps: 22 | 23 | 1. Clone the project repository: 24 | 25 | ```bash 26 | git clone https://github.com/RedHatProductSecurity/trestle-bot.git 27 | cd trestle-bot 28 | ``` 29 | 30 | 2. Install the project dependencies: 31 | 32 | ```bash 33 | poetry install --without dev --no-root 34 | ``` 35 | 36 | 3. Run the tests: 37 | 38 | ```bash 39 | make test-e2e 40 | ``` 41 | 42 | > **Note:** This should always be run from the root of the project directory. 43 | 44 | ## Additional Notes 45 | - The WireMock tool is used to mock Git server endpoints for testing. 46 | - Podman is used for container and pod management and to build the container image for the mock API server. 47 | - If the images are not already built, the `make test-e2e` command will build them automatically and remove them at the end of the test. If not, you can build them manually with the following command from the root of the project directory: 48 | 49 | ```bash 50 | podman build -t localhost/mock-server:latest -f tests/e2e/Dockerfile tests/e2e 51 | podman build -t localhost/trestlebot:latest -f Dockerfile . 52 | 53 | # Use a prebuilt image from quay.io 54 | podman pull quay.io/continuouscompliance/trestle-bot:latest 55 | export TRESTLEBOT_IMAGE=quay.io/continuouscompliance/trestle-bot:latest 56 | ``` 57 | 58 | - When created tests that push to a branch, ensure the name is "test". This is because the mock API server is configured to only allow pushes to a branch named "test". -------------------------------------------------------------------------------- /tests/e2e/conftest.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | # Copyright (c) 2024 Red Hat, Inc. 3 | 4 | 5 | """E2E test fixtures.""" 6 | 7 | import pytest 8 | 9 | from tests.conftest import YieldFixture 10 | from tests.e2e.e2e_testutils import E2ETestRunner 11 | 12 | 13 | @pytest.fixture(scope="package") 14 | def e2e_runner() -> YieldFixture[E2ETestRunner]: 15 | """Fixture for running e2e tests.""" 16 | runner = E2ETestRunner() 17 | runner.setup() 18 | yield runner 19 | runner.teardown() 20 | -------------------------------------------------------------------------------- /tests/e2e/mappings/mapping.json: -------------------------------------------------------------------------------- 1 | { 2 | "mappings": [ 3 | { 4 | "request": { 5 | "method": "GET", 6 | "url": "/test.git/HEAD" 7 | }, 8 | "response": { 9 | "status": 200, 10 | "body": "ref: refs/heads/test\n", 11 | "headers": { 12 | "Content-Type": "application/x-git-advertisement" 13 | } 14 | } 15 | }, 16 | { 17 | "request": { 18 | "method": "GET", 19 | "urlPattern": "/test.git/info/refs.*", 20 | "queryParameters": { 21 | "service": { 22 | "equalTo": "git-receive-pack" 23 | } 24 | } 25 | }, 26 | "response": { 27 | "status": 200, 28 | "body": "3e84c924d2574c95e8a7e8d7a76530b95d16f784\trefs/heads/test\n", 29 | "headers": { 30 | "Content-Type": "application/x-git-advertisement" 31 | } 32 | } 33 | }, 34 | { 35 | "request": { 36 | "method": "POST", 37 | "url": "/test.git/git-receive-pack" 38 | }, 39 | "response": { 40 | "status": 200, 41 | "body": "0000ACK refs/heads/test\n0000", 42 | "headers": { 43 | "Content-Type": "application/x-git-receive-pack-result" 44 | } 45 | } 46 | } 47 | ] 48 | } -------------------------------------------------------------------------------- /tests/e2e/play-kube.yml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: v1 3 | kind: Pod 4 | metadata: 5 | name: trestlebot-e2e-pod 6 | labels: 7 | app: trestlebot-e2e 8 | spec: 9 | containers: 10 | - name: mock-server-container 11 | image: localhost/mock-server:latest 12 | securityContext: 13 | allowPrivilegeEscalation: false 14 | capabilities: 15 | drop: 16 | - ALL 17 | add: 18 | - NET_BIND_SERVICE 19 | ports: 20 | - containerPort: 8080 21 | -------------------------------------------------------------------------------- /tests/integration/README.md: -------------------------------------------------------------------------------- 1 | # Integration Testing 2 | 3 | The purpose of the integration tests is to validate that trestle-bot produces output that downstream utilities, like complytime, can consume. 4 | 5 | The `complytime_home` fixture in `tests/integration/conftest.py` will download, cache, and install complytime to a temporary directory per test. 6 | 7 | If integration tests fail, your cached complytime download may be stale; delete `/tmp/trestle-bot-complytime-cache` and try again. 8 | 9 | -------------------------------------------------------------------------------- /tests/integration_data/c2p-openscap-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "metadata": { 3 | "id": "openscap", 4 | "description": "My openscap plugin", 5 | "version": "0.0.1", 6 | "types": [ 7 | "pvp" 8 | ] 9 | }, 10 | "executablePath": "openscap-plugin", 11 | "sha256": "CHANGEME", 12 | "configuration": [ 13 | { 14 | "name": "workspace", 15 | "description": "Directory for writing plugin artifacts", 16 | "required": true 17 | }, 18 | { 19 | "name": "profile", 20 | "description": "The OpenSCAP profile to run for assessment", 21 | "required": true 22 | }, 23 | { 24 | "name": "datastream", 25 | "description": "The OpenSCAP datastream to use. If not set, the plugin will try to determine it based on system information", 26 | "required": false 27 | }, 28 | { 29 | "name": "policy", 30 | "description": "The name of the generated tailoring file", 31 | "default": "tailoring_policy.xml", 32 | "required": false 33 | }, 34 | { 35 | "name": "arf", 36 | "description": "The name of the generated ARF file", 37 | "default": "arf.xml", 38 | "required": false 39 | }, 40 | { 41 | "name": "results", 42 | "description": "The name of the generated results file", 43 | "default": "results.xml", 44 | "required": false 45 | } 46 | ] 47 | } 48 | -------------------------------------------------------------------------------- /tests/integration_data/sample-catalog.json: -------------------------------------------------------------------------------- 1 | { 2 | "catalog": { 3 | "uuid": "f9f5bc95-489c-4e1c-b053-b10456050d3e", 4 | "metadata": { 5 | "title": "Catalog for anssi", 6 | "last-modified": "2025-02-26T18:38:40.384933+08:00", 7 | "version": "REPLACE_ME", 8 | "oscal-version": "1.1.2" 9 | }, 10 | "params": [], 11 | "groups": [ 12 | { 13 | "id": "r1", 14 | "title": "REPLACE_ME", 15 | "controls": [ 16 | { 17 | "id": "r1", 18 | "class": "CAC_IMPORT", 19 | "title": "Hardware Support", 20 | "params": [], 21 | "props": [ 22 | { 23 | "name": "label", 24 | "value": "R1" 25 | }, 26 | { 27 | "name": "sort-id", 28 | "value": "r1" 29 | } 30 | ], 31 | "parts": [ 32 | { 33 | "id": "r1_smt", 34 | "name": "statement" 35 | } 36 | ] 37 | } 38 | ] 39 | } 40 | ] 41 | } 42 | } 43 | 44 | -------------------------------------------------------------------------------- /tests/integration_data/sample-component-definition.json: -------------------------------------------------------------------------------- 1 | { 2 | "component-definition": { 3 | "uuid": "7791eb3a-764a-41e0-8cd3-8d775c9e95bf", 4 | "metadata": { 5 | "title": "My sample component definition.", 6 | "last-modified": "2023-02-21T06:53:42+00:00", 7 | "version": "0.1.0", 8 | "oscal-version": "1.1.2" 9 | }, 10 | "components": [ 11 | { 12 | "uuid": "7390f05c-d2b9-41d5-bf5f-3e6b17032d25", 13 | "type": "software", 14 | "title": "My Software", 15 | "description": "My target software for validation.", 16 | "props": [ 17 | { 18 | "name": "Rule_Id", 19 | "ns": "https://oscal-compass.github.io/compliance-trestle/schemas/oscal/cd", 20 | "value": "set_password_hashing_algorithm_logindefs", 21 | "remarks": "rule_set_00" 22 | }, 23 | { 24 | "name": "Rule_Description", 25 | "ns": "https://oscal-compass.github.io/compliance-trestle/schemas/oscal/cd", 26 | "value": "This rule ensures that the password hashing algorithm is set in login.defs", 27 | "remarks": "rule_set_00" 28 | }, 29 | { 30 | "name": "Rule_Id", 31 | "ns": "https://oscal-compass.github.io/compliance-trestle/schemas/oscal/cd", 32 | "value": "package_telnet-server_removed", 33 | "remarks": "rule_set_01" 34 | }, 35 | { 36 | "name": "Rule_Description", 37 | "ns": "https://oscal-compass.github.io/compliance-trestle/schemas/oscal/cd", 38 | "value": "This rule ensures that telnet-server package is removed", 39 | "remarks": "rule_set_01" 40 | } 41 | ], 42 | "control-implementations": [ 43 | { 44 | "uuid": "bb6420f5-146c-44c0-b708-79b96e7a009e", 45 | "source": "file://controls/sample-profile.json", 46 | "description": "My example profile.", 47 | "props": [ 48 | { 49 | "name": "Framework_Short_Name", 50 | "ns": "https://oscal-compass.github.io/compliance-trestle/schemas/oscal/cd", 51 | "value": "example" 52 | } 53 | ], 54 | "implemented-requirements": [ 55 | { 56 | "uuid": "ed2ac4e9-d16a-4fc5-bd3a-13484b6d8fef", 57 | "control-id": "most_important_requirement", 58 | "description": "My example implemented requirement.", 59 | "props": [ 60 | { 61 | "name": "Rule_Id", 62 | "ns": "https://oscal-compass.github.io/compliance-trestle/schemas/oscal/cd", 63 | "value": "set_password_hashing_algorithm_logindefs" 64 | }, 65 | { 66 | "name": "Rule_Id", 67 | "ns": "https://oscal-compass.github.io/compliance-trestle/schemas/oscal/cd", 68 | "value": "package_telnet-server_removed" 69 | } 70 | ] 71 | } 72 | ] 73 | } 74 | ] 75 | }, 76 | { 77 | "uuid": "b1c7a388-e8d4-4ff0-a249-0bb6686764cf", 78 | "type": "validation", 79 | "title": "openscap", 80 | "description": "An example validation component for openscap-plugin", 81 | "props": [ 82 | { 83 | "name": "Rule_Id", 84 | "ns": "https://oscal-compass.github.io/compliance-trestle/schemas/oscal/cd", 85 | "value": "package_telnet-server_removed", 86 | "remarks": "rule_set_00" 87 | }, 88 | { 89 | "name": "Rule_Description", 90 | "ns": "https://oscal-compass.github.io/compliance-trestle/schemas/oscal/cd", 91 | "value": "This rule ensures that telnet-server package is removed", 92 | "remarks": "rule_set_00" 93 | }, 94 | { 95 | "name": "Check_Id", 96 | "ns": "https://oscal-compass.github.io/compliance-trestle/schemas/oscal/cd", 97 | "value": "package_telnet-server_removed", 98 | "remarks": "rule_set_00" 99 | }, 100 | { 101 | "name": "Check_Description", 102 | "ns": "https://oscal-compass.github.io/compliance-trestle/schemas/oscal/cd", 103 | "value": "For OpenSCAP, the rule and the check share the same ID", 104 | "remarks": "rule_set_00" 105 | } 106 | ] 107 | } 108 | ] 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /tests/integration_data/sample-profile.json: -------------------------------------------------------------------------------- 1 | { 2 | "profile": { 3 | "uuid": "0c546fdb-f2d2-4c94-8a03-32b86d37800b", 4 | "metadata": { 5 | "title": "Example Profile (low)", 6 | "last-modified": "2025-02-05T08:56:16.664181-05:00", 7 | "version": "REPLACE_ME", 8 | "oscal-version": "1.1.2" 9 | }, 10 | "imports": [ 11 | { 12 | "href": "file://controls/sample-catalog.json", 13 | "include-controls": [ 14 | { 15 | "with-ids": [ 16 | "most_important_requirement" 17 | ] 18 | } 19 | ] 20 | } 21 | ], 22 | "merge": { 23 | "combine": { 24 | "method": "merge" 25 | }, 26 | "as-is": true 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /tests/trestlebot/__init__.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | # Copyright (c) 2023 Red Hat, Inc. 3 | 4 | """Test package for trestlebot top-level logic.""" 5 | -------------------------------------------------------------------------------- /tests/trestlebot/cli/test_autosync_cmd.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | # Copyright (c) 2024 Red Hat, Inc. 3 | 4 | 5 | """Testing module for trestlebot autosync command""" 6 | import pathlib 7 | from typing import Tuple 8 | 9 | from click.testing import CliRunner 10 | from git import Repo 11 | 12 | from trestlebot.cli.commands.autosync import autosync_cmd 13 | from trestlebot.cli.config import TrestleBotConfig, write_to_file 14 | 15 | 16 | def test_invalid_oscal_model(tmp_repo: Tuple[str, Repo]) -> None: 17 | """Test invalid OSCAl model option.""" 18 | 19 | repo_path, _ = tmp_repo 20 | runner = CliRunner() 21 | result = runner.invoke( 22 | autosync_cmd, 23 | [ 24 | "--oscal-model", 25 | "invalid", 26 | "--repo-path", 27 | repo_path, 28 | "--markdown-dir", 29 | "markdown", 30 | "--branch", 31 | "main", 32 | "--committer-name", 33 | "Test User", 34 | "--committer-email", 35 | "test@example.com", 36 | ], 37 | ) 38 | assert "Invalid value for '--oscal-model'" in result.output 39 | assert result.exit_code == 2 40 | 41 | 42 | def test_missing_ssp_index_file_option(tmp_repo: Tuple[str, Repo]) -> None: 43 | """Test missing ssp_index_file option for autosync ssp.""" 44 | repo_path, _ = tmp_repo 45 | runner = CliRunner() 46 | cmd_options = [ 47 | "--oscal-model", 48 | "ssp", 49 | "--repo-path", 50 | repo_path, 51 | "--markdown-dir", 52 | "markdown", 53 | "--branch", 54 | "main", 55 | "--committer-name", 56 | "Test User", 57 | "--committer-email", 58 | "test@example.com", 59 | ] 60 | result = runner.invoke(autosync_cmd, cmd_options) 61 | assert result.exit_code == 1 62 | assert "Missing option '--ssp-index-file'" in result.output 63 | 64 | 65 | def test_missing_markdown_dir_option(tmp_repo: Tuple[str, Repo]) -> None: 66 | # When no markdown_dir setting in trestlebot config file. 67 | repo_path, _ = tmp_repo 68 | runner = CliRunner() 69 | filepath = pathlib.Path(repo_path).joinpath("config.yml") 70 | config_obj = TrestleBotConfig(repo_path=repo_path) 71 | write_to_file(config_obj, filepath) 72 | cmd_options = [ 73 | "--oscal-model", 74 | "compdef", 75 | "--repo-path", 76 | repo_path, 77 | "--branch", 78 | "main", 79 | "--committer-name", 80 | "Test User", 81 | "--committer-email", 82 | "test@example.com", 83 | "--config", 84 | str(filepath), 85 | ] 86 | result = runner.invoke(autosync_cmd, cmd_options) 87 | assert result.exit_code == 2 88 | assert "Error: Missing option '--markdown-dir'" in result.output 89 | 90 | # With non-existent 'markdown_dir' setting in config.yml 91 | config_obj = TrestleBotConfig(markdown_dir="markdown") 92 | write_to_file(config_obj, filepath) 93 | result = runner.invoke(autosync_cmd, cmd_options) 94 | assert result.exit_code == 1 95 | -------------------------------------------------------------------------------- /tests/trestlebot/cli/test_config.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | # Copyright (c) 2024 Red Hat, Inc. 3 | 4 | 5 | """Unit tests for CLI config module""" 6 | import pathlib 7 | 8 | import pytest 9 | import yaml 10 | 11 | from trestlebot.cli.config import ( 12 | TrestleBotConfig, 13 | TrestleBotConfigError, 14 | UpstreamsConfig, 15 | load_from_file, 16 | make_config, 17 | write_to_file, 18 | ) 19 | 20 | 21 | @pytest.fixture 22 | def config_obj() -> TrestleBotConfig: 23 | return TrestleBotConfig( 24 | repo_path="/tmp", 25 | markdown_dir="markdown", 26 | upstreams=UpstreamsConfig(sources=["repo@main"]), 27 | ) 28 | 29 | 30 | def test_invalid_config_raises_errors() -> None: 31 | """Test create config with invalid directory to raise error.""" 32 | 33 | with pytest.raises(TrestleBotConfigError) as ex: 34 | _ = make_config(dict(repo_path="0")) 35 | 36 | assert ( 37 | str(ex.value) 38 | == "Invalid config value for repo_path. Path does not point to a directory." 39 | ) 40 | 41 | 42 | def test_make_config_raises_no_errors(tmp_init_dir: str) -> None: 43 | """Test create a valid config object.""" 44 | values = { 45 | "repo_path": tmp_init_dir, 46 | "markdown_dir": "markdown", 47 | "committer_name": "committer-name", 48 | "committer_email": "committer-email", 49 | "upstreams": {"sources": ["https://test@main"], "skip_validation": True}, 50 | } 51 | config = make_config(values) 52 | assert isinstance(config, TrestleBotConfig) 53 | assert config.upstreams is not None 54 | assert config.upstreams.sources == ["https://test@main"] 55 | assert config.upstreams.skip_validation is True 56 | assert config.repo_path == pathlib.Path(tmp_init_dir) 57 | assert config.markdown_dir == values["markdown_dir"] 58 | assert config.committer_name == values["committer_name"] 59 | assert config.committer_email == values["committer_email"] 60 | 61 | 62 | def test_config_write_to_file(config_obj: TrestleBotConfig, tmp_init_dir: str) -> None: 63 | """Test config is written to yaml file.""" 64 | filepath = pathlib.Path(tmp_init_dir).joinpath("config.yml") 65 | write_to_file(config_obj, filepath) 66 | with open(filepath, "r") as f: 67 | yaml_data = yaml.safe_load(f) 68 | 69 | assert yaml_data == config_obj.to_yaml_dict() 70 | 71 | 72 | def test_config_load_from_file(config_obj: TrestleBotConfig, tmp_init_dir: str) -> None: 73 | """Test config is read from yaml file into config object.""" 74 | filepath = pathlib.Path(tmp_init_dir).joinpath("config.yml") 75 | with filepath.open("w") as config_file: 76 | yaml.dump(config_obj.to_yaml_dict(), config_file) 77 | 78 | config = load_from_file(filepath) 79 | assert isinstance(config, TrestleBotConfig) 80 | assert config == config_obj 81 | -------------------------------------------------------------------------------- /tests/trestlebot/cli/test_init_cmd.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | # Copyright (c) 2024 Red Hat, Inc. 3 | 4 | 5 | """Testing module for trestlebot init command""" 6 | import pathlib 7 | 8 | import yaml 9 | from click.testing import CliRunner 10 | from trestle.common.const import MODEL_DIR_LIST, TRESTLE_CONFIG_DIR, TRESTLE_KEEP_FILE 11 | from trestle.common.file_utils import is_hidden 12 | 13 | from tests.testutils import setup_for_init 14 | from trestlebot.cli.commands.init import call_trestle_init, init_cmd 15 | from trestlebot.const import TRESTLEBOT_CONFIG_DIR 16 | 17 | 18 | def test_init_repo_dir_does_not_exist() -> None: 19 | """Init should fail if repo dir does not exit""" 20 | runner = CliRunner() 21 | result = runner.invoke(init_cmd, ["--repo-path", "0"]) 22 | assert result.exit_code == 2 23 | assert "does not exist." in result.output 24 | 25 | 26 | def test_init_not_git_repo(tmp_init_dir: str) -> None: 27 | """Init should fail if repo dir is not a Git repo.""" 28 | runner = CliRunner() 29 | result = runner.invoke( 30 | init_cmd, ["--repo-path", tmp_init_dir, "--markdown-dir", "markdown"] 31 | ) 32 | assert result.exit_code == 1 33 | assert "not a Git repository" in result.output 34 | 35 | 36 | def test_init_existing_trestlebot_dir(tmp_init_dir: str) -> None: 37 | """Init should fail if repo already contains .trestlebot/ dir.""" 38 | 39 | # setup_for_init(pathlib.Path(tmp_init_dir)) 40 | # Manulaly create .trestlebot dir so it already exists 41 | trestlebot_dir = pathlib.Path(tmp_init_dir) / pathlib.Path(TRESTLEBOT_CONFIG_DIR) 42 | trestlebot_dir.mkdir() 43 | 44 | setup_for_init(pathlib.Path(tmp_init_dir)) 45 | 46 | runner = CliRunner() 47 | result = runner.invoke( 48 | init_cmd, ["--repo-path", tmp_init_dir, "--markdown-dir", "markdown"] 49 | ) 50 | 51 | assert result.exit_code == 1 52 | assert "existing .trestlebot directory" in result.output 53 | 54 | 55 | def test_init_creates_config_file(tmp_init_dir: str) -> None: 56 | """Test init command creates yaml config file.""" 57 | 58 | setup_for_init(pathlib.Path(tmp_init_dir)) 59 | 60 | runner = CliRunner() 61 | result = runner.invoke( 62 | init_cmd, ["--repo-path", tmp_init_dir, "--markdown-dir", "markdown"] 63 | ) 64 | assert result.exit_code == 0 65 | assert "Successfully initialized trestlebot" in result.output 66 | 67 | config_path = ( 68 | pathlib.Path(tmp_init_dir) 69 | .joinpath(TRESTLEBOT_CONFIG_DIR) 70 | .joinpath("config.yml") 71 | ) 72 | with open(config_path, "r") as f: 73 | yaml_data = yaml.safe_load(f) 74 | 75 | assert yaml_data["repo_path"] == tmp_init_dir 76 | assert yaml_data["markdown_dir"] == "markdown" 77 | 78 | 79 | def test_init_creates_model_dirs(tmp_init_dir: str) -> None: 80 | """Init should create model directories in repo""" 81 | 82 | tmp_dir = pathlib.Path(tmp_init_dir) 83 | setup_for_init(tmp_dir) 84 | 85 | runner = CliRunner() 86 | runner.invoke(init_cmd, ["--repo-path", tmp_init_dir, "--markdown-dir", "markdown"]) 87 | 88 | model_dirs = [d.name for d in tmp_dir.iterdir() if not is_hidden(d)] 89 | model_dirs.remove("markdown") # pop markdown dir 90 | assert sorted(model_dirs) == sorted(MODEL_DIR_LIST) 91 | 92 | 93 | def test_init_creates_markdown_dirs(tmp_init_dir: str) -> None: 94 | """Init should create model directories in repo""" 95 | 96 | tmp_dir = pathlib.Path(tmp_init_dir) 97 | markdown_dir = tmp_dir.joinpath("markdown") 98 | setup_for_init(tmp_dir) 99 | 100 | runner = CliRunner() 101 | runner.invoke(init_cmd, ["--repo-path", tmp_init_dir, "--markdown-dir", "markdown"]) 102 | 103 | markdown_dirs = [d.name for d in markdown_dir.iterdir() if not is_hidden(d)] 104 | assert sorted(markdown_dirs) == sorted(MODEL_DIR_LIST) 105 | 106 | 107 | def test_init_creates_trestle_dirs(tmp_init_dir: str) -> None: 108 | """Init should create markdown dirs in repo""" 109 | 110 | tmp_dir = pathlib.Path(tmp_init_dir) 111 | call_trestle_init(tmp_dir, False) 112 | trestle_dir = tmp_dir.joinpath(TRESTLE_CONFIG_DIR) 113 | keep_file = trestle_dir.joinpath(TRESTLE_KEEP_FILE) 114 | assert keep_file.exists() is True 115 | -------------------------------------------------------------------------------- /tests/trestlebot/cli/test_rules_transform_cmd.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | # Copyright (c) 2024 Red Hat, Inc. 3 | 4 | 5 | """Testing module for trestlebot rules-transform command""" 6 | 7 | import pathlib 8 | from typing import Tuple 9 | 10 | from click.testing import CliRunner 11 | from git import Repo 12 | 13 | from tests.testutils import setup_for_compdef, setup_rules_view 14 | from trestlebot.cli.commands.rules_transform import rules_transform_cmd 15 | 16 | 17 | test_comp_name = "test_comp" 18 | test_md = "md_cd" 19 | 20 | 21 | def test_rules_transform(tmp_repo: Tuple[str, Repo]) -> None: 22 | """Test rule transform.""" 23 | repo_path_str, repo = tmp_repo 24 | 25 | repo_path = pathlib.Path(repo_path_str) 26 | 27 | setup_for_compdef(repo_path, test_comp_name, test_md) 28 | setup_rules_view(repo_path, test_comp_name) 29 | 30 | assert not repo_path.joinpath(test_md).exists() 31 | 32 | runner = CliRunner() 33 | result = runner.invoke( 34 | rules_transform_cmd, 35 | [ 36 | "--dry-run", 37 | "--repo-path", 38 | repo_path, 39 | "--markdown-dir", 40 | test_md, 41 | "--branch", 42 | "main", 43 | "--committer-name", 44 | "Test User", 45 | "--committer-email", 46 | "test@example.com", 47 | ], 48 | ) 49 | 50 | assert result.exit_code == 0 51 | assert repo_path.joinpath(test_md).exists() 52 | commit = next(repo.iter_commits()) 53 | assert len(commit.stats.files) == 9 54 | -------------------------------------------------------------------------------- /tests/trestlebot/tasks/__init__.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | # Copyright (c) 2023 Red Hat, Inc. 3 | 4 | """Test tasks package.""" 5 | -------------------------------------------------------------------------------- /tests/trestlebot/tasks/authored/__init__.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | # Copyright (c) 2023 Red Hat, Inc. 3 | 4 | """Test authored package.""" 5 | -------------------------------------------------------------------------------- /tests/trestlebot/tasks/authored/test_types.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | # Copyright (c) 2023 Red Hat, Inc. 3 | 4 | 5 | """Test author types for Trestlebot""" 6 | 7 | from unittest.mock import Mock 8 | 9 | import pytest 10 | 11 | from tests import testutils 12 | from trestlebot.tasks.authored import types 13 | from trestlebot.tasks.authored.base_authored import ( 14 | AuthoredObjectBase, 15 | AuthoredObjectException, 16 | ) 17 | from trestlebot.tasks.authored.catalog import AuthoredCatalog 18 | from trestlebot.tasks.authored.compdef import AuthoredComponentDefinition 19 | from trestlebot.tasks.authored.profile import AuthoredProfile 20 | from trestlebot.tasks.authored.ssp import AuthoredSSP 21 | 22 | 23 | test_prof = "simplified_nist_profile" 24 | test_comp = "test_comp" 25 | test_ssp_output = "test-ssp" 26 | markdown_dir = "md_ssp" 27 | 28 | 29 | def test_get_authored_catalog(tmp_trestle_dir: str) -> None: 30 | """Test get authored type for catalogs""" 31 | 32 | authored_object: AuthoredObjectBase = types.get_authored_object( 33 | types.AuthoredType.CATALOG.value, tmp_trestle_dir, "" 34 | ) 35 | 36 | assert authored_object.get_trestle_root() == tmp_trestle_dir 37 | assert isinstance(authored_object, AuthoredCatalog) 38 | 39 | 40 | def test_get_authored_profile(tmp_trestle_dir: str) -> None: 41 | """Test get authored type for profiles""" 42 | 43 | authored_object: AuthoredObjectBase = types.get_authored_object( 44 | types.AuthoredType.PROFILE.value, tmp_trestle_dir, "" 45 | ) 46 | 47 | assert authored_object.get_trestle_root() == tmp_trestle_dir 48 | assert isinstance(authored_object, AuthoredProfile) 49 | 50 | 51 | def test_get_authored_compdef(tmp_trestle_dir: str) -> None: 52 | """Test get authored type for compdefs""" 53 | 54 | authored_object: AuthoredObjectBase = types.get_authored_object( 55 | types.AuthoredType.COMPDEF.value, tmp_trestle_dir, "" 56 | ) 57 | 58 | assert authored_object.get_trestle_root() == tmp_trestle_dir 59 | assert isinstance(authored_object, AuthoredComponentDefinition) 60 | 61 | 62 | def test_get_authored_ssp(tmp_trestle_dir: str) -> None: 63 | """Test get authored type for ssp""" 64 | with pytest.raises( 65 | FileNotFoundError, 66 | ): 67 | _ = types.get_authored_object(types.AuthoredType.SSP.value, tmp_trestle_dir, "") 68 | 69 | # Test with a valid ssp index 70 | authored_object: AuthoredObjectBase = types.get_authored_object( 71 | types.AuthoredType.SSP.value, tmp_trestle_dir, str(testutils.TEST_SSP_INDEX) 72 | ) 73 | 74 | assert authored_object.get_trestle_root() == tmp_trestle_dir 75 | assert isinstance(authored_object, AuthoredSSP) 76 | 77 | 78 | def test_invalid_authored_type(tmp_trestle_dir: str) -> None: 79 | """Test triggering an error with an invalid type""" 80 | with pytest.raises( 81 | AuthoredObjectException, 82 | match="Invalid authored type fake", 83 | ): 84 | _ = types.get_authored_object("fake", tmp_trestle_dir, "") 85 | 86 | 87 | def test_get_model_dir_with_invalid_type() -> None: 88 | """Test triggering an error with an invalid type when getting the model dir.""" 89 | with pytest.raises( 90 | AuthoredObjectException, 91 | match="Invalid authored object ", 92 | ): 93 | mock = Mock(spec=AuthoredObjectBase) 94 | _ = types.get_trestle_model_dir(mock) 95 | -------------------------------------------------------------------------------- /tests/trestlebot/tasks/test_base_task.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | # Copyright (c) 2023 Red Hat, Inc. 3 | 4 | 5 | """Test workspace filtering logic.""" 6 | 7 | import pathlib 8 | from typing import List 9 | 10 | import pytest 11 | 12 | from trestlebot.tasks.base_task import ModelFilter 13 | 14 | 15 | @pytest.mark.parametrize( 16 | "skip_list, include_list, model_name, expected", 17 | [ 18 | [["simplified_nist_catalog"], [], "simplified_nist_catalog", True], 19 | [[], ["simplified_nist_catalog"], "simplified_nist_catalog", False], 20 | [["simplified*"], ["*"], "simplified_nist_catalog", True], 21 | [ 22 | ["simplified_nist_catalog"], 23 | ["simplified*"], 24 | "simplified_nist_profile", 25 | False, 26 | ], 27 | [[], [], "simplified_nist_catalog", True], 28 | [[], ["*"], "simplified_nist_catalog", False], 29 | ], 30 | ) 31 | def test_is_skipped( 32 | skip_list: List[str], include_list: List[str], model_name: str, expected: str 33 | ) -> None: 34 | """Test skip logic.""" 35 | model_path = pathlib.Path(model_name) 36 | model_filter = ModelFilter(skip_list, include_list) 37 | assert model_filter.is_skipped(model_path) == expected 38 | -------------------------------------------------------------------------------- /tests/trestlebot/test_reporter.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | # Copyright (c) 2024 Red Hat, Inc. 3 | 4 | """Test for general reporting logic""" 5 | 6 | from unittest.mock import patch 7 | 8 | from trestlebot.reporter import BotResults, ResultsReporter 9 | 10 | 11 | def test_results_reporter_with_commit() -> None: 12 | """Test results reporter""" 13 | results = BotResults(changes=[], commit_sha="123456", pr_number=2) 14 | 15 | with patch("builtins.print") as mock_print: 16 | ResultsReporter().report_results(results) 17 | mock_print.assert_called_once_with( 18 | "\nCommit Hash: 123456\nPull Request Number: 2" 19 | ) 20 | 21 | 22 | def test_results_reporter_no_commit() -> None: 23 | """Test results reporter with no commit""" 24 | results = BotResults(changes=[], commit_sha="", pr_number=0) 25 | 26 | with patch("builtins.print") as mock_print: 27 | ResultsReporter().report_results(results) 28 | mock_print.assert_called_once_with("No changes detected") 29 | 30 | 31 | def test_results_reporter_with_changes() -> None: 32 | """Test results reporter with changes""" 33 | results = BotResults(changes=["file1"], commit_sha="", pr_number=0) 34 | 35 | with patch("builtins.print") as mock_print: 36 | ResultsReporter().report_results(results) 37 | mock_print.assert_called_once_with("\nChanges:\nfile1") 38 | -------------------------------------------------------------------------------- /tests/trestlebot/transformers/__init__.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | # Copyright (c) 2023 Red Hat, Inc. 3 | 4 | """Test transformers package.""" 5 | -------------------------------------------------------------------------------- /tests/trestlebot/transformers/test_csv_transformer.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | # Copyright (c) 2023 Red Hat, Inc. 3 | 4 | 5 | """Test for CSV Transformer.""" 6 | 7 | import csv 8 | import pathlib 9 | from typing import Dict, List 10 | 11 | import pytest 12 | 13 | from trestlebot.transformers.csv_transformer import ( 14 | CSVBuilder, 15 | FromRulesCSVTransformer, 16 | ToRulesCSVTransformer, 17 | ) 18 | from trestlebot.transformers.trestle_rule import TrestleRule 19 | 20 | 21 | def test_csv_builder(test_rule: TrestleRule, tmp_trestle_dir: str) -> None: 22 | """Test CSV builder on a happy path""" 23 | 24 | csv_builder = CSVBuilder() 25 | csv_builder.add_row(test_rule) 26 | 27 | assert len(csv_builder._rows) == 1 28 | row = csv_builder._rows[0] 29 | assert row["Rule_Id"] == test_rule.name 30 | assert row["Rule_Description"] == test_rule.description 31 | assert row["Component_Title"] == test_rule.component.name 32 | assert row["Component_Type"] == test_rule.component.type 33 | assert row["Component_Description"] == test_rule.component.description 34 | assert row["Control_Id_List"] == "ac-1 ac-2" 35 | assert row["Parameter_Id"] == test_rule.parameter.name # type: ignore 36 | assert row["Parameter_Description"] == test_rule.parameter.description # type: ignore 37 | assert row["Parameter_Value_Alternatives"] == '{"default": "test", "test": "test"}' 38 | assert row["Parameter_Value_Default"] == test_rule.parameter.default_value # type: ignore 39 | assert row["Profile_Description"] == test_rule.profile.description 40 | assert row["Profile_Source"] == test_rule.profile.href 41 | assert row["Check_Id"] == test_rule.check.name # type: ignore 42 | assert row["Check_Description"] == test_rule.check.description # type: ignore 43 | 44 | trestle_root = pathlib.Path(tmp_trestle_dir) 45 | tmp_csv_path = trestle_root.joinpath("test.csv") 46 | csv_builder.write_to_file(tmp_csv_path) 47 | 48 | assert tmp_csv_path.exists() 49 | 50 | first_row: List[str] = [] 51 | with open(tmp_csv_path, "r", newline="") as csvfile: 52 | csv_reader = csv.reader(csvfile) 53 | first_row = next(csv_reader) 54 | 55 | for column in csv_builder._csv_columns.get_required_column_names(): 56 | assert column in first_row 57 | 58 | 59 | def test_validate_row_missing_keys(test_valid_csv_row: Dict[str, str]) -> None: 60 | """Test validate row with missing keys.""" 61 | del test_valid_csv_row["Rule_Id"] 62 | csv_builder = CSVBuilder() 63 | with pytest.raises(RuntimeError, match="Row missing key: *"): 64 | csv_builder.validate_row(test_valid_csv_row) 65 | 66 | 67 | def test_validate_row_extra_keys(test_valid_csv_row: Dict[str, str]) -> None: 68 | """Test validate row with extra keys.""" 69 | test_valid_csv_row["extra_key"] = "extra_value" 70 | csv_builder = CSVBuilder() 71 | with pytest.raises(RuntimeError, match="Row has extra key: *"): 72 | csv_builder.validate_row(test_valid_csv_row) 73 | 74 | 75 | def test_read_write_integration(test_rule: TrestleRule) -> None: 76 | """Test read/write integration.""" 77 | from_rules_transformer = FromRulesCSVTransformer() 78 | to_rules_transformer = ToRulesCSVTransformer() 79 | 80 | csv_row_data = from_rules_transformer.transform(test_rule) 81 | read_rule = to_rules_transformer.transform(csv_row_data) 82 | 83 | assert read_rule == test_rule 84 | -------------------------------------------------------------------------------- /tests/workflows/__init__.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | # Copyright (c) 2023 Red Hat, Inc. 3 | 4 | """Tests that validate workflow outcomes by model type.""" 5 | -------------------------------------------------------------------------------- /tests/workflows/test_rules_transform_workflow.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | # Copyright (c) 2023 Red Hat, Inc. 3 | 4 | 5 | """Test the rules transformation workflow from a Task Class perspective.""" 6 | 7 | import os 8 | import pathlib 9 | 10 | from trestle.common.load_validate import load_validate_model_name 11 | from trestle.oscal import component as comp 12 | 13 | import trestlebot.const as const 14 | from tests.testutils import setup_for_profile 15 | from trestlebot.tasks.assemble_task import AssembleTask 16 | from trestlebot.tasks.authored.compdef import AuthoredComponentDefinition 17 | from trestlebot.tasks.regenerate_task import RegenerateTask 18 | from trestlebot.tasks.rule_transform_task import RuleTransformTask 19 | from trestlebot.transformers.yaml_transformer import ToRulesYAMLTransformer 20 | 21 | 22 | test_component_definition = "test_component_definition" 23 | test_profile = "simplified_nist_profile" 24 | test_md_path = "md_compdef" 25 | 26 | 27 | def test_rules_transform_workflow(tmp_trestle_dir: str) -> None: 28 | """Test the rules transformation workflow for component definitions.""" 29 | 30 | trestle_root_path = pathlib.Path(tmp_trestle_dir) 31 | 32 | # Environment setup and initial rule generation 33 | _ = setup_for_profile(trestle_root_path, test_profile, test_profile) 34 | 35 | authored_compdef = AuthoredComponentDefinition(trestle_root=tmp_trestle_dir) 36 | authored_compdef.create_new_default( 37 | profile_name=test_profile, 38 | compdef_name=test_component_definition, 39 | comp_title="Test", 40 | comp_description="Test component definition", 41 | comp_type="service", 42 | ) 43 | 44 | # Transform 45 | transform = RuleTransformTask( 46 | tmp_trestle_dir, const.RULES_VIEW_DIR, ToRulesYAMLTransformer() 47 | ) 48 | transform.execute() 49 | 50 | # Load the component definition 51 | compdef: comp.ComponentDefinition 52 | compdef, _ = load_validate_model_name( 53 | trestle_root_path, test_component_definition, comp.ComponentDefinition 54 | ) 55 | 56 | assert len(compdef.components) == 1 57 | component = compdef.components[0] 58 | 59 | assert component.title == "Test" 60 | assert component.description == "Test component definition" 61 | assert component.type == "service" 62 | assert len(component.props) == 24 63 | assert len(component.control_implementations) == 1 64 | assert ( 65 | component.control_implementations[0].source 66 | == f"trestle://profiles/{test_profile}/profile.json" 67 | ) 68 | 69 | last_modified = compdef.metadata.last_modified 70 | 71 | # Run regenerate 72 | regenerate = RegenerateTask(authored_compdef, test_md_path) 73 | regenerate.execute() 74 | 75 | assert os.path.exists( 76 | os.path.join(trestle_root_path, test_md_path, test_component_definition) 77 | ) 78 | 79 | # Run assemble 80 | assemble = AssembleTask(authored_compdef, test_md_path) 81 | assemble.execute() 82 | 83 | # Load the component definition 84 | compdef, _ = load_validate_model_name( 85 | trestle_root_path, test_component_definition, comp.ComponentDefinition 86 | ) 87 | 88 | assert len(compdef.components) == 1 89 | component = compdef.components[0] 90 | 91 | # Asset last modified is updated, but all expected information is still present 92 | assert compdef.metadata.last_modified > last_modified 93 | 94 | assert component.title == "Test" 95 | assert component.description == "Test component definition" 96 | assert component.type == "service" 97 | assert len(component.props) == 24 98 | assert len(component.control_implementations) == 1 99 | assert ( 100 | component.control_implementations[0].source 101 | == f"trestle://profiles/{test_profile}/profile.json" 102 | ) 103 | -------------------------------------------------------------------------------- /trestlebot/__init__.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | # Copyright (c) 2023 Red Hat, Inc. 3 | 4 | """ 5 | trestlebot - A python library and command line utility for that performs opinionated actions using the 6 | `compliance-trestle` (https://ibm.github.io/compliance-trestle/) library to manage content 7 | is OSCAL workspace. 8 | """ 9 | -------------------------------------------------------------------------------- /trestlebot/__main__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # SPDX-License-Identifier: Apache-2.0 3 | # Copyright (c) 2023 Red Hat, Inc. 4 | 5 | 6 | # Default entrypoint for trestlebot is the root cmd when run with python -m trestlebot 7 | 8 | from trestlebot.cli.root import root_cmd 9 | 10 | 11 | def init() -> None: 12 | """trestlebot root""" 13 | if __name__ == "__main__": 14 | root_cmd() 15 | 16 | 17 | init() 18 | -------------------------------------------------------------------------------- /trestlebot/cli/commands/autosync.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | # Copyright (c) 2024 Red Hat, Inc. 3 | 4 | """Autosync command""" 5 | 6 | import logging 7 | import sys 8 | import traceback 9 | from typing import Any, List 10 | 11 | import click 12 | 13 | from trestlebot.cli.options.common import common_options, git_options 14 | from trestlebot.cli.utils import comma_sep_to_list, run_bot 15 | from trestlebot.const import ERROR_EXIT_CODE 16 | from trestlebot.tasks.assemble_task import AssembleTask 17 | from trestlebot.tasks.authored import types 18 | from trestlebot.tasks.authored.base_authored import AuthoredObjectBase 19 | from trestlebot.tasks.base_task import ModelFilter, TaskBase 20 | from trestlebot.tasks.regenerate_task import RegenerateTask 21 | 22 | 23 | logger = logging.getLogger(__name__) 24 | 25 | 26 | @click.command("autosync", help="Autosync catalog, profile, compdef and ssp.") 27 | @click.pass_context 28 | @common_options 29 | @git_options 30 | @click.option( 31 | "--oscal-model", 32 | type=click.Choice(choices=[model.value for model in types.AuthoredType]), 33 | help="OSCAL model type for autosync.", 34 | required=True, 35 | ) 36 | @click.option( 37 | "--markdown-dir", 38 | type=str, 39 | help="Directory containing markdown files.", 40 | required=True, 41 | ) 42 | @click.option( 43 | "--skip-items", 44 | type=str, 45 | help="Comma-separated list of glob patterns of the chosen model type \ 46 | to skip when running tasks.", 47 | ) 48 | @click.option( 49 | "--skip-assemble", 50 | help="Skip assembly task.", 51 | is_flag=True, 52 | default=False, 53 | show_default=True, 54 | ) 55 | @click.option( 56 | "--skip-regenerate", 57 | help="Skip regenerate task.", 58 | is_flag=True, 59 | default=False, 60 | show_default=True, 61 | ) 62 | @click.option( 63 | "--version", 64 | help="Version of the OSCAL model to set during assembly into JSON.", 65 | type=str, 66 | ) 67 | @click.option( 68 | "--ssp-index-file", 69 | help="Path to ssp index file. Required if --oscal-model is 'ssp'.", 70 | type=str, 71 | required=False, 72 | ) 73 | def autosync_cmd(ctx: click.Context, **kwargs: Any) -> None: 74 | """Command to autosync catalog, profile, compdef and ssp.""" 75 | 76 | oscal_model = kwargs["oscal_model"] 77 | markdown_dir = kwargs["markdown_dir"] 78 | working_dir = str(kwargs["repo_path"].resolve()) 79 | kwargs["working_dir"] = working_dir 80 | 81 | if oscal_model == "ssp" and not kwargs.get("ssp_index_file"): 82 | logger.error("Trestlebot Error: Missing option '--ssp-index-file'.") 83 | sys.exit(ERROR_EXIT_CODE) 84 | 85 | pre_tasks: List[TaskBase] = [] 86 | 87 | if kwargs.get("file_pattern"): 88 | kwargs.update({"patterns": comma_sep_to_list(kwargs["file_patterns"])}) 89 | 90 | try: 91 | model_filter: ModelFilter = ModelFilter( 92 | skip_patterns=comma_sep_to_list(kwargs.get("skip_items", "")), 93 | include_patterns=["*"], 94 | ) 95 | authored_object: AuthoredObjectBase = types.get_authored_object( 96 | oscal_model, 97 | working_dir, 98 | kwargs.get("ssp_index_file", ""), 99 | ) 100 | 101 | # Assuming an edit has occurred assemble would be run before regenerate. 102 | if not kwargs.get("skip_assemble"): 103 | assemble_task: AssembleTask = AssembleTask( 104 | authored_object=authored_object, 105 | markdown_dir=markdown_dir, 106 | version=kwargs.get("version", ""), 107 | model_filter=model_filter, 108 | ) 109 | pre_tasks.append(assemble_task) 110 | else: 111 | logger.info("Assemble task skipped.") 112 | 113 | if not kwargs.get("skip_regenerate"): 114 | regenerate_task: RegenerateTask = RegenerateTask( 115 | authored_object=authored_object, 116 | markdown_dir=markdown_dir, 117 | model_filter=model_filter, 118 | ) 119 | pre_tasks.append(regenerate_task) 120 | else: 121 | logger.info("Regeneration task skipped.") 122 | 123 | results = run_bot(pre_tasks, kwargs) 124 | logger.debug(f"Trestlebot results: {results}") 125 | except Exception as e: 126 | traceback_str = traceback.format_exc() 127 | logger.error(f"Trestle-bot Error: {str(e)}") 128 | logger.debug(traceback_str) 129 | sys.exit(ERROR_EXIT_CODE) 130 | -------------------------------------------------------------------------------- /trestlebot/cli/commands/rules_transform.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | # Copyright (c) 2024 Red Hat, Inc. 3 | 4 | """Module for rules-transform command""" 5 | 6 | import logging 7 | from typing import Any, List 8 | 9 | import click 10 | 11 | from trestlebot.cli.options.common import common_options, git_options, handle_exceptions 12 | from trestlebot.cli.utils import comma_sep_to_list, run_bot 13 | from trestlebot.const import RULES_VIEW_DIR 14 | from trestlebot.tasks.authored.compdef import AuthoredComponentDefinition 15 | from trestlebot.tasks.base_task import ModelFilter, TaskBase 16 | from trestlebot.tasks.regenerate_task import RegenerateTask 17 | from trestlebot.tasks.rule_transform_task import RuleTransformTask 18 | from trestlebot.transformers.yaml_transformer import ToRulesYAMLTransformer 19 | 20 | 21 | logger = logging.getLogger(__name__) 22 | 23 | 24 | @click.command( 25 | name="rules-transform", 26 | help="Transform rules to an OSCAL Component Definition JSON file.", 27 | ) 28 | @click.pass_context 29 | @common_options 30 | @git_options 31 | @click.option( 32 | "--markdown-dir", 33 | type=str, 34 | help="Directory name to store markdown files.", 35 | ) 36 | @click.option( 37 | "--rules-view-dir", 38 | type=str, 39 | help="Top-level rules-view directory.", 40 | default=RULES_VIEW_DIR, 41 | ) 42 | @click.option( 43 | "--skip-items", 44 | type=str, 45 | help="Comma-separated list of glob patterns for directories to skip when running tasks.", 46 | ) 47 | @handle_exceptions 48 | def rules_transform_cmd(ctx: click.Context, **kwargs: Any) -> None: 49 | """Run the rule transform operation.""" 50 | # Allow any model to be skipped by setting skip_item, by default include all 51 | model_filter: ModelFilter = ModelFilter( 52 | skip_patterns=comma_sep_to_list(kwargs.get("skip_items", "")), 53 | include_patterns=["*"], 54 | ) 55 | 56 | transformer = ToRulesYAMLTransformer() 57 | rule_transform_task: RuleTransformTask = RuleTransformTask( 58 | working_dir=kwargs["repo_path"], 59 | rules_view_dir=kwargs["rules_view_dir"], 60 | rule_transformer=transformer, 61 | model_filter=model_filter, 62 | ) 63 | regenerate_task: RegenerateTask = RegenerateTask( 64 | markdown_dir=kwargs["markdown_dir"], 65 | authored_object=AuthoredComponentDefinition(kwargs["repo_path"]), 66 | model_filter=model_filter, 67 | ) 68 | 69 | pre_tasks: List[TaskBase] = [rule_transform_task, regenerate_task] 70 | kwargs["working_dir"] = str(kwargs["repo_path"].resolve()) 71 | result = run_bot(pre_tasks, kwargs) 72 | logger.debug(f"Bot results: {result}") 73 | logger.info("Rule transform complete!") 74 | -------------------------------------------------------------------------------- /trestlebot/cli/commands/sync_upstreams.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | # Copyright (c) 2024 Red Hat, Inc. 3 | 4 | """Module for upstream command""" 5 | import logging 6 | import sys 7 | from typing import Any, List 8 | 9 | import click 10 | 11 | from trestlebot.bot import TrestleBot 12 | from trestlebot.cli.options.common import common_options, git_options, handle_exceptions 13 | from trestlebot.cli.utils import comma_sep_to_list 14 | from trestlebot.const import ERROR_EXIT_CODE 15 | from trestlebot.tasks.base_task import ModelFilter, TaskBase 16 | from trestlebot.tasks.sync_upstreams_task import SyncUpstreamsTask 17 | 18 | 19 | logger = logging.getLogger(__name__) 20 | 21 | 22 | def load_value_from_ctx( 23 | ctx: click.Context, param: click.Parameter, value: Any = None 24 | ) -> Any: 25 | """Load config value for option from context.""" 26 | if value: 27 | return value 28 | 29 | if not ctx.default_map: 30 | return None 31 | 32 | upstreams = ctx.default_map.get("upstreams") 33 | if not upstreams: 34 | return None 35 | 36 | config = upstreams.model_dump() 37 | value = config.get(param.name) 38 | if isinstance(value, List): 39 | return ",".join(value) 40 | return value 41 | 42 | 43 | @click.command( 44 | name="sync-upstreams", 45 | help="Sync OSCAL content from upstream repositories.", 46 | ) 47 | @click.pass_context 48 | @click.option( 49 | "--sources", 50 | type=str, 51 | help="Comma-separated list of upstream git sources to sync. Each source is a string \ 52 | in the form @ where ref is a git ref such as a tag or branch.", 53 | envvar="TRESTLEBOT_UPSTREAMS_SOURCES", 54 | callback=load_value_from_ctx, 55 | required=False, 56 | ) 57 | @click.option( 58 | "--exclude-models", 59 | type=str, 60 | help="Comma-separated list of glob patterns for model names to exclude when running \ 61 | tasks (e.g. --include-models='component_x,profile_y*')", 62 | required=False, 63 | envvar="TRESTLEBOT_UPSTREAMS_EXCLUDE_MODELS", 64 | callback=load_value_from_ctx, 65 | ) 66 | @click.option( 67 | "--include-models", 68 | type=str, 69 | default="*", 70 | help="Comma-separated list of glob patterns for model names to include when running \ 71 | tasks (e.g. --include-models='component_x,profile_y*')", 72 | required=False, 73 | envvar="TRESTLEBOT_UPSTREAMS_INCLUDE_MODELS", 74 | callback=load_value_from_ctx, 75 | ) 76 | @click.option( 77 | "--skip-validation", 78 | type=bool, 79 | help="Skip validation of the models when they are copied.", 80 | is_flag=True, 81 | envvar="TRESTLEBOT_UPSTREAMS_SKIP_VALIDATION", 82 | callback=load_value_from_ctx, 83 | ) 84 | @common_options 85 | @git_options 86 | @handle_exceptions 87 | def sync_upstreams_cmd(ctx: click.Context, **kwargs: Any) -> None: 88 | """Add new upstream sources to workspace.""" 89 | if not kwargs.get("sources"): 90 | logger.error("Trestlebot Error: Missing option '--sources'.") 91 | sys.exit(ERROR_EXIT_CODE) 92 | 93 | working_dir = str(kwargs["repo_path"].resolve()) 94 | include_model_list = comma_sep_to_list(kwargs["include_models"]) 95 | 96 | model_filter: ModelFilter = ModelFilter( 97 | skip_patterns=comma_sep_to_list(kwargs.get("exclude_models", "")), 98 | include_patterns=include_model_list, 99 | ) 100 | 101 | validate: bool = not kwargs.get("skip_validation", False) 102 | 103 | sync_upstreams_task = SyncUpstreamsTask( 104 | working_dir=working_dir, 105 | git_sources=comma_sep_to_list(kwargs["sources"]), 106 | model_filter=model_filter, 107 | validate=validate, 108 | ) 109 | 110 | pre_tasks: List[TaskBase] = [sync_upstreams_task] 111 | 112 | bot = TrestleBot( 113 | working_dir=working_dir, 114 | branch=kwargs["branch"], 115 | commit_name=kwargs["committer_name"], 116 | commit_email=kwargs["committer_email"], 117 | author_name=kwargs.get("author_name", ""), 118 | author_email=kwargs.get("author_email", ""), 119 | target_branch=kwargs.get("target_branch", ""), 120 | ) 121 | 122 | results = bot.run( 123 | patterns=["*.json"], 124 | pre_tasks=pre_tasks, 125 | commit_message=kwargs.get("commit_message", ""), 126 | pull_request_title=kwargs.get("pull_request_title", ""), 127 | dry_run=kwargs.get("dry_run", False), 128 | ) 129 | 130 | logger.debug(f"Trestlebot results: {results}") 131 | -------------------------------------------------------------------------------- /trestlebot/cli/commands/version.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | # Copyright (c) 2024 Red Hat, Inc. 3 | 4 | """Version command""" 5 | -------------------------------------------------------------------------------- /trestlebot/cli/log.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | # Copyright (c) 2023 Red Hat, Inc. 3 | 4 | """Configure logger for trestlebot and trestle.""" 5 | 6 | import argparse 7 | import logging 8 | import sys 9 | from typing import List 10 | 11 | import trestle.common.log as trestle_log 12 | 13 | 14 | _logger = logging.getLogger("trestlebot") 15 | 16 | 17 | def set_log_level(level: int = logging.INFO) -> None: 18 | """Set the log level from the args for trestle and trestlebot.""" 19 | 20 | configure_logger(level) 21 | 22 | # Setup the trestle logger, it expects an argparse Namespace with a verbose int 23 | verbose = 1 if level == logging.DEBUG else 0 24 | args = argparse.Namespace(verbose=verbose) 25 | trestle_log.set_log_level_from_args(args=args) 26 | 27 | 28 | def configure_logger(level: int = logging.INFO, propagate: bool = False) -> None: 29 | """Configure the logger.""" 30 | # Prevent extra message 31 | _logger.propagate = propagate 32 | _logger.setLevel(level=level) 33 | for handler in configure_handlers(): 34 | _logger.addHandler(handler) 35 | 36 | 37 | def configure_handlers() -> List[logging.Handler]: 38 | """Configure the handlers.""" 39 | # Create a StreamHandler to send non-error logs to stdout 40 | stdout_info_handler = logging.StreamHandler(sys.stdout) 41 | stdout_info_handler.setLevel(logging.INFO) 42 | stdout_info_handler.addFilter(trestle_log.SpecificLevelFilter(logging.INFO)) 43 | 44 | stdout_debug_handler = logging.StreamHandler(sys.stdout) 45 | stdout_debug_handler.setLevel(logging.DEBUG) 46 | stdout_debug_handler.addFilter(trestle_log.SpecificLevelFilter(logging.DEBUG)) 47 | 48 | # Create a StreamHandler to send error logs to stderr 49 | stderr_handler = logging.StreamHandler(sys.stderr) 50 | stderr_handler.setLevel(logging.WARNING) 51 | 52 | # Create a formatter and set it on both handlers 53 | detailed_formatter = logging.Formatter( 54 | "%(name)s:%(lineno)d %(levelname)s: %(message)s" 55 | ) 56 | stdout_debug_handler.setFormatter(detailed_formatter) 57 | stderr_handler.setFormatter(detailed_formatter) 58 | return [stdout_debug_handler, stdout_info_handler, stderr_handler] 59 | -------------------------------------------------------------------------------- /trestlebot/cli/options/create.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | # Copyright (c) 2024 Red Hat, Inc. 3 | 4 | """ 5 | Module for common create commands 6 | """ 7 | 8 | import functools 9 | from typing import Any, Callable, Dict, Sequence, TypeVar 10 | 11 | import click 12 | 13 | 14 | F = TypeVar("F", bound=Callable[..., Any]) 15 | 16 | 17 | def common_create_options(f: F) -> F: 18 | """ 19 | Configuring common create options decorator for SSP and CD command 20 | """ 21 | 22 | @click.option( 23 | "--profile-name", 24 | prompt="Enter name of profile in trestle workspace to include", 25 | help="Name of profile in trestle workspace to include.", 26 | ) 27 | @click.option( 28 | "--markdown-dir", 29 | type=str, 30 | prompt="Enter path to store markdown files", 31 | default="markdown/", 32 | help="Directory name to store markdown files.", 33 | ) 34 | @functools.wraps(f) 35 | def wrapper_common_create_options( 36 | *args: Sequence[Any], **kwargs: Dict[Any, Any] 37 | ) -> Any: 38 | return f(*args, **kwargs) 39 | 40 | return wrapper_common_create_options 41 | -------------------------------------------------------------------------------- /trestlebot/cli/root.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | # Copyright (c) 2024 Red Hat, Inc. 3 | 4 | 5 | """Main entrypoint for trestlebot""" 6 | 7 | import click 8 | 9 | from trestlebot.cli.commands.autosync import autosync_cmd 10 | from trestlebot.cli.commands.create import create_cmd 11 | from trestlebot.cli.commands.init import init_cmd 12 | from trestlebot.cli.commands.rules_transform import rules_transform_cmd 13 | from trestlebot.cli.commands.sync_cac_content import sync_cac_content_cmd 14 | from trestlebot.cli.commands.sync_oscal_content import sync_oscal_content_cmd 15 | from trestlebot.cli.commands.sync_upstreams import sync_upstreams_cmd 16 | 17 | 18 | EPILOG = "See our docs at https://redhatproductsecurity.github.io/trestle-bot" 19 | 20 | CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"]) 21 | 22 | 23 | @click.group( 24 | name="trestlebot", 25 | help="Trestle-bot CLI", 26 | context_settings=CONTEXT_SETTINGS, 27 | epilog=EPILOG, 28 | ) 29 | @click.pass_context 30 | def root_cmd(ctx: click.Context) -> None: 31 | """Root command""" 32 | 33 | 34 | root_cmd.add_command(init_cmd) 35 | root_cmd.add_command(autosync_cmd) 36 | root_cmd.add_command(create_cmd) 37 | root_cmd.add_command(rules_transform_cmd) 38 | root_cmd.add_command(sync_cac_content_cmd) 39 | root_cmd.add_command(sync_upstreams_cmd) 40 | root_cmd.add_command(sync_oscal_content_cmd) 41 | 42 | if __name__ == "__main__": 43 | root_cmd() 44 | -------------------------------------------------------------------------------- /trestlebot/cli/utils.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | # Copyright (c) 2024 Red Hat, Inc. 3 | 4 | from typing import Any, Dict, List 5 | 6 | from trestlebot.bot import TrestleBot 7 | from trestlebot.reporter import BotResults 8 | from trestlebot.tasks.base_task import TaskBase 9 | 10 | 11 | def comma_sep_to_list(string: str) -> List[str]: 12 | """Convert comma-sep string to list of strings and strip.""" 13 | string = string.strip() if string else "" 14 | return list(map(str.strip, string.split(","))) if string else [] 15 | 16 | 17 | def run_bot(pre_tasks: List[TaskBase], kwargs: Dict[Any, Any]) -> BotResults: 18 | """Reusable logic for all commands.""" 19 | 20 | # Configure and run the bot 21 | bot = TrestleBot( 22 | working_dir=kwargs["repo_path"], 23 | branch=kwargs["branch"], 24 | commit_name=kwargs["committer_name"], 25 | commit_email=kwargs["committer_email"], 26 | author_name=kwargs.get("author_name", ""), 27 | author_email=kwargs.get("author_email", ""), 28 | ) 29 | 30 | return bot.run( 31 | pre_tasks=pre_tasks, 32 | patterns=kwargs.get("patterns", ["."]), 33 | commit_message=kwargs.get( 34 | "commit_message", "Automatic updates from trestle-bot" 35 | ), 36 | dry_run=kwargs.get("dry_run", False), 37 | ) 38 | -------------------------------------------------------------------------------- /trestlebot/const.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | # Copyright (c) 2023 Red Hat, Inc. 3 | 4 | 5 | """Global constants""" 6 | 7 | import trestle.common.const as trestle_const 8 | 9 | 10 | # Common exit codes 11 | SUCCESS_EXIT_CODE = 0 12 | ERROR_EXIT_CODE = 1 13 | INVALID_ARGS_EXIT_CODE = 2 14 | 15 | 16 | # SSP Index Fields 17 | 18 | PROFILE_KEY_NAME = "profile" 19 | COMPDEF_KEY_NAME = "component_definitions" 20 | LEVERAGED_SSP_KEY_NAME = "leveraged_ssp" 21 | YAML_HEADER_PATH_KEY_NAME = "yaml_header_path" 22 | 23 | # Rule YAML Fields 24 | RULE_INFO_TAG = trestle_const.TRESTLE_TAG + "rule-info" 25 | NAME = "name" 26 | DESCRIPTION = "description" 27 | PARAMETER = "parameter" 28 | CHECK = "check" 29 | PROFILE = "profile" 30 | HREF = "href" 31 | ALTERNATIVE_VALUES = "alternative-values" 32 | DEFAULT_KEY = "default" 33 | DEFAULT_VALUE = "default-value" 34 | TYPE = "type" 35 | INCLUDE_CONTROLS = "include-controls" 36 | 37 | COMPONENT_YAML = "component.yaml" 38 | COMPONENT_INFO_TAG = trestle_const.TRESTLE_TAG + "component-info" 39 | 40 | YAML_EXTENSION = ".yaml" 41 | 42 | RULES_VIEW_DIR = "rules" 43 | RULE_PREFIX = "rule-" 44 | 45 | 46 | # GitHub Actions Outputs 47 | COMMIT = "commit" 48 | PR_NUMBER = "pr_number" 49 | CHANGES = "changes" 50 | 51 | # Git Provider Types 52 | GITHUB = "github" 53 | GITLAB = "gitlab" 54 | GITHUB_SERVER_URL = "https://github.com" 55 | 56 | # Trestlebot init constants 57 | TRESTLEBOT_CONFIG_DIR = ".trestlebot" 58 | TRESTLEBOT_KEEP_FILE = ".keep" 59 | 60 | # Props 61 | 62 | # TODO(jpower432): Propose upstream as to be populated 63 | # by the profile or catalog "name" based on trestle workspace 64 | # conventions. 65 | FRAMEWORK_SHORT_NAME = "Framework_Short_Name" 66 | -------------------------------------------------------------------------------- /trestlebot/provider.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | # Copyright (c) 2023 Red Hat, Inc. 3 | 4 | 5 | """Base Git Provider class for the Trestle Bot.""" 6 | 7 | from __future__ import annotations 8 | 9 | import re 10 | from abc import ABC, abstractmethod 11 | from typing import Optional, Tuple 12 | from urllib.parse import ParseResult, urlparse 13 | 14 | 15 | class GitProviderException(Exception): 16 | """An error when interacting with a Git provider""" 17 | 18 | 19 | class GitProvider(ABC): 20 | """ 21 | Abstract base class for Git provider types 22 | """ 23 | 24 | @property 25 | @abstractmethod 26 | def provider_pattern(self) -> re.Pattern[str]: 27 | """Regex pattern to validate repository URLs""" 28 | 29 | def match_url(self, repo_url: str) -> Tuple[Optional[re.Match[str]], str]: 30 | """Match a repository URL with the pattern""" 31 | parsed_url: ParseResult = urlparse(repo_url) 32 | 33 | path = parsed_url.path 34 | stripped_url = path 35 | if host := parsed_url.hostname: 36 | stripped_url = f"{host}{path}" 37 | if scheme := parsed_url.scheme: 38 | stripped_url = f"{scheme}://{stripped_url}" 39 | return self.provider_pattern.match(stripped_url), stripped_url 40 | 41 | @abstractmethod 42 | def parse_repository(self, repository_url: str) -> Tuple[str, str]: 43 | """Parse repository information into namespace and repo, respectively""" 44 | 45 | @abstractmethod 46 | def create_pull_request( 47 | self, 48 | ns: str, 49 | repo_name: str, 50 | base_branch: str, 51 | head_branch: str, 52 | title: str, 53 | body: str, 54 | ) -> int: 55 | """Create a pull request for a specified branch and return the request number""" 56 | -------------------------------------------------------------------------------- /trestlebot/provider_factory.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | # Copyright (c) 2024 Red Hat, Inc. 3 | 4 | import logging 5 | from typing import Optional 6 | 7 | from trestlebot import const 8 | from trestlebot.github import GitHub 9 | from trestlebot.gitlab import GitLab 10 | from trestlebot.provider import GitProvider 11 | 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | 16 | class GitProviderFactory: 17 | """Factory class for creating Git provider objects""" 18 | 19 | @staticmethod 20 | def provider_factory(access_token: str, type: str, server_url: str) -> GitProvider: 21 | """ 22 | Factory class for creating Git provider objects 23 | 24 | Args: 25 | access_token: Access token for the Git provider 26 | type: Type of Git provider. Supported values are "github" or "gitlab" 27 | server_url: URL of the Git provider server 28 | 29 | Returns: 30 | a GitProvider object 31 | 32 | Raises: 33 | ValueError: If the server URL is provided for GitHub provider 34 | RuntimeError: If the Git provider cannot be detected 35 | 36 | 37 | Notes: The GitHub provider currently only supports GitHub and not 38 | GitHub Enterprise. So the server value must be https://github.com. 39 | """ 40 | 41 | git_provider: Optional[GitProvider] = None 42 | 43 | if type == const.GITHUB: 44 | logger.debug("Creating GitHub provider") 45 | if server_url and server_url != const.GITHUB_SERVER_URL: 46 | raise ValueError("GitHub provider does not support custom server URLs") 47 | git_provider = GitHub(access_token=access_token) 48 | elif type == const.GITLAB: 49 | logger.debug("Creating GitLab provider") 50 | if not server_url: 51 | # No server URL will use default https://gitlab.com 52 | git_provider = GitLab(api_token=access_token) 53 | else: 54 | git_provider = GitLab(api_token=access_token, server_url=server_url) 55 | 56 | if git_provider is None: 57 | raise RuntimeError("Could not determine Git provider from inputs") 58 | 59 | return git_provider 60 | -------------------------------------------------------------------------------- /trestlebot/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/complytime/trestle-bot/6c95b78310d2088063c052726a934742ca4b5759/trestlebot/py.typed -------------------------------------------------------------------------------- /trestlebot/reporter.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | # Copyright (c) 2024 Red Hat, Inc. 3 | 4 | """Results reporting for the Trestle Bot.""" 5 | 6 | from dataclasses import dataclass 7 | from typing import List 8 | 9 | 10 | @dataclass 11 | class BotResults: 12 | """A dataclass to hold the results of the bot run""" 13 | 14 | changes: List[str] 15 | commit_sha: str 16 | pr_number: int 17 | 18 | 19 | class ResultsReporter: 20 | """ 21 | Base class for reporting the results of the Trestle Bot. 22 | """ 23 | 24 | def report_results(self, results: BotResults) -> None: 25 | """ 26 | Report the results of the Trestle Bot. 27 | 28 | Args: 29 | results: BotResults object 30 | """ 31 | results_str = "" 32 | if results.commit_sha: 33 | results_str += f"\nCommit Hash: {results.commit_sha}" 34 | 35 | if results.pr_number: 36 | results_str += f"\nPull Request Number: {results.pr_number}" 37 | elif results.changes: 38 | results_str += "\nChanges:\n" 39 | results_str += ResultsReporter.get_changes_str(results.changes) 40 | else: 41 | results_str = "No changes detected" 42 | 43 | print(results_str) # noqa: T201 44 | 45 | @staticmethod 46 | def get_changes_str(changes: List[str]) -> str: 47 | """ 48 | Return a string representation of the changes. 49 | 50 | Notes: This method is starting off as a simple join of the changes list, 51 | but is intended to be expanded to provide more detailed information about the changes. 52 | The goal is consistent representation. 53 | """ 54 | return "\n".join(changes) 55 | -------------------------------------------------------------------------------- /trestlebot/tasks/__init__.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | # Copyright (c) 2023 Red Hat, Inc. 3 | 4 | """ 5 | Trestlebot tasks module. 6 | 7 | Tasks are the primary unit of work in trestlebot. 8 | They are designed to perform workspace 9 | operation for trestle workspace management. 10 | """ 11 | -------------------------------------------------------------------------------- /trestlebot/tasks/assemble_task.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | # Copyright (c) 2023 Red Hat, Inc. 3 | 4 | 5 | """Trestle Bot Assembly Tasks""" 6 | 7 | import logging 8 | import os 9 | import pathlib 10 | from typing import Optional 11 | 12 | from trestlebot import const 13 | from trestlebot.tasks.authored.base_authored import ( 14 | AuthoredObjectBase, 15 | AuthoredObjectException, 16 | ) 17 | from trestlebot.tasks.base_task import ModelFilter, TaskBase, TaskException 18 | 19 | 20 | logger = logging.getLogger(__name__) 21 | 22 | 23 | class AssembleTask(TaskBase): 24 | """ 25 | Assemble Markdown into OSCAL content 26 | """ 27 | 28 | def __init__( 29 | self, 30 | authored_object: AuthoredObjectBase, 31 | markdown_dir: str, 32 | version: str = "", 33 | model_filter: Optional[ModelFilter] = None, 34 | ) -> None: 35 | """ 36 | Initialize assemble task. 37 | 38 | Args: 39 | authored_object: Object can assembled OSCAL content into JSON 40 | markdown_dir: Location of directory to write Markdown in 41 | model_filter: Optional filter to apply to the task to include or exclude models 42 | from processing 43 | """ 44 | 45 | self._authored_object = authored_object 46 | self._markdown_dir = markdown_dir 47 | self._version = version 48 | working_dir = self._authored_object.get_trestle_root() 49 | super().__init__(working_dir, model_filter) 50 | 51 | def execute(self) -> int: 52 | """Execute task""" 53 | return self._assemble() 54 | 55 | def _assemble(self) -> int: 56 | """ 57 | Assemble all objects in markdown directory 58 | 59 | Returns: 60 | 0 on success, raises an exception if not successful 61 | """ 62 | search_path = os.path.join(self.working_dir, self._markdown_dir) 63 | if not os.path.exists(search_path): 64 | raise TaskException(f"Markdown directory {search_path} does not exist") 65 | 66 | for model in self.iterate_models(pathlib.Path(search_path)): 67 | # Construct model path from markdown path. AuthoredObject already has 68 | # the working dir data as part of object construction. 69 | logger.info(f"Assembling model {model}") 70 | model_base_name = os.path.basename(model) 71 | model_path = os.path.join(self._markdown_dir, model_base_name) 72 | try: 73 | self._authored_object.assemble( 74 | markdown_path=model_path, version_tag=self._version 75 | ) 76 | except AuthoredObjectException as e: 77 | raise TaskException(f"Assemble task failed for model {model_path}: {e}") 78 | 79 | return const.SUCCESS_EXIT_CODE 80 | -------------------------------------------------------------------------------- /trestlebot/tasks/authored/__init__.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | # Copyright (c) 2023 Red Hat, Inc. 3 | 4 | """Tasks for single instances of an authored OSCAL model.""" 5 | -------------------------------------------------------------------------------- /trestlebot/tasks/authored/base_authored.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | # Copyright (c) 2023 Red Hat, Inc. 3 | 4 | 5 | """Trestle Bot base authored object""" 6 | 7 | import os 8 | import pathlib 9 | from abc import ABC, abstractmethod 10 | 11 | from trestle.common.file_utils import is_valid_project_root 12 | 13 | 14 | class AuthoredObjectException(Exception): 15 | """An error during object authoring""" 16 | 17 | 18 | class AuthoredObjectBase(ABC): 19 | """ 20 | Abstract base class for OSCAL objects that are authored. 21 | """ 22 | 23 | def __init__(self, trestle_root: str) -> None: 24 | """Initialize task base and store trestle root path""" 25 | if not os.path.exists(trestle_root): 26 | raise AuthoredObjectException(f"Root path {trestle_root} does not exist") 27 | 28 | if not is_valid_project_root(pathlib.Path(trestle_root)): 29 | raise AuthoredObjectException( 30 | f"Root path {trestle_root} is not a valid trestle project root" 31 | ) 32 | 33 | self._trestle_root = trestle_root 34 | 35 | def get_trestle_root(self) -> str: 36 | """Return the trestle root directory""" 37 | return self._trestle_root 38 | 39 | @abstractmethod 40 | def assemble(self, markdown_path: str, version_tag: str = "") -> None: 41 | """Execute assemble for model path""" 42 | 43 | @abstractmethod 44 | def regenerate(self, model_path: str, markdown_path: str) -> None: 45 | """Execute regeneration for model path""" 46 | -------------------------------------------------------------------------------- /trestlebot/tasks/authored/catalog.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | # Copyright (c) 2023 Red Hat, Inc. 3 | 4 | 5 | """Trestle Bot functions for catalog authoring""" 6 | 7 | import os 8 | import pathlib 9 | 10 | from trestle.common.err import TrestleError 11 | from trestle.core.repository import AgileAuthoring 12 | 13 | from trestlebot.tasks.authored.base_authored import ( 14 | AuthoredObjectBase, 15 | AuthoredObjectException, 16 | ) 17 | 18 | 19 | class AuthoredCatalog(AuthoredObjectBase): 20 | """ 21 | Class for authoring OSCAL catalogs in automation 22 | """ 23 | 24 | def __init__(self, trestle_root: str) -> None: 25 | """ 26 | Initialize authored catalog. 27 | """ 28 | super().__init__(trestle_root) 29 | 30 | def assemble(self, markdown_path: str, version_tag: str = "") -> None: 31 | """Run assemble actions for catalog type at the provided path""" 32 | trestle_root = pathlib.Path(self.get_trestle_root()) 33 | catalog = os.path.basename(markdown_path) 34 | authoring = AgileAuthoring(trestle_root) 35 | try: 36 | success = authoring.assemble_catalog_markdown( 37 | name=catalog, 38 | output=catalog, 39 | markdown_dir=markdown_path, 40 | set_parameters=True, 41 | regenerate=False, 42 | version=version_tag, 43 | ) 44 | if not success: 45 | raise AuthoredObjectException( 46 | f"Unknown error occurred while assembling {catalog}" 47 | ) 48 | except TrestleError as e: 49 | raise AuthoredObjectException(f"Trestle assemble failed for {catalog}: {e}") 50 | 51 | def regenerate(self, model_path: str, markdown_path: str) -> None: 52 | """Run assemble actions for catalog type at the provided path""" 53 | trestle_root = pathlib.Path(self.get_trestle_root()) 54 | authoring = AgileAuthoring(trestle_root) 55 | 56 | catalog = os.path.basename(model_path) 57 | try: 58 | success = authoring.generate_catalog_markdown( 59 | name=catalog, 60 | output=os.path.join(markdown_path, catalog), 61 | force_overwrite=False, 62 | yaml_header=None, 63 | overwrite_header_values=False, 64 | ) 65 | if not success: 66 | raise AuthoredObjectException( 67 | f"Unknown error occurred while regenerating {catalog}" 68 | ) 69 | except TrestleError as e: 70 | raise AuthoredObjectException(f"Trestle generate failed for {catalog}: {e}") 71 | -------------------------------------------------------------------------------- /trestlebot/tasks/authored/types.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | # Copyright (c) 2023 Red Hat, Inc. 3 | 4 | 5 | """Trestle Bot authoring type information""" 6 | 7 | from enum import Enum 8 | 9 | from trestle.common import const 10 | 11 | from trestlebot.tasks.authored.base_authored import ( 12 | AuthoredObjectBase, 13 | AuthoredObjectException, 14 | ) 15 | from trestlebot.tasks.authored.catalog import AuthoredCatalog 16 | from trestlebot.tasks.authored.compdef import AuthoredComponentDefinition 17 | from trestlebot.tasks.authored.profile import AuthoredProfile 18 | from trestlebot.tasks.authored.ssp import AuthoredSSP, SSPIndex 19 | 20 | 21 | class AuthoredType(Enum): 22 | """Top-level OSCAL models that have authoring support""" 23 | 24 | CATALOG = "catalog" 25 | PROFILE = "profile" 26 | SSP = "ssp" 27 | COMPDEF = "compdef" 28 | 29 | 30 | def get_authored_object( 31 | input_type: str, working_dir: str, ssp_index_path: str = "" 32 | ) -> AuthoredObjectBase: 33 | """Determine and configure author object context""" 34 | if input_type == AuthoredType.CATALOG.value: 35 | return AuthoredCatalog(working_dir) 36 | elif input_type == AuthoredType.PROFILE.value: 37 | return AuthoredProfile(working_dir) 38 | elif input_type == AuthoredType.COMPDEF.value: 39 | return AuthoredComponentDefinition(working_dir) 40 | elif input_type == AuthoredType.SSP.value: 41 | ssp_index: SSPIndex = SSPIndex(ssp_index_path) 42 | return AuthoredSSP(working_dir, ssp_index) 43 | else: 44 | raise AuthoredObjectException(f"Invalid authored type {input_type}") 45 | 46 | 47 | def get_trestle_model_dir(authored_object: AuthoredObjectBase) -> str: 48 | """Determine directory for JSON content in trestle""" 49 | if isinstance(authored_object, AuthoredCatalog): 50 | return const.MODEL_DIR_CATALOG 51 | elif isinstance(authored_object, AuthoredProfile): 52 | return const.MODEL_DIR_PROFILE 53 | elif isinstance(authored_object, AuthoredComponentDefinition): 54 | return const.MODEL_DIR_COMPDEF 55 | elif isinstance(authored_object, AuthoredSSP): 56 | return const.MODEL_DIR_SSP 57 | else: 58 | raise AuthoredObjectException( 59 | f"Invalid authored object {type(authored_object)}" 60 | ) 61 | -------------------------------------------------------------------------------- /trestlebot/tasks/base_task.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | # Copyright (c) 2023 Red Hat, Inc. 3 | 4 | 5 | """Trestle Bot base task for extensible bot pre-tasks""" 6 | 7 | import fnmatch 8 | import pathlib 9 | from abc import ABC, abstractmethod 10 | from typing import Callable, Iterable, List, Optional 11 | 12 | from trestle.common import const 13 | from trestle.common.file_utils import is_hidden 14 | 15 | 16 | class TaskException(Exception): 17 | """An error during task execution""" 18 | 19 | 20 | class ModelFilter: 21 | """ 22 | Filter models based on include and exclude patterns. 23 | 24 | Args: 25 | skip_patterns: List of glob patterns to exclude from processing. 26 | include_patterns: List of glob patterns to include in processing. 27 | 28 | Note: If a model is in both the include and exclude lists, it will be excluded. 29 | The skip list is applied first. 30 | """ 31 | 32 | def __init__(self, skip_patterns: List[str], include_patterns: List[str]): 33 | self._include_model_list: List[str] = include_patterns 34 | self._skip_model_list: List[str] = [const.TRESTLE_KEEP_FILE] + skip_patterns 35 | 36 | def is_skipped(self, model_path: pathlib.Path) -> bool: 37 | """Check if the model is skipped through include or skip lists.""" 38 | if any( 39 | fnmatch.fnmatch(model_path.name, pattern) 40 | for pattern in self._skip_model_list 41 | ): 42 | return True 43 | elif any( 44 | fnmatch.fnmatch(model_path.name, pattern) 45 | for pattern in self._include_model_list 46 | ): 47 | return False 48 | else: 49 | return True 50 | 51 | 52 | class TaskBase(ABC): 53 | """ 54 | Abstract base class for tasks with a work directory. 55 | """ 56 | 57 | def __init__(self, working_dir: str, model_filter: Optional[ModelFilter]) -> None: 58 | """ 59 | Initialize base task. 60 | 61 | Args: 62 | working_dir: Working directory to complete operations in. 63 | model_filter: Model filter to use for this task. 64 | """ 65 | self._working_dir = working_dir 66 | self.filter: Optional[ModelFilter] = model_filter 67 | 68 | @property 69 | def working_dir(self) -> str: 70 | """Return the working directory""" 71 | return self._working_dir 72 | 73 | def iterate_models(self, directory_path: pathlib.Path) -> Iterable[pathlib.Path]: 74 | """Iterate over the models in the working directory""" 75 | filtered_paths: Iterable[pathlib.Path] 76 | 77 | if self.filter is not None: 78 | is_skipped: Callable[[pathlib.Path], bool] = self.filter.is_skipped 79 | filtered_paths = list( 80 | filter( 81 | lambda p: not is_skipped(p) and (not is_hidden(p) or p.is_dir()), 82 | pathlib.Path.iterdir(directory_path), 83 | ) 84 | ) 85 | else: 86 | filtered_paths = list( 87 | filter( 88 | lambda p: not is_hidden(p) or p.is_dir(), 89 | pathlib.Path.iterdir(directory_path), 90 | ) 91 | ) 92 | 93 | return filtered_paths.__iter__() 94 | 95 | @abstractmethod 96 | def execute(self) -> int: 97 | """Execute the task and return the exit code""" 98 | pass 99 | -------------------------------------------------------------------------------- /trestlebot/tasks/regenerate_task.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | # Copyright (c) 2023 Red Hat, Inc. 3 | 4 | 5 | """Trestle Bot Regenerate Tasks""" 6 | 7 | import logging 8 | import os 9 | import pathlib 10 | from typing import Optional 11 | 12 | from trestlebot import const 13 | from trestlebot.tasks.authored import types 14 | from trestlebot.tasks.authored.base_authored import ( 15 | AuthoredObjectBase, 16 | AuthoredObjectException, 17 | ) 18 | from trestlebot.tasks.base_task import ModelFilter, TaskBase, TaskException 19 | 20 | 21 | logger = logging.getLogger(__name__) 22 | 23 | 24 | class RegenerateTask(TaskBase): 25 | """ 26 | Regenerate Trestle Markdown from OSCAL JSON content changes 27 | """ 28 | 29 | def __init__( 30 | self, 31 | authored_object: AuthoredObjectBase, 32 | markdown_dir: str, 33 | model_filter: Optional[ModelFilter] = None, 34 | ) -> None: 35 | """ 36 | Initialize regenerate task. 37 | 38 | Args: 39 | authored_object: Object that can regenerate Markdown content from JSON 40 | markdown_dir: Location of directory to write Markdown in 41 | model_filter: Optional filter to apply to the task to include or exclude models 42 | from processing. 43 | """ 44 | 45 | self._authored_object = authored_object 46 | self._markdown_dir = markdown_dir 47 | working_dir = self._authored_object.get_trestle_root() 48 | super().__init__(working_dir, model_filter) 49 | 50 | def execute(self) -> int: 51 | """Execute task""" 52 | return self._regenerate() 53 | 54 | def _regenerate(self) -> int: 55 | """ 56 | Regenerate all objects in model JSON directory 57 | 58 | Returns: 59 | 0 on success, raises an exception if not successful 60 | """ 61 | model_dir = types.get_trestle_model_dir(self._authored_object) 62 | 63 | search_path = os.path.join(self.working_dir, model_dir) 64 | for model in self.iterate_models(pathlib.Path(search_path)): 65 | logger.info(f"Regenerating model {model}") 66 | model_base_name = os.path.basename(model) 67 | model_path = os.path.join(model_dir, model_base_name) 68 | 69 | try: 70 | self._authored_object.regenerate( 71 | model_path=model_path, markdown_path=self._markdown_dir 72 | ) 73 | except AuthoredObjectException as e: 74 | raise TaskException(f"Regenerate task failed for model {model}: {e}") 75 | 76 | return const.SUCCESS_EXIT_CODE 77 | -------------------------------------------------------------------------------- /trestlebot/tasks/sync_oscal_content_catalog_task.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import pathlib 3 | from typing import Dict 4 | 5 | from ruamel.yaml import CommentedMap 6 | from trestle.common.model_utils import ModelUtils 7 | from trestle.core.catalog.catalog_interface import CatalogInterface 8 | from trestle.core.control_interface import ControlInterface 9 | from trestle.core.models.file_content_type import FileContentType 10 | from trestle.oscal.catalog import Catalog, Control 11 | 12 | from trestlebot.const import SUCCESS_EXIT_CODE 13 | from trestlebot.tasks.base_task import TaskBase 14 | from trestlebot.utils import ( 15 | populate_if_dict_field_not_exist, 16 | read_cac_yaml_ordered, 17 | write_cac_yaml_ordered, 18 | ) 19 | 20 | 21 | logger = logging.getLogger(__name__) 22 | 23 | 24 | class SyncOscalCatalogTask(TaskBase): 25 | """Sync OSCAL catalog to CaC content task.""" 26 | 27 | def __init__( 28 | self, 29 | cac_content_root: pathlib.Path, 30 | working_dir: str, 31 | cac_policy_id: str, 32 | ) -> None: 33 | """Initialize task.""" 34 | super().__init__(working_dir, None) 35 | self.cac_content_root = cac_content_root 36 | self.cac_policy_id = cac_policy_id 37 | self.catalog_controls: Dict[str, Control] = {} 38 | self.control_file_path = pathlib.Path( 39 | self.cac_content_root, "controls", f"{self.cac_policy_id}.yml" 40 | ) 41 | self.oscal_to_cac_map: Dict[str, str] = {} 42 | 43 | def get_catalog_controls(self, catalog: Catalog) -> Dict[str, Control]: 44 | """ 45 | Get all controls from a catalog. 46 | """ 47 | controls = { 48 | control.id: control 49 | for control in CatalogInterface(catalog).get_all_controls_from_catalog( 50 | recurse=True 51 | ) 52 | } 53 | return controls 54 | 55 | def get_oscal_to_cac_map(self, catalog: Catalog) -> Dict[str, str]: 56 | """ 57 | Get oscal_control_id to cac_control_id map 58 | """ 59 | result = {} 60 | for control in CatalogInterface(catalog).get_all_controls_from_catalog( 61 | recurse=True 62 | ): 63 | label = ControlInterface.get_label(control) 64 | if label: 65 | result[control.id] = label 66 | 67 | return result 68 | 69 | def sync_description(self, cac_control_map: Dict[str, CommentedMap]) -> None: 70 | """ 71 | Sync OSCAL catalog parts field to CaC control file description field 72 | """ 73 | for oscal_control_id, oscal_control in self.catalog_controls.items(): 74 | cac_control_id = self.oscal_to_cac_map.get(oscal_control_id) 75 | if not cac_control_id: 76 | continue 77 | 78 | cac_control = cac_control_map.get(cac_control_id) 79 | if not cac_control: 80 | continue 81 | parts_statement = ControlInterface.get_part_prose( 82 | oscal_control, "statement" 83 | ) 84 | description = cac_control.get("description") 85 | if not description and not parts_statement: 86 | continue 87 | 88 | populate_if_dict_field_not_exist(cac_control, "description", "") 89 | cac_control["description"] = parts_statement 90 | 91 | def sync_oscal_catalog(self) -> None: 92 | """ 93 | Sync OSCAL catalog information to CaC control file. 94 | """ 95 | data = read_cac_yaml_ordered(self.control_file_path) 96 | cac_control_map = { 97 | control["id"]: control for control in data.get("controls", []) 98 | } 99 | self.sync_description(cac_control_map) 100 | write_cac_yaml_ordered(self.control_file_path, data) 101 | 102 | def execute(self) -> int: 103 | oscal_json = ModelUtils.get_model_path_for_name_and_class( 104 | self.working_dir, self.cac_policy_id, Catalog, FileContentType.JSON 105 | ) 106 | 107 | if not oscal_json.exists(): 108 | raise RuntimeError(f"{oscal_json} does not exist") 109 | 110 | oscal_catalog = Catalog.oscal_read(oscal_json) 111 | self.catalog_controls = self.get_catalog_controls(oscal_catalog) 112 | self.oscal_to_cac_map = self.get_oscal_to_cac_map(oscal_catalog) 113 | self.sync_oscal_catalog() 114 | 115 | return SUCCESS_EXIT_CODE 116 | -------------------------------------------------------------------------------- /trestlebot/transformers/__init__.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | # Copyright (c) 2023 Red Hat, Inc. 3 | 4 | """ 5 | Trestlebot transformers. 6 | 7 | Custom transformers using trestle libs for trestlebot. 8 | 9 | The RulesTransformer class is the base class for all transformers pertaining 10 | to TrestleRule objects. This allows us to have a common interface for all formats 11 | that express rules. 12 | """ 13 | -------------------------------------------------------------------------------- /trestlebot/transformers/base_transformer.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | # Copyright (c) 2023 Red Hat, Inc. 3 | 4 | 5 | """Base transformer for rules.""" 6 | 7 | # Evaluate if this should be contributed back to trestle 8 | 9 | from abc import abstractmethod 10 | from typing import Any 11 | 12 | from trestle.transforms.transformer_factory import TransformerBase 13 | 14 | from trestlebot.transformers.trestle_rule import TrestleRule 15 | 16 | 17 | class ToRulesTransformer(TransformerBase): 18 | """Abstract interface for transforming to rule data.""" 19 | 20 | @abstractmethod 21 | def transform(self, data: Any) -> TrestleRule: 22 | """Transform to rule data.""" 23 | 24 | 25 | class FromRulesTransformer(TransformerBase): 26 | """Abstract interface for transforming from rule data.""" 27 | 28 | @abstractmethod 29 | def transform(self, rule: TrestleRule) -> Any: 30 | """Transform from rule data.""" 31 | 32 | 33 | class RulesTransformerException(Exception): 34 | """An error during transformation of a rule""" 35 | -------------------------------------------------------------------------------- /trestlebot/utils.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | # Copyright (c) 2024 Red Hat, Inc. 3 | 4 | """Common utility functions.""" 5 | import os 6 | import pathlib 7 | import textwrap 8 | from typing import Any, List 9 | 10 | from ruamel.yaml import YAML, CommentedMap, CommentToken 11 | from ruamel.yaml.scalarstring import LiteralScalarString 12 | from ssg.controls import ControlsManager 13 | from ssg.products import load_product_yaml, product_yaml_path 14 | 15 | 16 | def populate_if_dict_field_not_exist( 17 | data: CommentedMap, field_name: str, default_value: Any 18 | ) -> Any: 19 | """ 20 | Set field with default value if a CommentedMap field does not exist, 21 | if filed exists, this is a no-op 22 | return field value 23 | """ 24 | if data.get(field_name) is None: 25 | # insert new filed to -2 position, avoid extra newline 26 | data.insert(len(data) - 1, field_name, default_value) 27 | 28 | return data[field_name] 29 | 30 | 31 | def get_comments_from_yaml_data(yaml_data: Any) -> List[str]: 32 | """ 33 | Get all comments from yaml_data, yaml_data must be read 34 | using ruamel.yaml library 35 | """ 36 | comments: List[str] = [] 37 | if not yaml_data.ca.items: 38 | return comments 39 | 40 | for _, comment_info in yaml_data.ca.items.items(): 41 | for comment in comment_info: 42 | if not comment: 43 | continue 44 | 45 | if isinstance(comment, List): 46 | for c in comment: 47 | if isinstance(c, CommentToken): 48 | comments.append(c.value) 49 | elif isinstance(comment, CommentToken): 50 | comments.append(comment.value) 51 | 52 | return comments 53 | 54 | 55 | def get_field_comment(data: CommentedMap, field_name: str) -> List[str]: 56 | """ 57 | Get comments under specific field from data, data must be read 58 | using ruamel.yaml library 59 | """ 60 | result = [] 61 | comments = data.ca.items.get(field_name, []) 62 | 63 | for comment in comments: 64 | if not comment: 65 | continue 66 | 67 | if isinstance(comment, List): 68 | for c in comment: 69 | if isinstance(c, CommentToken): 70 | result.append(c.value) 71 | elif isinstance(comment, CommentToken): 72 | result.append(comment.value) 73 | 74 | return result 75 | 76 | 77 | def read_cac_yaml_ordered(file_path: pathlib.Path) -> Any: 78 | """ 79 | Read data from CaC content yaml file while preserving the order 80 | """ 81 | yaml = YAML() 82 | yaml.preserve_quotes = True 83 | return yaml.load(file_path) 84 | 85 | 86 | def write_cac_yaml_ordered(file_path: pathlib.Path, data: Any) -> None: 87 | """ 88 | Serializes a Python object into a CaC content YAML stream, preserving the order. 89 | """ 90 | yaml = YAML() 91 | yaml.indent(mapping=4, sequence=6, offset=4) 92 | yaml.dump(data, file_path) 93 | 94 | 95 | def load_controls_manager(cac_content_root: str, product: str) -> ControlsManager: 96 | """ 97 | Loads and initializes a ControlsManager instance. 98 | """ 99 | product_yml_path = product_yaml_path(cac_content_root, product) 100 | product_yaml = load_product_yaml(product_yml_path) 101 | controls_dir = os.path.join(cac_content_root, "controls") 102 | control_mgr = ControlsManager(controls_dir, product_yaml) 103 | control_mgr.load() 104 | return control_mgr 105 | 106 | 107 | def to_literal_scalar_string(s: str) -> LiteralScalarString: 108 | """ 109 | Convert a string to a literal scalar string. 110 | """ 111 | return LiteralScalarString(textwrap.dedent(s)) 112 | --------------------------------------------------------------------------------