diff --git a/.github/workflows/sbom-inventory-scheduler.yml b/.github/workflows/sbom-inventory-scheduler.yml index 86568e326c..8810c702fd 100644 --- a/.github/workflows/sbom-inventory-scheduler.yml +++ b/.github/workflows/sbom-inventory-scheduler.yml @@ -1,22 +1,23 @@ # Central SBOM inventory aggregator. # -# Scheduled companion to sbom-generation.yml. It reads every managed repo's +# Hourly companion to sbom-generation.yml. It reads every non-fork repository's # latest SBOM back out of the GitHub dependency graph (populated by the # per-repo SBOM Generation dependency snapshot) and writes ONE consolidated org # inventory into this .github repo: # # docs/sbom/inventory.json machine-readable component roll-up -# docs/sbom/inventory.md component + license roll-up (flags copyleft / -# NOASSERTION against the commercial-license-only policy) +# docs/sbom/inventory.md component + license roll-up for commercial-policy review # -# Cross-repo reads reuse the OpenCode app OIDC token exchange the other -# schedulers use, falling back to github.token. Results land through a PR so the -# central inventory update follows the same review path as everything else. +# Cross-repo reads require the OpenCode app OIDC token exchange or the dedicated +# organization-wide SBOM token. A repository-scoped github.token is deliberately +# not a fallback because a partial private-repository view must never publish as +# a complete organization inventory. Results land through a PR so the central +# inventory update follows the same review path as everything else. name: SBOM Inventory Scheduler on: schedule: - - cron: "0 6 * * 1" + - cron: "0 * * * *" repository_dispatch: types: [sbom-inventory] @@ -103,12 +104,23 @@ jobs: echo "token=$app_token" } >>"$GITHUB_OUTPUT" + - name: Require organization-wide SBOM credential + env: + GH_TOKEN: ${{ secrets.SBOM_INVENTORY_TOKEN || steps.aggregator_app_token.outputs.token }} + run: | + set -euo pipefail + if [ -z "${GH_TOKEN:-}" ]; then + echo "Organization-wide SBOM credential unavailable; refusing partial inventory." >&2 + exit 1 + fi + echo "::add-mask::$GH_TOKEN" + - name: Checkout trusted aggregator uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: repository: ContextualWisdomLab/.github ref: main - fetch-depth: 1 + fetch-depth: 0 persist-credentials: false - name: Set up Python @@ -119,37 +131,90 @@ jobs: - name: Self-test aggregator run: python3 scripts/ci/sbom_inventory_aggregator.py --self-test + - name: Discover live non-fork repositories + env: + GH_TOKEN: ${{ secrets.SBOM_INVENTORY_TOKEN || steps.aggregator_app_token.outputs.token }} + run: | + set -euo pipefail + repos_json="$( + gh repo list \ + --no-archived \ + --limit 500 \ + --json "nameWithOwner,isFork" \ + -- \ + "$ORG_LOGIN" + )" + mapfile -t repos < <( + jq -r '.[] | select(.isFork == false) | .nameWithOwner' <<<"$repos_json" + ) + if [ "${#repos[@]}" -eq 0 ]; then + echo "No live non-fork repositories were discovered for $ORG_LOGIN." >&2 + exit 1 + fi + printf '%s\n' "${repos[@]}" >"$RUNNER_TEMP/cwl-nonfork-repositories.txt" + echo "Discovered ${#repos[@]} live non-fork repositories." + - name: Aggregate org SBOM inventory env: - GH_TOKEN: ${{ secrets.SBOM_INVENTORY_TOKEN || steps.aggregator_app_token.outputs.token || github.token }} + GH_TOKEN: ${{ secrets.SBOM_INVENTORY_TOKEN || steps.aggregator_app_token.outputs.token }} run: | set -euo pipefail + repo_args=() + while IFS= read -r repo; do + if [ -n "$repo" ]; then + repo_args+=(--repo "$repo") + fi + done <"$RUNNER_TEMP/cwl-nonfork-repositories.txt" + if [ "${#repo_args[@]}" -eq 0 ]; then + echo "Non-fork repository evidence file was empty." >&2 + exit 1 + fi generated_at="$(date -u +'%Y-%m-%dT%H:%M:%SZ')" python3 scripts/ci/sbom_inventory_aggregator.py \ - --org "$ORG_LOGIN" \ --output-dir docs/sbom \ - --generated-at "$generated_at" + --generated-at "$generated_at" \ + "${repo_args[@]}" - name: Open or update inventory PR env: - GH_TOKEN: ${{ secrets.SBOM_INVENTORY_TOKEN || steps.aggregator_app_token.outputs.token || github.token }} + GH_TOKEN: ${{ secrets.SBOM_INVENTORY_TOKEN || steps.aggregator_app_token.outputs.token }} run: | set -euo pipefail if git diff --quiet -- docs/sbom; then echo "No SBOM inventory changes; nothing to publish." exit 0 fi + branch="automation/sbom-inventory" git config user.name "cwl-sbom-inventory[bot]" git config user.email "cwl-sbom-inventory@users.noreply.github.com" - git checkout -B "$branch" git add docs/sbom git commit -m "chore: refresh org SBOM inventory" - git push --force-with-lease origin "$branch" + + # persist-credentials remains false; configure Git's credential helper + # from the already masked GH_TOKEN without putting the token in a URL. + gh auth setup-git + + # Preserve the existing publication head as ancestry without trusting + # its generated tree. A concurrent writer makes the final normal push + # fail closed instead of rewriting remote history. + if git ls-remote --exit-code --heads origin "refs/heads/$branch" >/dev/null 2>&1; then + git fetch --no-tags origin "refs/heads/$branch" + previous_head="$(git rev-parse FETCH_HEAD)" + if ! git merge-base --is-ancestor "$previous_head" HEAD; then + git merge \ + --strategy=ours \ + --no-edit \ + -m "chore: preserve SBOM inventory publication lineage" \ + "$previous_head" + fi + fi + + git push origin "HEAD:refs/heads/$branch" if [ -z "$(gh pr list --head "$branch" --state open --json number --jq '.[].number')" ]; then gh pr create \ --base main \ --head "$branch" \ --title "chore: refresh org SBOM inventory" \ - --body "Automated central SBOM inventory refresh. Review the license roll-up in docs/sbom/inventory.md for any flagged copyleft/NOASSERTION components." + --body "Automated central SBOM inventory refresh for live non-fork repositories. Review reciprocal, restricted, and NOASSERTION license evidence in docs/sbom/inventory.md against the product's actual distribution and hosted-service model." fi diff --git a/docs/doctoring/hourly-commercial-license-sbom-remediation.md b/docs/doctoring/hourly-commercial-license-sbom-remediation.md new file mode 100644 index 0000000000..d4ed9b0128 --- /dev/null +++ b/docs/doctoring/hourly-commercial-license-sbom-remediation.md @@ -0,0 +1,45 @@ +# Hourly commercial-license SBOM remediation + +Status: implementation evidence for the central ContextualWisdomLab supply-chain control plane. +Scope: live repositories whose GitHub metadata proves `fork=false`; forks are provenance evidence only and are never owner-side remediation targets. + +## Observed gap + +At `ContextualWisdomLab/.github@5f81d8e665b7d3f51f379a090e077486dbf548c5`, the central SBOM inventory still reports `pending first scheduled run`, zero repositories, and zero components. The scheduler runs only once a week and delegates organization discovery to an aggregator that does not itself exclude forks on protected `main`. That combination can make a zero-finding report look materially cleaner than the evidence actually supports. + +The existing license classifier is intentionally high-recall but is not a legal conclusion: it substring-flags GPL/AGPL/LGPL/MPL/EPL/CDDL and related expressions plus `NOASSERTION`. A flagged component therefore means **commercial-policy review is required**, not “commercial use is forbidden.” The GNU GPL explicitly permits selling copies; obligations depend on how covered code is combined, modified, conveyed, or offered as a network service. AGPLv3 adds a corresponding-source obligation for users interacting remotely with a modified covered program under section 13. + +## Decision + +1. Refresh the organization inventory every hour. +2. Build the owned target set from live GitHub repository metadata and admit only entries with `isFork == false` before any SBOM collection. +3. Require an organization-wide SBOM credential before discovery or collection. The repository-scoped `github.token` is not an acceptable fallback because it can silently hide private sibling repositories; absence of the dedicated token or successful OpenCode app exchange fails closed instead of publishing a partial inventory. +4. Reconcile SPDX/CycloneDX evidence with manifests, lockfiles, vendored/native/binary assets, container inputs, generated packages, and dependency-graph evidence before calling an inventory complete. +5. Interpret license expressions as evidence requiring an explicit `allow`, `review`, or `replace/block` outcome tied to the actual product distribution and hosted-service model. Do not equate copyleft with non-commercial use. +6. For an actionable incompatibility, remediate in this order: remove an unused component; replace it with a maintained permissively licensed equivalent; implement only the bounded required capability cleanly in-house from independent product/API/standards behavior; isolate it behind an independently deployed service/process boundary only when that genuinely changes the technical and legal coupling; or redesign the feature to remove the dependency. +7. A replacement implementation must not copy protected source, tests, comments, data, expressive structure, or other copyrightable material from the incompatible implementation. Product contracts, published standards, independent interoperability documentation, and lawful black-box behavior are the acceptable specification sources. +8. Update manifests and lockfiles, SBOMs, NOTICE/THIRD_PARTY_NOTICES, tests, architecture/ADR evidence, CHANGELOG when release-relevant, and `docs/product-technical-gap-baseline.md`; then rerun exact-head Checks/reviews and merge only through ordinary branch protection. +9. Preserve concurrent writers. The recurring inventory publication branch must advance without history rewriting; a race fails closed and is retried on a later run. Because checkout deliberately keeps `persist-credentials: false`, publication establishes Git authentication through the masked organization-wide `GH_TOKEN` with `gh auth setup-git` before the first remote Git operation. + +## Standards and interpretation baseline + +- SPDX 3.0 is the current SPDX document specification; SPDX is standardized as ISO/IEC 5962:2021. SBOM license identifiers and expressions are machine contracts and must not be reduced to free-text substring heuristics for final policy decisions. +- CycloneDX 1.7 is the current stable BOM specification and ECMA-424 2nd Edition. CycloneDX 2.0 is announced for 2026 but is not yet the stable baseline as of 2026-09-01. +- GPL-family software can be used commercially. The engineering concern for ContextualWisdomLab is whether the concrete incorporation, modification, conveyance, hosted-service behavior, source-offer obligation, attribution, patent terms, or reciprocal scope conflicts with the intended proprietary/commercial product contract. +- Unknown (`NOASSERTION`/unlicensed) and explicitly non-commercial, evaluation-only, field-of-use, or source-available restrictions fail closed into review until provenance and rights are established. + +This is an engineering governance policy and evidence record, not legal advice. Ambiguous rights or license compatibility that cannot be resolved from authoritative terms remains a legal-rights blocker rather than being guessed by automation. + +## Verification contract + +The scheduler contract is executable in `tests/test_sbom_inventory_scheduler_contract.py`: it binds assertions to the named executable discovery, aggregation, credential, and publication steps; requires an hourly cron; requires live `isFork == false` filtering; passes only the verified repositories explicitly to the aggregator; rejects `github.token` fallback; configures authenticated Git before remote publication; and prohibits force-push behavior. The first inventory run after merge is not considered complete merely because it reports zero findings; unavailable SBOMs and incomplete dependency materialization remain explicit defects to repair. + +## References + +Free Software Foundation. (n.d.). *Frequently asked questions about the GNU licenses*. https://www.gnu.org/licenses/gpl-faq.html + +Free Software Foundation. (2007). *GNU Affero General Public License, version 3*. https://www.gnu.org/licenses/agpl-3.0.html + +OWASP Foundation. (2025). *CycloneDX specification 1.7 (ECMA-424, 2nd ed.)*. https://cyclonedx.org/specification/overview/ + +SPDX Workgroup. (n.d.). *SPDX specifications*. Linux Foundation. https://spdx.dev/use/specifications/ diff --git a/tests/test_sbom_inventory_scheduler_contract.py b/tests/test_sbom_inventory_scheduler_contract.py new file mode 100644 index 0000000000..f181dd0891 --- /dev/null +++ b/tests/test_sbom_inventory_scheduler_contract.py @@ -0,0 +1,72 @@ +"""Executable contract for the central SBOM inventory scheduler.""" + +from pathlib import Path + + +WORKFLOW = Path(".github/workflows/sbom-inventory-scheduler.yml") + + +def _workflow_text() -> str: + """Return the scheduler source as text for dependency-free contract checks.""" + return WORKFLOW.read_text(encoding="utf-8") + + +def _step_body(name: str) -> str: + """Return one named executable workflow step, excluding later steps.""" + workflow = _workflow_text() + marker = f" - name: {name}\n" + start = workflow.index(marker) + next_step = workflow.find("\n - name: ", start + len(marker)) + return workflow[start : next_step if next_step != -1 else len(workflow)] + + +def test_sbom_inventory_scheduler_runs_hourly() -> None: + """Organization license evidence must refresh once each hour.""" + workflow = _workflow_text() + assert 'cron: "0 * * * *"' in workflow + assert 'cron: "0 6 * * 1"' not in workflow + + +def test_sbom_inventory_scheduler_requires_cross_repo_credential() -> None: + """Repository-scoped github.token must never publish a partial org inventory.""" + workflow = _workflow_text() + credential_step = _step_body("Require organization-wide SBOM credential") + assert "|| github.token" not in workflow + assert ( + "GH_TOKEN: ${{ secrets.SBOM_INVENTORY_TOKEN || steps.aggregator_app_token.outputs.token }}" + in credential_step + ) + assert 'if [ -z "${GH_TOKEN:-}" ]; then' in credential_step + assert "refusing partial inventory" in credential_step + assert "exit 1" in credential_step + + +def test_sbom_inventory_scheduler_excludes_forks_before_collection() -> None: + """Only repositories proven non-forks may become owned inventory targets.""" + discovery_step = _step_body("Discover live non-fork repositories") + aggregation_step = _step_body("Aggregate org SBOM inventory") + assert "gh repo list" in discovery_step + assert '"nameWithOwner,isFork"' in discovery_step + assert ".[] | select(.isFork == false) | .nameWithOwner" in discovery_step + assert "cwl-nonfork-repositories.txt" in discovery_step + assert 'repo_args+=(--repo "$repo")' in aggregation_step + assert '"${repo_args[@]}"' in aggregation_step + assert '--org "$ORG_LOGIN"' not in aggregation_step + + +def test_sbom_inventory_scheduler_authenticates_git_before_publication() -> None: + """The non-persistent checkout must establish Git auth before remote mutation.""" + publication_step = _step_body("Open or update inventory PR") + auth_index = publication_step.index("gh auth setup-git") + first_remote_index = min( + publication_step.index("git ls-remote"), + publication_step.index("git push"), + ) + assert auth_index < first_remote_index + + +def test_sbom_inventory_scheduler_does_not_force_push() -> None: + """Recurring publication must preserve concurrent branch history.""" + publication_step = _step_body("Open or update inventory PR") + assert "--force" not in publication_step + assert "--force-with-lease" not in publication_step