diff --git a/.api.env.example b/.api.env.example index 11b8100..8ccbf34 100644 --- a/.api.env.example +++ b/.api.env.example @@ -1,8 +1,34 @@ EVYDENCE_ADDR=:8080 EVYDENCE_API_KEY_PEPPER=change-me-long-random-pepper EVYDENCE_DATABASE_URL=postgres://evydence:change-me@localhost:5432/evydence?sslmode=disable +# Local default is snapshot_preferred. Production defaults to relational_only when unset. +# EVYDENCE_POSTGRES_LOAD_MODE=relational_only EVYDENCE_OBJECT_STORE=filesystem EVYDENCE_OBJECT_DIR=./tmp/objects +EVYDENCE_RATE_LIMIT_REQUESTS_PER_MINUTE=0 +# Optional hardening mode for parser-backed uploads. When true, the API stores +# accepted records and the outbox worker writes parser-derived fields and +# OpenVEX-derived vulnerability decisions. +EVYDENCE_WORKER_OWNED_PARSER_SIDE_EFFECTS=false +EVYDENCE_SIGNING_KEY_MODE=external +# Optional external signing gateway. Use HTTPS outside localhost. +# EVYDENCE_SIGNING_EXECUTOR_URL=https://signer.example.test/sign +# EVYDENCE_SIGNING_EXECUTOR_TOKEN=replace-with-signing-gateway-token +# EVYDENCE_SIGNING_EXECUTOR_TIMEOUT_SECONDS=10 +# Optional AWS KMS signing executor. Set EVYDENCE_SIGNING_KEY_MODE=aws-kms. +# EVYDENCE_AWS_REGION=eu-north-1 +# EVYDENCE_AWS_KMS_KEY_ID=alias/evydence-release +# EVYDENCE_AWS_KMS_SIGNING_ALGORITHM=ECDSA_SHA_256 +# EVYDENCE_AWS_KMS_TIMEOUT_SECONDS=10 +# Optional OIDC discovery refresh tuning. HTTP is allowed only for localhost when explicitly enabled. +# EVYDENCE_OIDC_DISCOVERY_TIMEOUT_SECONDS=10 +# EVYDENCE_OIDC_DISCOVERY_ALLOW_INSECURE_LOCALHOST=false +# Optional OIDC UserInfo live validation tuning. HTTP is allowed only for localhost when explicitly enabled. +# EVYDENCE_OIDC_USERINFO_TIMEOUT_SECONDS=10 +# EVYDENCE_OIDC_USERINFO_ALLOW_INSECURE_LOCALHOST=false +# Optional public transparency proof fetch tuning. HTTP is allowed only for localhost when explicitly enabled. +# EVYDENCE_TRANSPARENCY_FETCH_TIMEOUT_SECONDS=10 +# EVYDENCE_TRANSPARENCY_FETCH_ALLOW_INSECURE_LOCALHOST=false # For MinIO/S3, set EVYDENCE_OBJECT_STORE=minio and provide: # EVYDENCE_S3_ENDPOINT=localhost:9000 # EVYDENCE_S3_BUCKET=evydence diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3663f70 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,49 @@ +.git +.github +.refs +.trash + +# Local secrets and environment overrides. Keep examples in git, but do not +# send operator-specific values into Docker build contexts. +.env +.env.* +.api.env +.api.env.* +.test.env +.test.env.* +*.pem +*.key +*.p12 +*.pfx + +# Release, backup, and generated evidence artifacts. +release-evidence +release-evidence/ +backups +backups/ +coverage.out +*.prof +*.pprof +*.test + +# Build output and temporary files. +bin +bin/ +dist +dist/ +tmp +tmp/ +__pycache__ +**/__pycache__ + +# SDK build artifacts. +sdk/typescript/.build +sdk/typescript/node_modules +sdk/python/**/__pycache__ + +# Terraform local state/cache. +.terraform +**/.terraform +.terraform.lock.hcl +*.tfstate +*.tfstate.* diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md new file mode 100644 index 0000000..53ca208 --- /dev/null +++ b/.github/ISSUE_TEMPLATE.md @@ -0,0 +1,35 @@ +# Issue Intake + +Use the structured issue forms when GitHub shows them. This fallback template is +for clients that do not render issue forms. + +Before posting, remove API keys, collector secrets, bearer tokens, session +tokens, portal tokens, private keys, provider credentials, database URLs, +raw evidence payloads, customer data, exploit payloads against third-party +systems, and unredacted customer package contents. + +Security vulnerabilities should not be reported in public issues. Use GitHub +private vulnerability reporting when available, or follow `SECURITY.md` to +request a private intake channel without including vulnerability details in the +first contact. + +## Summary + +Describe the bug, documentation gap, feature request, or production-support +question. + +## Environment + +- Evydence commit, tag, or image digest: +- Deployment profile: +- PostgreSQL/object-store mode: +- Relevant command or endpoint: + +## Evidence + +List sanitized logs, commands, request IDs, report IDs, or reproduction steps. +Do not attach raw tenant evidence or secrets. + +## Expected Outcome + +Describe the result you expected and the result you observed. diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..1ba0b59 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,69 @@ +name: Bug report +description: Report a reproducible Evydence defect without sharing secrets or raw evidence. +title: "bug: " +labels: ["bug", "triage"] +body: + - type: markdown + attributes: + value: | + Do not include API keys, bearer tokens, session tokens, private keys, + provider credentials, database URLs, raw evidence payloads, customer + data, or unredacted customer package contents. Use the private security + intake in SECURITY.md for suspected vulnerabilities. + - type: input + id: version + attributes: + label: Version or commit + description: Commit SHA, tag, image digest, or chart version. + placeholder: 842fb053f303b2a78eb3118be6a4536715571215 + validations: + required: true + - type: dropdown + id: area + attributes: + label: Area + options: + - API + - CLI + - Worker/outbox + - PostgreSQL/migrations + - Object storage + - Signing/verification + - Reports/packages + - Deployment/Helm/Docker + - Documentation + - Other + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected behavior + validations: + required: true + - type: textarea + id: observed + attributes: + label: Observed behavior + description: Sanitize logs and redact sensitive values. + validations: + required: true + - type: textarea + id: reproduce + attributes: + label: Reproduction steps + description: Include exact commands, API paths, or configuration names when safe. + validations: + required: true + - type: textarea + id: validation + attributes: + label: Checks already run + placeholder: make test, make docs-check, make production-check + - type: checkboxes + id: safety + attributes: + label: Safety confirmation + options: + - label: I have not included secrets, raw evidence payloads, private keys, bearer tokens, provider credentials, database URLs, or customer data. + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..ae12cb9 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: Security vulnerability + url: https://github.com/aatuh/evydence/security + about: Use the private security intake described in SECURITY.md. Do not post suspected vulnerabilities publicly. + - name: Support policy + url: https://github.com/aatuh/evydence/blob/master/SUPPORT.md + about: Read support boundaries and sanitized-report expectations before opening an issue. diff --git a/.github/ISSUE_TEMPLATE/docs.yml b/.github/ISSUE_TEMPLATE/docs.yml new file mode 100644 index 0000000..9267eae --- /dev/null +++ b/.github/ISSUE_TEMPLATE/docs.yml @@ -0,0 +1,30 @@ +name: Documentation issue +description: Report unclear, stale, missing, or misleading documentation. +title: "docs: " +labels: ["documentation", "triage"] +body: + - type: input + id: path + attributes: + label: Document path + placeholder: docs/tutorials/getting-started.md + validations: + required: true + - type: textarea + id: problem + attributes: + label: What is wrong or missing? + description: Include the exact command, section, or expectation that did not match reality. + validations: + required: true + - type: textarea + id: fix + attributes: + label: Suggested correction + - type: checkboxes + id: claims + attributes: + label: Claim safety + options: + - label: This issue does not ask Evydence to claim legal compliance, certification, complete SBOM coverage, authoritative scanner results, or secure releases. + required: true diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..aa0f103 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,47 @@ +name: Feature request +description: Propose a focused Evydence capability or integration. +title: "feat: " +labels: ["enhancement", "triage"] +body: + - type: textarea + id: problem + attributes: + label: Problem + description: What release-evidence, compliance-readiness, or operator workflow is blocked? + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposed behavior + description: Describe the smallest useful capability. + validations: + required: true + - type: dropdown + id: boundary + attributes: + label: Trust boundary affected + options: + - API + - Tenant isolation/authz + - File upload/object storage + - Signing/verification + - Provider/collector integration + - Reports/packages/exports + - Deployment/operations + - Documentation only + - Other + validations: + required: true + - type: textarea + id: validation + attributes: + label: Acceptance test idea + description: What command, API flow, or report would prove the feature works? + - type: checkboxes + id: scope + attributes: + label: Scope confirmation + options: + - label: This request supports compliance readiness or technical evidence organization without asking for legal compliance, certification, or secure-release guarantees. + required: true diff --git a/.github/ISSUE_TEMPLATE/production_support.yml b/.github/ISSUE_TEMPLATE/production_support.yml new file mode 100644 index 0000000..bff1fa0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/production_support.yml @@ -0,0 +1,41 @@ +name: Production support question +description: Ask a non-sensitive self-hosting or operations question. +title: "ops: " +labels: ["operations", "triage"] +body: + - type: markdown + attributes: + value: | + Public issues are not a private support channel. Do not include secrets, + raw evidence payloads, customer data, private keys, bearer tokens, + database URLs, or provider credentials. + - type: dropdown + id: profile + attributes: + label: Deployment profile + options: + - Local evaluation + - Controlled internal self-hosted candidate + - Air-gapped self-hosted candidate + - Regulated self-hosted review + - Other + validations: + required: true + - type: textarea + id: question + attributes: + label: Question + validations: + required: true + - type: textarea + id: sanitized_config + attributes: + label: Sanitized configuration summary + description: Use variable names and redacted values only. + - type: checkboxes + id: safety + attributes: + label: Safety confirmation + options: + - label: I have redacted all secrets, raw payloads, customer data, bearer tokens, private keys, provider credentials, and database URLs. + required: true diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..dc046eb --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,26 @@ +version: 2 +updates: + - package-ecosystem: gomod + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 + labels: + - dependencies + - go + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 + labels: + - dependencies + - github-actions + - package-ecosystem: docker + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 + labels: + - dependencies + - docker diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..b73a50c --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,33 @@ +# Pull Request + +## Summary + +- + +## Change Type + +- [ ] Documentation +- [ ] Tooling +- [ ] API contract +- [ ] Persistence/data +- [ ] Security/authz/session/secret handling +- [ ] Deployment/operations +- [ ] Refactor + +## Security And Evidence Invariants + +Confirm the change preserves tenant isolation, append-only evidence behavior, +safe errors/logs, conservative product language, and no compliance, +certification, complete-SBOM, scanner-authority, or secure-release claims. + +## Validation + +List the exact commands run and their results. Include skipped checks with the +reason. + +## Sensitive Data Check + +Confirm the PR does not include API keys, collector secrets, bearer tokens, +session tokens, portal tokens, private keys, provider credentials, database +URLs, raw evidence payloads, customer data, or unredacted customer package +contents. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ea26477 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,66 @@ +name: CI + +on: + push: + branches: + - master + - main + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + production-check: + name: Production Check + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16-alpine@sha256:16bc17c64a573ef34162af9298258d1aec548232985b33ed7b1eac33ba35c229 + env: + POSTGRES_DB: evydence + POSTGRES_USER: evydence + POSTGRES_PASSWORD: change-me + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U evydence" + --health-interval 5s + --health-timeout 5s + --health-retries 20 + env: + EVYDENCE_API_KEY_PEPPER: ci-test-pepper + EVYDENCE_TEST_DATABASE_URL: postgres://evydence:change-me@localhost:5432/evydence?sslmode=disable + steps: + - name: Check out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Set up Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 + with: + go-version-file: go.mod + cache: true + + - name: Install QA tools + run: make tools + + - name: Run production check with live PostgreSQL + run: make production-check + + - name: Upload release evidence artifacts + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: evydence-ci-evidence + path: | + coverage.out + tmp/release-check-summary.txt + tmp/production-benchmark/production-benchmark-summary.json + tmp/production-benchmark/production-benchmark-summary.txt + tmp/black-box-release-artifact/black-box-release-artifact-summary.json + tmp/black-box-release-artifact/black-box-release-artifact-summary.txt + tmp/black-box-release-artifact/release/evydence_linux_amd64/SHA256SUMS + tmp/production-check/evydence-release-manifest.json + tmp/production-check/evydence-release-manifest.sig.json + if-no-files-found: ignore diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..ecdcdcf --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,37 @@ +name: CodeQL + +on: + push: + branches: + - master + - main + pull_request: + schedule: + - cron: "19 4 * * 2" + workflow_dispatch: + +permissions: + contents: read + +jobs: + analyze: + name: CodeQL Analyze + runs-on: ubuntu-latest + permissions: + contents: read + security-events: write + steps: + - name: Check out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Initialize CodeQL + uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 + with: + languages: go + queries: security-and-quality + + - name: Autobuild + uses: github/codeql-action/autobuild@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 + + - name: Analyze + uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 diff --git a/.github/workflows/container-image.yml b/.github/workflows/container-image.yml new file mode 100644 index 0000000..1b05a3f --- /dev/null +++ b/.github/workflows/container-image.yml @@ -0,0 +1,198 @@ +name: Container Image + +on: + workflow_dispatch: + inputs: + ref: + description: "Git ref to build, such as v0.1.0-rc.7" + required: true + default: "v0.1.0-rc.7" + image_tag: + description: "OCI tag to publish" + required: true + default: "v0.1.0-rc.7" + +permissions: + contents: read + +jobs: + publish-image: + name: Build And Push Container Image + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + image: ${{ steps.image.outputs.image }} + digest: ${{ steps.image.outputs.digest }} + commit: ${{ steps.image.outputs.commit }} + created: ${{ steps.image.outputs.created }} + steps: + - name: Check out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.event.inputs.ref }} + persist-credentials: false + + - name: Validate image inputs + shell: bash + run: | + set -euo pipefail + ref="${{ github.event.inputs.ref }}" + tag="${{ github.event.inputs.image_tag }}" + if [[ ! "$ref" =~ ^[A-Za-z0-9._/-]+$ ]]; then + echo "ref contains unsupported characters" >&2 + exit 2 + fi + if [[ ! "$tag" =~ ^[A-Za-z0-9_.-]+$ ]]; then + echo "image_tag contains unsupported characters" >&2 + exit 2 + fi + + - name: Set up Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.1.0 + with: + go-version-file: go.mod + cache: true + + - name: Log in to GitHub Container Registry + env: + EVYDENCE_GHCR_PUBLISH_TOKEN: ${{ secrets.EVYDENCE_GHCR_PUBLISH_TOKEN }} + shell: bash + run: | + set -euo pipefail + if [[ -z "${EVYDENCE_GHCR_PUBLISH_TOKEN}" ]]; then + echo "EVYDENCE_GHCR_PUBLISH_TOKEN secret is required to publish container images" >&2 + exit 2 + fi + printf '%s' "${EVYDENCE_GHCR_PUBLISH_TOKEN}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin + + - name: Build and push image + id: image + shell: bash + run: | + set -euo pipefail + image="ghcr.io/${{ github.repository }}:${{ github.event.inputs.image_tag }}" + commit="$(git rev-parse HEAD)" + created="$(git show -s --format=%cI HEAD)" + docker buildx create --use --name evydence-builder || docker buildx use evydence-builder + docker buildx build \ + --platform linux/amd64 \ + --label "org.opencontainers.image.source=https://github.com/${{ github.repository }}" \ + --label "org.opencontainers.image.revision=${commit}" \ + --label "org.opencontainers.image.created=${created}" \ + --label "org.opencontainers.image.version=${{ github.event.inputs.image_tag }}" \ + --provenance=true \ + --sbom=true \ + --tag "$image" \ + --push \ + . + digest="$(docker buildx imagetools inspect "$image" --format '{{json .Manifest.Digest}}' | tr -d '"')" + mkdir -p tmp/container-image + cat > tmp/container-image/image.env <> "$GITHUB_OUTPUT" + + sign-image: + name: Sign Container Image And Upload Evidence + runs-on: ubuntu-latest + needs: publish-image + permissions: + contents: read + id-token: write + steps: + - name: Check out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.event.inputs.ref }} + persist-credentials: false + + - name: Set up Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.1.0 + with: + go-version-file: go.mod + cache: true + + - name: Log in to GitHub Container Registry for signing + env: + EVYDENCE_GHCR_PUBLISH_TOKEN: ${{ secrets.EVYDENCE_GHCR_PUBLISH_TOKEN }} + shell: bash + run: | + set -euo pipefail + if [[ -z "${EVYDENCE_GHCR_PUBLISH_TOKEN}" ]]; then + echo "EVYDENCE_GHCR_PUBLISH_TOKEN secret is required to sign container images" >&2 + exit 2 + fi + printf '%s' "${EVYDENCE_GHCR_PUBLISH_TOKEN}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin + + - name: Install cosign + shell: bash + run: | + set -euo pipefail + go install github.com/sigstore/cosign/v2/cmd/cosign@v2.6.1 + echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH" + + - name: Sign image + shell: bash + run: | + set -euo pipefail + mkdir -p tmp/container-image + cat > tmp/container-image/image.env < tmp/container-image/cosign-verify.json + + - name: Write image manifest + shell: bash + run: | + set -euo pipefail + set -a + . tmp/container-image/image.env + set +a + python3 - <<'PY' + import json + import os + from pathlib import Path + + payload = { + "schema": "evydence-container-image.v1", + "image": os.environ["IMAGE"], + "digest": os.environ["DIGEST"], + "source_ref": os.environ.get("GITHUB_REF", ""), + "source_commit": os.environ["COMMIT"], + "created": os.environ["CREATED"], + "signature": { + "type": "cosign-keyless", + "issuer": "https://token.actions.githubusercontent.com", + "verification_file": "cosign-verify.json", + }, + "limitations": [ + "Image signing proves this workflow signed the referenced image digest; it is not legal compliance proof, certification, complete SBOM proof, authoritative vulnerability coverage, or a secure-release guarantee.", + "Operators remain responsible for registry access policy, deployment admission controls, runtime configuration, backups, monitoring, and incident response.", + ], + } + Path("tmp/container-image/evydence-container-image-manifest.json").write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8") + PY + + - name: Upload image evidence + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: evydence-container-image-evidence-${{ github.event.inputs.image_tag }} + path: tmp/container-image/ + if-no-files-found: error diff --git a/.github/workflows/marketing-site-pages.yml b/.github/workflows/marketing-site-pages.yml new file mode 100644 index 0000000..87c1a72 --- /dev/null +++ b/.github/workflows/marketing-site-pages.yml @@ -0,0 +1,71 @@ +name: Marketing Site Pages + +on: + push: + branches: + - master + - main + paths: + - ".github/workflows/marketing-site-pages.yml" + - "site/marketing/**" + - "Makefile" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: marketing-site-pages + cancel-in-progress: true + +jobs: + build: + name: Build Marketing Site + runs-on: ubuntu-latest + permissions: + contents: read + pages: write + env: + PUBLIC_SITE_URL: https://evydence.app + PUBLIC_SITE_BASE: / + PUBLIC_GA_MEASUREMENT_ID: G-XC2ESEHQ3W + steps: + - name: Check out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Set up Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: "22" + cache: npm + cache-dependency-path: site/marketing/package-lock.json + + - name: Install marketing-site dependencies + run: npm ci --prefix site/marketing + + - name: Build and validate marketing site + run: make marketing-site-production-check + + - name: Configure GitHub Pages + uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b # v5 + + - name: Upload GitHub Pages artifact + uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3 + with: + path: site/marketing/dist + + deploy: + name: Deploy Marketing Site + runs-on: ubuntu-latest + needs: build + permissions: + contents: read + id-token: write + pages: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4 diff --git a/.github/workflows/release-artifacts.yml b/.github/workflows/release-artifacts.yml new file mode 100644 index 0000000..975d63b --- /dev/null +++ b/.github/workflows/release-artifacts.yml @@ -0,0 +1,142 @@ +name: Release Artifacts + +on: + push: + tags: + - "v*" + workflow_dispatch: + inputs: + tag: + description: "Release tag to package, for example v0.1.0" + required: true + type: string + upload_draft_release: + description: "Create or update a draft GitHub release with the generated artifacts" + required: true + default: false + type: boolean + +permissions: + contents: read + +jobs: + package: + name: Package Signed Release Artifacts + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + tag: ${{ steps.release.outputs.tag }} + services: + postgres: + image: postgres:16-alpine@sha256:16bc17c64a573ef34162af9298258d1aec548232985b33ed7b1eac33ba35c229 + env: + POSTGRES_DB: evydence + POSTGRES_USER: evydence + POSTGRES_PASSWORD: change-me + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U evydence" + --health-interval 5s + --health-timeout 5s + --health-retries 20 + env: + EVYDENCE_API_KEY_PEPPER: ci-test-pepper + EVYDENCE_TEST_DATABASE_URL: postgres://evydence:change-me@localhost:5432/evydence?sslmode=disable + EVYDENCE_RELEASE_SIGNING_PRIVATE_KEY_B64: ${{ secrets.EVYDENCE_RELEASE_SIGNING_PRIVATE_KEY_B64 }} + EVYDENCE_RELEASE_ALLOW_EXISTING_TAG: ${{ github.ref_type == 'tag' && '1' || '0' }} + steps: + - name: Check out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + + - name: Resolve release tag + id: release + shell: bash + run: | + set -euo pipefail + if [[ "${GITHUB_REF_TYPE}" == "tag" ]]; then + tag="${GITHUB_REF_NAME}" + else + tag="${{ github.event.inputs.tag }}" + fi + if [[ ! "${tag}" =~ ^v[0-9]+(\.[0-9]+){1,2}(-[0-9A-Za-z.-]+)?$ ]]; then + echo "release tag must look like v1.2.3 or v1.2.3-rc.1" >&2 + exit 2 + fi + echo "tag=${tag}" >> "${GITHUB_OUTPUT}" + + - name: Set up Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 + with: + go-version-file: go.mod + cache: true + + - name: Install QA tools + run: make tools + + - name: Require release signing secret + shell: bash + run: | + set -euo pipefail + if [[ -z "${EVYDENCE_RELEASE_SIGNING_PRIVATE_KEY_B64}" ]]; then + echo "EVYDENCE_RELEASE_SIGNING_PRIVATE_KEY_B64 secret is required to sign release artifacts" >&2 + exit 2 + fi + + - name: Package signed release candidate + shell: bash + run: | + set -euo pipefail + # scripts/release_candidate_package.sh runs make production-check before artifact packaging. + # The generated dist// evidence set includes evydence-release-manifest.sig.json + # plus a Scorecard-compatible evydence-release-manifest.sig alias. + scripts/release_candidate_package.sh "${{ steps.release.outputs.tag }}" + + - name: Upload packaged release evidence + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: evydence-release-artifacts-${{ steps.release.outputs.tag }} + path: dist/${{ steps.release.outputs.tag }}/ + if-no-files-found: error + + publish-draft-release: + name: Publish Draft GitHub Release + runs-on: ubuntu-latest + needs: package + if: github.event_name == 'push' || github.event.inputs.upload_draft_release == 'true' + permissions: + contents: read + steps: + - name: Download packaged release evidence + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: evydence-release-artifacts-${{ needs.package.outputs.tag }} + path: dist/${{ needs.package.outputs.tag }}/ + + - name: Require release publishing token + env: + EVYDENCE_RELEASE_PUBLISH_TOKEN: ${{ secrets.EVYDENCE_RELEASE_PUBLISH_TOKEN }} + shell: bash + run: | + set -euo pipefail + if [[ -z "${EVYDENCE_RELEASE_PUBLISH_TOKEN}" ]]; then + echo "EVYDENCE_RELEASE_PUBLISH_TOKEN secret is required to create or update draft GitHub releases" >&2 + exit 2 + fi + + - name: Create or update draft GitHub release + env: + GH_TOKEN: ${{ secrets.EVYDENCE_RELEASE_PUBLISH_TOKEN }} + shell: bash + run: | + set -euo pipefail + tag="${{ needs.package.outputs.tag }}" + repo="${GITHUB_REPOSITORY:?}" + if gh release view "${tag}" --repo "${repo}" >/dev/null 2>&1; then + gh release upload "${tag}" dist/${tag}/* --repo "${repo}" --clobber + else + gh release create "${tag}" dist/${tag}/* --repo "${repo}" --draft --title "Evydence ${tag}" --notes-file "dist/${tag}/release-notes.md" + fi diff --git a/.github/workflows/scorecard-sarif.yml b/.github/workflows/scorecard-sarif.yml new file mode 100644 index 0000000..ba3fd19 --- /dev/null +++ b/.github/workflows/scorecard-sarif.yml @@ -0,0 +1,34 @@ +name: OpenSSF Scorecard SARIF + +on: + schedule: + - cron: "43 3 * * 1" + workflow_dispatch: + +permissions: + contents: read + +jobs: + scorecard-sarif: + name: Scorecard SARIF + runs-on: ubuntu-latest + permissions: + contents: read + security-events: write + steps: + - name: Check out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Run OpenSSF Scorecard + uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3 + with: + results_file: scorecard-results.sarif + results_format: sarif + publish_results: false + + - name: Upload SARIF + uses: github/codeql-action/upload-sarif@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 + with: + sarif_file: scorecard-results.sarif diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml new file mode 100644 index 0000000..5ff9e51 --- /dev/null +++ b/.github/workflows/scorecard.yml @@ -0,0 +1,30 @@ +name: OpenSSF Scorecard + +on: + branch_protection_rule: + schedule: + - cron: "37 3 * * 1" + workflow_dispatch: + +permissions: + contents: read + +jobs: + scorecard: + name: Scorecard + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + steps: + - name: Check out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Run OpenSSF Scorecard + uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3 + with: + results_file: scorecard-results.sarif + results_format: sarif + publish_results: true diff --git a/.gitignore b/.gitignore index dc73eb8..0c6498d 100644 --- a/.gitignore +++ b/.gitignore @@ -6,11 +6,13 @@ !.env.example !.api.env.example !.test.env.example +!.production.env.example coverage.out bin/ dist/ tmp/ +node_modules/ release-evidence/ backups/ __pycache__/ diff --git a/.implementation_increments.md b/.implementation_increments.md deleted file mode 100644 index 4fca17c..0000000 --- a/.implementation_increments.md +++ /dev/null @@ -1,257 +0,0 @@ -# Complete Evydence Implementation Increment Plan - -This file tracks every increment needed to implement the full system described in `.initial_design.md`, from the current release-ledger MVP through production v1, high-trust v2, enterprise v3, and future extensions. - -Status legend: - -- `[ ]` not done -- `[x]` done - -## Implementation Rules - -- Each increment must be independently testable, documented, and committable. -- Run targeted tests during implementation and `make finalize` before closing each increment. -- Create Conventional Commits at sensible green checkpoints. -- Update OpenAPI, migrations, docs, examples, and tests with every behavior change. -- Preserve tenant isolation, append-only evidence behavior, safe error handling, and no compliance/certification/secure-release claims. -- Mark an increment `[x]` only after implementation, docs, tests, and final gates pass. - -## Completed Increments - -1. `[x]` Release Ledger MVP Scaffold - Core Go API, tenant API keys, evidence, artifacts, products/projects/releases, audit chain, signing keys, release bundles, SBOM, vulnerability scans, OpenAPI upload, policy evaluation, missing evidence, release readiness, docs, migrations, Docker Compose, OpenAPI, and tests. - -2. `[x]` Durable Runtime Foundation - PostgreSQL snapshot persistence, filesystem object storage, persisted outbox, worker polling, production config checks, migrations, and Postgres test gates. - -3. `[x]` VEX, Vulnerability Decisions, Exceptions - OpenVEX ingestion, normalized vulnerability decisions, scoped exceptions, approval flow, and deterministic release-risk handling. - -4. `[x]` GitHub Actions Provenance And Build Attestations - Collectors, build runs, GitHub Actions metadata capture, DSSE/in-toto/SLSA structural parsing, attestation evidence, readiness gates, CLI upload, and composite action docs. - -5. `[x]` Control Frameworks And CRA/Control Coverage Reports - Tenant-created frameworks, controls, control evidence links, control waivers through exceptions, control coverage reports, CRA-readiness reports, migrations, API routes, and docs. - -## Remaining Core Product Increments - -6. `[x]` Relational Store Split - Added migration-backed relational tables and tenant-scoped resource projection rows for implemented resources, with transactional PostgreSQL saves, tenant/release indexes, persisted idempotency state, and outbox row locking. Canonical runtime loading still uses the versioned ledger snapshot while per-resource repository tuning remains future hardening. - -7. `[x]` Evidence Search, Filtering, And Timeline Querying - Added tenant-scoped evidence search by product, project, release, build, deployment, type, subtype, source, collector, created time, subject refs, tags, and verification status. - -8. `[x]` Evidence Lifecycle: Amendments, Redactions, Tombstones - Added append-only amendment, redaction, tombstone, retention marker, and supersession lifecycle events without silently mutating historical evidence fields. - -9. `[x]` Release Candidates - Added release candidates as immutable snapshots of builds, artifacts, SBOMs, scans, VEX, OpenAPI contracts, and bundles before append-only promotion or rejection. - -10. `[x]` Container Images And Artifact Signatures - Added OCI image records, image digests, detached signatures, artifact signature evidence, and digest/signature verification receipts. - -11. `[x]` Repository, Commit, Branch, Pull Request Model - Added source repositories, commits, branches, pull requests, review decision metadata, protected-branch snapshots, and source evidence APIs. - -12. `[x]` GitHub Source Collector - Extended GitHub support beyond Actions metadata to collect repository, commit, PR, review, and branch-protection evidence without replacing source control. - -13. `[x]` GitLab CI And Source Collector - Added GitLab CI/build/source metadata ingestion, project/pipeline/job identity capture, source snapshots, and collector-scoped API keys. - -14. `[x]` Generic CI Collector - Added provider-neutral CI build/run ingestion for Jenkins, Azure DevOps, Buildkite, and offline CI contexts using explicit metadata and artifact digests. - -15. `[x]` Deployment Environments And Deployment Evidence - Added environments, deployment events, artifact/release/environment links, rollout metadata, rollback-as-new-event behavior, and deployment evidence reports. - -16. `[x]` Incident Timeline And Remediation Evidence - Added incidents, timeline events, remediation tasks, evidence links, and deterministic incident package reports. Signed webhook ingestion remains future provider-hardening work. - -17. `[x]` Static, Dynamic, Secret, And License Scan Evidence - Added SAST, DAST, secret scanning, license scanning, SARIF where relevant, safe redaction/quarantine behavior, and normalized summaries. - -18. `[x]` Threat Models, Security Reviews, And Pen Test Reports - Added sensitive manual/security evidence types with lower default trust, scoped access, raw payload preservation, and explicit limitations. - -19. `[x]` SPDX And SBOM Expansion - Added SPDX JSON first-class ingestion, component inventory storage, parser version metadata, and validation limitations. Rich component lookup APIs remain future expansion. - -20. `[x]` SBOM Diff And Dependency Change Evidence - Added SBOM diffs, dependency change records, changed components, and release comparison data for added/removed components. - -21. `[x]` CycloneDX VEX Support - Added CycloneDX VEX ingestion alongside OpenVEX, normalized decision creation, raw payload preservation, and format-specific validation. - -22. `[x]` Advanced Vulnerability Workflows - Added scanner metadata workflow records, SLA/disagreement/supersession/re-open markers, and vulnerability posture reports. - -23. `[x]` OpenAPI Diff And Contract Checks - Added `ContractDiff`, deterministic contract comparison, and breaking/non-breaking change classification based on stored OpenAPI metadata. Approval requirements remain a later approval-record increment. - -24. `[x]` API Security Check Evidence - Added API lint/security scan evidence, OpenAPI ruleset results, API governance metadata, and control evidence-compatible resource types. - -25. `[x]` Policy Engine V2 - Added versioned custom policies with a deterministic internal DSL, stored input snapshots, replayable evaluations, and controlled extensibility beyond built-in gates. - -26. `[x]` Waiver Resource - Added a first-class waiver resource separate from exceptions for controls/policies, with owner, risk, expiry, approval, supersession, and audit-chain entries. - -27. `[x]` Approval Records - Added explicit immutable approval records for releases, breaking API changes, waivers, security reviews, and customer packages. - -28. `[x]` Customer Security Packages - Added scoped JSON package manifests, redaction profiles, expiry, access auditing, verification instructions, and over-disclosure tests. ZIP packaging remains future output-format expansion. - -29. `[x]` Security Review Package Report - Added scoped security-review reports for customer/procurement review with evidence summaries, assumptions, limitations, and redaction-aware output. - -30. `[x]` CRA Readiness HTML Package - Extended CRA readiness from JSON to reproducible HTML/package output with deterministic templates and no legal compliance claims. - -31. `[x]` Control Framework Template Packs - Added versioned built-in starter packs for CRA readiness and NIST SSDF-lite controls, with tenant install APIs. Broader SOC 2/ISO/internal packs remain future pack expansion. - -32. `[x]` Custom Report Templates - Added tenant-defined report templates with strict allowed fields, deterministic rendering, redaction controls, and template versioning. - -## Integrity, Signing, And Verification Increments - -33. `[x]` Offline Verifier CLI - Extended CLI to verify evidence bundle manifests without API access. Broader offline signature and audit-chain verification remain future CLI expansion. - -34. `[x]` Evidence Bundle Export And Import - Added portable evidence export/import manifests for air-gapped review, including hashes, signatures, chain checkpoints, and verification instructions. - -35. `[x]` DSSE Signature Verification - Moved build attestations from structural validation to optional cryptographic verification with configured Ed25519 trust roots and verification receipts. - -36. `[x]` Cosign/Sigstore Verification - Added cosign-style artifact/container image verification metadata, optional Rekor metadata capture, and digest-bound signature evidence. Full Sigstore trust-chain validation remains future trust-root expansion. - -37. `[x]` Key Revocation And Valid-At-Signing Semantics - Added signing-key revocation, append-only audit entries, and valid-at-signing verification behavior for historical signatures created before revocation. - -38. `[x]` KMS/HSM Signing Provider Abstraction - Added signing-provider records for local encrypted dev, AWS KMS, GCP KMS, Azure Key Vault, and PKCS#11 HSM references without storing private key material. Production-grade signing operations remain behind external signing mode. - -39. `[x]` Merkle Batching And Chain Checkpoints - Added signed Merkle batches over tenant audit-chain ranges and verification of checkpoint root/signature consistency. - -40. `[x]` External Transparency Checkpoints - Added optional external transparency/timestamp checkpoint records for Merkle batches without requiring public transparency for local trust. - -41. `[x]` WORM/Object Lock Integration - Added tenant-prefixed object-retention policy records and verification transitions. Enforcement depends on the configured object store and deployment policy. - -## Runtime, Operations, And Deployment Increments - -42. `[x]` S3/MinIO Runtime Object Store - Added an S3/MinIO-compatible object-store adapter with tenant-prefixed key validation, metadata preservation, runtime env selection, and operations guidance. - -43. `[x]` Backup And Restore - Added backup manifests with state hashes, resource counts, audit-chain consistency checks, verification endpoints, and operations guidance for paired database/object-store backups. - -44. `[x]` Observability And Readiness - Added `/v1/ready`, admin-scoped safe metrics, and operations guidance. OpenTelemetry dashboards and alert packs remain future observability expansion. - -45. `[x]` Admin Audit Querying - Added `/v1/audit-log` with tenant-scoped subject/time/limit filters, export-safe audit-chain fields, and admin access control. - -46. `[x]` Helm Chart And Kubernetes Deployment - Added Helm chart skeleton with API and worker deployments, external secret wiring, S3/KMS-style configuration, service, optional ingress, readiness/liveness probes, and Kubernetes deployment docs. - -47. `[x]` Air-Gapped Installation Package - Added air-gapped package manifest, offline install docs, bundled migration/OpenAPI/chart guidance, import/export workflow, and verification instructions. - -48. `[x]` Signed Release Artifacts - Added CLI release artifact manifest creation, Ed25519 signing, verification, key generation, and operator documentation for publishing checksums/signatures. - -49. `[x]` Production Hardening Review - Added production hardening review checklist covering security, privacy, operability, backup, restore, failure modes, collector pinning, and safe language constraints. - -## Developer Experience And Ecosystem Increments - -50. `[x]` SDK Generation Workflow - Added curated Go, TypeScript, and Python SDK examples, SDK documentation, and project-owned SDK file drift checks. - -51. `[x]` CLI Uploader Completeness - Expanded CLI with manifest-based bulk uploads, air-gapped bundle import upload, release artifact manifest signing/verification, local path validation, and safe API error reporting. - -52. `[x]` GitHub Action Release Workflow - Added repository-local GitHub Actions release evidence workflow example for build artifact digest and Evydence provenance upload. Full marketplace action publishing remains future packaging work. - -53. `[x]` GitLab CI Template - Added GitLab CI release evidence template for build artifact digest and manifest-based evidence upload. - -54. `[x]` Collector Supply-Chain Security - Added collector release supply-chain records, version pinning, signature/SBOM/scan evidence references, collector health report API, docs, migrations, and tests. - -55. `[x]` Import Bundle Collector - Added `import_bundle` collector type, bundle import collector scopes, and CLI upload flow for evidence bundles moved through air-gapped transfer. - -56. `[x]` Documentation Portal Structure - Added `docs/README.md` plus tutorial, how-to, reference, and explanation entry points, and wired docs-check to verify the canonical portal files. - -## Enterprise And Commercial Increments - -57. `[x]` Organizations, Users, And Human Identity - Added tenant-scoped organizations, human users, user deactivation, audit-chain entries, human actor attribution, snapshot persistence, migrations, API routes, and tests. - -58. `[x]` RBAC And ABAC - Added role bindings for users and collectors, role-to-scope enforcement for human sessions, admin/customer/collector roles, tenant isolation checks, API routes, and tests. - -59. `[x]` Enterprise SSO/SAML/OIDC - Added OIDC/SAML provider records, verified identity links, one-time hashed SSO session tokens, session revocation, role mapping metadata, audit entries, API routes, and tests. Live provider token verification remains future work. - -60. `[x]` Multi-Tenant Admin Console API - Added low-detail instance admin snapshot API with tenant/resource counts and no raw evidence, token, or secret exposure. - -61. `[x]` Legal Hold And Advanced Retention - Added legal holds, retention overrides, retention reports, tenant-scoped validation, audit entries, persistence, migrations, API routes, and docs. - -62. `[x]` Customer Portal - Added expiring customer portal package tokens, hash-only token storage, scoped package manifest access, access auditing, API routes, and tests without exposing raw tenant evidence. - -63. `[x]` Customer-Specific Questionnaire Packages - Added questionnaire templates, evidence-mapped questionnaire packages, deterministic responses with evidence IDs and limitations, persistence, migrations, API routes, and tests. - -64. `[x]` Advanced CRA/SOC2/ISO Evidence Templates - Expanded built-in starter template packs for CRA-readiness, NIST SSDF-lite, SOC 2-style technical evidence, and ISO 27001-style technical evidence while preserving readiness-only wording. - -65. `[x]` Commercial Collector Framework - Added tenant-scoped commercial collector definitions with provider/version/manifest digest/allowed scopes, persistence, migrations, API routes, docs, and tests. - -## Future Extensions - -66. `[ ]` AI Evidence Summarization - Add optional summarization of existing evidence only, with provenance, citations, limitations, and no automated compliance conclusions. - -67. `[ ]` Questionnaire Automation - Add evidence-backed questionnaire draft answers after customer packages and template controls are mature. - -68. `[ ]` Graph Exploration Layer - Evaluate graph views or graph database support only after PostgreSQL adjacency queries prove insufficient. - -69. `[ ]` SaaS Edition Foundation - Add SaaS deployment architecture only after self-hosted trust, tenancy, backup, observability, and enterprise auth are mature. - -70. `[ ]` Public Transparency Log - Add optional public transparency log integration after local checkpoints and external timestamping are stable. - -71. `[ ]` Marketplace Collectors - Add collector marketplace workflow after collector signing, health, versioning, and supply-chain controls are complete. - -72. `[ ]` PDF Designer - Add PDF output only after deterministic JSON/HTML reports, redaction, and customer package verification are mature. - -73. `[ ]` ML Anomaly Detection - Add optional collector/evidence anomaly detection after metrics, baselines, and audit-safe alerting are mature. - -## Assumptions - -- The first five increments are marked complete from current repository evidence. -- All remaining increments are needed to implement the full system described in `.initial_design.md`, including v1, v2, v3, and future/nice-to-have sections. -- Future increments should remain API-first unless the increment explicitly introduces portal/UI work. -- Integrity, tenant isolation, auditability, verification, and safe product language remain mandatory throughout. diff --git a/.production.env.example b/.production.env.example new file mode 100644 index 0000000..0f608db --- /dev/null +++ b/.production.env.example @@ -0,0 +1,97 @@ +# Evydence production environment example. +# +# Copy to an untracked deployment-specific file or translate these values into +# your secret manager, Kubernetes Secret, or sealed-secret workflow. Empty +# secret values are intentional: production startup should fail until an +# operator supplies real values. Do not commit rendered production values. + +# Runtime profile +ENV=production +EVYDENCE_ADDR=:8080 +EVYDENCE_POSTGRES_LOAD_MODE=relational_only +EVYDENCE_API_WRITER_MODE=single +EVYDENCE_API_WRITER_REPLICAS=1 +EVYDENCE_BOOTSTRAP_DISABLED=true +EVYDENCE_PRINT_BOOTSTRAP_SECRET=false + +# Required secrets +EVYDENCE_DATABASE_URL= +EVYDENCE_API_KEY_PEPPER= + +# Object storage. Production should use external S3/MinIO-compatible storage, +# not filesystem object storage. +EVYDENCE_OBJECT_STORE=s3 +EVYDENCE_S3_ENDPOINT= +EVYDENCE_S3_BUCKET= +EVYDENCE_S3_REGION= +EVYDENCE_S3_USE_SSL=true +EVYDENCE_S3_ACCESS_KEY_ID= +EVYDENCE_S3_SECRET_ACCESS_KEY= + +# Worker and parser behavior +EVYDENCE_WORKER_POLL_INTERVAL=1s +EVYDENCE_WORKER_BATCH_SIZE=10 +EVYDENCE_WORKER_MAX_PAYLOAD_BYTES=20971520 +EVYDENCE_WORKER_OWNED_PARSER_SIDE_EFFECTS=true + +# Rate limiting. Keep edge/reverse-proxy rate limits as the primary control; +# this in-process limiter is a local safety net keyed by TCP remote address. +EVYDENCE_RATE_LIMIT_REQUESTS_PER_MINUTE=120 + +# Signing. Choose one production signing profile. Do not use local plaintext +# signing-key storage in production. +EVYDENCE_SIGNING_KEY_MODE=external +EVYDENCE_SIGNING_EXECUTOR_URL= +EVYDENCE_SIGNING_EXECUTOR_TOKEN= +EVYDENCE_SIGNING_EXECUTOR_TIMEOUT_SECONDS=10 +EVYDENCE_SIGNING_EXECUTOR_ALLOW_INSECURE_LOCALHOST=false + +# AWS KMS profile. Set EVYDENCE_SIGNING_KEY_MODE=aws-kms to use this path. +EVYDENCE_AWS_REGION= +EVYDENCE_AWS_KMS_KEY_ID= +EVYDENCE_AWS_KMS_SIGNING_ALGORITHM=ECDSA_SHA_256 +EVYDENCE_AWS_KMS_TIMEOUT_SECONDS=10 + +# GCP Cloud KMS profile. Set EVYDENCE_SIGNING_KEY_MODE=gcp-kms to use this path. +EVYDENCE_GCP_KMS_ACCESS_TOKEN= +EVYDENCE_GCP_KMS_KEY_NAME= +EVYDENCE_GCP_KMS_ENDPOINT=https://cloudkms.googleapis.com +EVYDENCE_GCP_KMS_TIMEOUT_SECONDS=10 + +# Azure Key Vault profile. Set EVYDENCE_SIGNING_KEY_MODE=azure-key-vault to use +# this path. +EVYDENCE_AZURE_KEY_VAULT_URL= +EVYDENCE_AZURE_KEY_VAULT_ACCESS_TOKEN= +EVYDENCE_AZURE_KEY_VAULT_KEY_NAME= +EVYDENCE_AZURE_KEY_VAULT_KEY_VERSION= +EVYDENCE_AZURE_KEY_VAULT_ALGORITHM=ES256 +EVYDENCE_AZURE_KEY_VAULT_API_VERSION=7.4 +EVYDENCE_AZURE_KEY_VAULT_TIMEOUT_SECONDS=10 + +# Provider and transparency gateways are optional. Use HTTPS for production. +EVYDENCE_PROVIDER_VALIDATION_GATEWAY_URL= +EVYDENCE_PROVIDER_VALIDATION_GATEWAY_TOKEN= +EVYDENCE_PROVIDER_VALIDATION_GATEWAY_TIMEOUT_SECONDS=10 +EVYDENCE_PROVIDER_VALIDATION_GATEWAY_ALLOW_INSECURE_LOCALHOST=false + +EVYDENCE_TRANSPARENCY_PROOF_GATEWAY_URL= +EVYDENCE_TRANSPARENCY_PROOF_GATEWAY_TOKEN= +EVYDENCE_TRANSPARENCY_PROOF_GATEWAY_TIMEOUT_SECONDS=10 +EVYDENCE_TRANSPARENCY_PROOF_GATEWAY_ALLOW_INSECURE_LOCALHOST=false + +# Direct public transparency fetch tuning when no gateway is configured. +EVYDENCE_TRANSPARENCY_FETCH_TIMEOUT_SECONDS=10 +EVYDENCE_TRANSPARENCY_FETCH_ALLOW_INSECURE_LOCALHOST=false + +# OIDC discovery and UserInfo live validation. HTTP is for localhost tests only. +EVYDENCE_OIDC_DISCOVERY_TIMEOUT_SECONDS=10 +EVYDENCE_OIDC_DISCOVERY_ALLOW_INSECURE_LOCALHOST=false +EVYDENCE_OIDC_USERINFO_TIMEOUT_SECONDS=10 +EVYDENCE_OIDC_USERINFO_ALLOW_INSECURE_LOCALHOST=false + +# Telemetry and diagnostics. Evydence exposes authenticated readiness, +# metrics, audit-log, and admin surfaces; configure scraping, log redaction, +# alert routing, and retention in the deployment platform. +# Do not export raw evidence payloads, bearer tokens, database URLs, private +# keys, object-store credentials, provider credentials, or customer package +# contents. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..ef917b1 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,199 @@ +# Changelog + +All notable public release changes for Evydence are recorded here. + +Release notes must distinguish implemented behavior from future intent and must +preserve Evydence’s non-claims: no legal compliance conclusions, no +certification, no complete-SBOM guarantee, no authoritative vulnerability +results, no secure-release guarantee, and no regulator or auditor acceptance. + +## Unreleased + +- No unreleased changes. + +## v0.1.0-rc.7 - 2026-06-01 + +Release status: controlled self-hosted production candidate. This prerelease is +suitable for evaluation, pilots, and controlled internal production after +operator review. Broad self-hosted production readiness, regulated production, +and hosted SaaS production remain out of scope for this status. + +### Fixed + +- Release artifact publication now uses the dedicated release publication token + path after signed release evidence is generated. +- The release-candidate evidence package includes signed archives, checksums, + OpenAPI and migration checksums, coverage output, production-check summary, + SBOM/provenance metadata, release notes, and a signed release manifest. + +### Known Limits + +- No tag-specific project-owned container image evidence is asserted for this + prerelease in the repository metadata; operators must verify a separately + published image digest or build/sign their own image for container + deployments. +- Multi-writer API HA remains outside the supported production profile; use one + API writer replica and scale workers through PostgreSQL outbox locking. + +## v0.1.0-rc.5 - 2026-06-01 + +Release status: controlled self-hosted production candidate. This prerelease is +suitable for evaluation, pilots, and controlled internal production after +operator review. Broad self-hosted production readiness, regulated production, +and hosted SaaS production remain out of scope for this status. + +### Added + +- Public release verification helper for downloading `v0.1.0-rc.5` from + GitHub Releases into a clean temporary directory, checking release checksums, + validating in-toto provenance metadata shape, and verifying the signed + release manifest with the released Linux amd64 CLI. +- Container image workflow evidence for + `ghcr.io/aatuh/evydence:v0.1.0-rc.5@sha256:38188044a3e5ded3c6094564ab39ce989185f65e22cf4296985ec19ba0eb1888`, + including release-attached image manifest and cosign verification output. +- Repository-owned restore rehearsal target for app-layer and live PostgreSQL + backup/restore mechanics. +- Static package-viewer preview asset for public docs. + +### Changed + +- Dependency maintenance updates for pinned GitHub Actions, the Docker build + and runtime base images, and `kin-openapi`. + +### Fixed + +- PostgreSQL release-evidence persistence now normalizes empty string slices to + empty `text[]` values for non-null array columns, including VEX import + reports and vulnerability decision evidence IDs. + +### Known Limits + +- Operators remain responsible for PostgreSQL, object storage, TLS, external + signing or KMS/HSM custody, WORM/object-lock policy where required, backups, + restore rehearsals, monitoring, provider validation, and incident response. +- Multi-writer API HA remains outside the supported production profile; use one + API writer replica and scale workers through PostgreSQL outbox locking. + +## v0.1.0-rc.4 - 2026-05-31 + +Release status: controlled self-hosted production candidate. This prerelease is +suitable for evaluation, pilots, and controlled internal production after +operator review. Broad self-hosted production readiness, regulated production, +and hosted SaaS production remain out of scope for this status. + +### Added + +- Public GitHub prerelease at + . +- Release archives for Linux, macOS, and Windows. +- `SHA256SUMS`, `openapi.sha256`, and `migrations.sha256`. +- Release coverage output and production-check summary. +- Release SBOM metadata and Evydence release provenance metadata. +- Scorecard-compatible in-toto provenance metadata. +- Signed release manifest plus `.sig.json` verifier input and `.sig` release + asset alias. +- Release notes attached to the public prerelease. + +### Known Limits + +- Public release archives and release evidence are published, but no + container image should be treated as deployable release evidence unless the + maintainer image workflow has produced an immutable digest and cosign evidence + for the tag. +- Operators remain responsible for PostgreSQL, object storage, TLS, external + signing or KMS/HSM custody, WORM/object-lock policy where required, backups, + restore rehearsals, monitoring, provider validation, and incident response. +- Multi-writer API HA remains outside the supported production profile; use one + API writer replica and scale workers through PostgreSQL outbox locking. + +## v0.1.0-rc.3 - 2026-05-31 + +Release status: controlled self-hosted production candidate. This build is +suitable for evaluation, pilots, and controlled internal production after +operator review. Broad self-hosted production readiness, regulated production, +and hosted SaaS production remain out of scope for this status. The local +release-candidate package for this tag was generated with +`make release-candidate-check TAG=v0.1.0-rc.3`. + +### Added + +- Direct GCP Cloud KMS and Azure Key Vault signing executors for signing stored + SHA-256 payload hashes without sending raw evidence payload bytes. +- Operator-controlled provider validation gateway that records non-secret + validation metadata without forwarding supplied access tokens. +- Operator-controlled transparency proof gateway before local proof + verification. +- Release-candidate SBOM metadata and release provenance metadata generation. +- OpenSSF Scorecard workflow, Dependabot configuration, issue templates, code + of conduct, and production-like Compose rehearsal. + +### Changed + +- README positioning now leads with the release-evidence user problem, current + limitations, fastest proof path, and differentiation from adjacent tools. +- Security reporting guidance no longer depends on LinkedIn as the first + recommended path and calls out external repository settings that operators + must verify. + +### Known Limits + +- Public GitHub release publication, branch protection, public CI status, + private vulnerability-reporting settings, and public adoption evidence remain + external repository/operator proof. +- High-scale multi-writer API HA, native PKCS#11/HSM module handling, direct + provider management API/group synchronization, broad WORM/object-lock proof, + and final production exit review remain hardening work or deployment-specific + review items. + +## v0.1.0-rc.2 - 2026-05-31 + +Release status: controlled self-hosted production candidate. This build is +suitable for evaluation, pilots, and controlled internal production after +operator review. Broad self-hosted production readiness, regulated production, +and hosted SaaS production remain out of scope for this status. The local +release-candidate package for this tag was generated with +`make release-candidate-check TAG=v0.1.0-rc.2`. + +### Added + +- Root legal, governance, security, support, trademark, commercial licensing, + release-evidence, and changelog metadata. +- Production-readiness profile, production gate, and coverage-threshold gate. +- Release-candidate checklist requiring production-check evidence, checksums, + signed artifact manifests, release notes, and documented limitations. +- Release-candidate package gate for controlled release-candidate artifacts, checksums, + OpenAPI and migration checksums, checked release notes, signed release + manifest, and manifest signature. +- Local `v0.1.0-rc.2` annotated release-candidate tag and signed evidence + package generated under ignored `dist/v0.1.0-rc.2/`. +- Focused PostgreSQL critical mutations for tenants, credential hashes, + idempotency records, audit-chain entries, release bundles, signatures, + verification results, provider verification receipts, vulnerability + decisions, and outbox jobs. +- Focused PostgreSQL release-ledger mutations for products, projects, releases, + artifacts, evidence items, evidence lifecycle events, SBOMs, vulnerability + scans, OpenAPI contracts, VEX documents, audit-chain entries, and parser + outbox jobs. +- Relational PostgreSQL state synchronization for remaining aggregate + persistence calls without writing the compatibility `ledger_state` snapshot. +- AWS KMS signing executor for signing provider operations over stored + SHA-256 payload hashes without sending raw evidence payload bytes. +- Optional OIDC UserInfo live provider validation for + `POST /v1/provider-verifications` using a supplied access token that is not + persisted. +- Object-retention policies can require sample-object legal hold verification + with S3/MinIO providers that expose object legal-hold status. +- Production API startup takes a PostgreSQL advisory writer lease so accidental + second API writers fail closed under the supported single-writer profile. + +### Known Limits + +- Evydence supports compliance readiness and technical evidence organization. +- Operators remain responsible for production PostgreSQL, object storage, + network policy, TLS, backups, monitoring, external signing, and incident + response. +- Service decomposition, HA/multi-writer operation, non-AWS KMS/HSM SDK + adapters, provider-specific management API/group synchronization, broader + object-lock proof beyond configured bucket/sample-object checks, and final + exit review remain production-hardening work after the release-candidate + gate. diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 0000000..fb3496e --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1,39 @@ +# Maintainer review ownership for high-trust Evydence changes. +# +# GitHub branch protection or repository rules must enforce these patterns +# before CODEOWNERS becomes a merge gate. Until then, this file documents the +# expected reviewer surface for security-sensitive paths. + +* @aatuh + +# Core application, tenant boundaries, evidence integrity, and policies. +/internal/app/ @aatuh +/internal/domain/ @aatuh +/internal/adapters/httpapi/ @aatuh +/internal/adapters/postgres/ @aatuh +/internal/adapters/objectstore/ @aatuh + +# Executables, operators, collectors, release tooling, and scripts. +/cmd/ @aatuh +/scripts/ @aatuh +/.github/workflows/ @aatuh +/Makefile @aatuh + +# Persistence, deployment, release evidence, and public trust surfaces. +/migrations/ @aatuh +/deploy/ @aatuh +/Dockerfile @aatuh +/docker-compose.yml @aatuh +/compose.production-like.yml @aatuh +/openapi.yaml @aatuh +/RELEASE_EVIDENCE.md @aatuh +/SECURITY.md @aatuh + +# Documentation that affects production positioning or public claims. +/README.md @aatuh +/docs/reference/production-readiness.md @aatuh +/docs/reference/production-exit-review.md @aatuh +/docs/reference/release-candidate.md @aatuh +/docs/reference/release-validation.md @aatuh +/docs/reference/release-evidence-index.md @aatuh +/docs/reference/maintainer-review-policy.md @aatuh diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..bf9652b --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,36 @@ +# Code Of Conduct + +Evydence is a high-trust release-evidence project. Participation must preserve +the same standard expected from the software: clear, respectful, evidence-based, +and careful with security-sensitive information. + +## Expected Behavior + +- Be direct without being abusive. +- Critique claims, code, docs, tests, and designs with concrete evidence. +- Keep discussions focused on Evydence, its users, and its technical tradeoffs. +- Respect boundaries around tenant data, credentials, raw evidence payloads, + customer package contents, provider tokens, private keys, and vulnerability + reports. +- Use private security intake for suspected vulnerabilities instead of public + issues, pull requests, screenshots, logs, or examples. + +## Unacceptable Behavior + +- Harassment, threats, personal attacks, or discriminatory conduct. +- Publishing secrets, credentials, raw evidence payloads, customer data, private + vulnerability details, or exploit material in public project channels. +- Repeatedly pushing legal compliance, certification, secure-release, complete + SBOM, authoritative scanner, regulator-acceptance, or auditor-acceptance + claims that the project explicitly does not make. +- Misrepresenting modified builds as official Evydence releases. + +## Enforcement + +Maintainers may edit, hide, lock, or remove comments, issues, pull requests, or +other contributions that violate this policy. Serious or repeated violations may +lead to a block from project spaces. Security-sensitive material may be removed +without preserving public context. + +This code of conduct is a project participation policy, not a legal compliance +or certification statement. diff --git a/COMMERCIAL.md b/COMMERCIAL.md new file mode 100644 index 0000000..7d3308a --- /dev/null +++ b/COMMERCIAL.md @@ -0,0 +1,113 @@ +# Commercial Licensing + +Evydence is publicly available under the GNU Affero General Public License +version 3.0 (`AGPL-3.0-only`). The public license is suitable for users who can +comply with AGPL obligations, including source-availability obligations for +modified network-service deployments. + +Commercial license exceptions are available for organizations that want to use +Evydence in proprietary products, private SaaS systems, closed internal +deployments, or other contexts where AGPL obligations are not suitable. + +This is not legal advice. Have counsel review AGPL and any commercial agreement +before relying on it. + +## License Decision Table + +This table is a practical routing aid, not legal advice. + +| Situation | Likely path to review | Why | +| --- | --- | --- | +| Evaluating Evydence locally, reading source, or running non-production tests | Public `AGPL-3.0-only` license may be enough if your use complies with AGPL. | The public repository remains available under AGPL. | +| Self-hosting Evydence for an internal team that can satisfy AGPL obligations | Public `AGPL-3.0-only` plus operator review may be enough. | You keep the public-license obligations and run your own deployment. | +| Modifying Evydence for a network service where AGPL source-availability obligations are acceptable | Public `AGPL-3.0-only` may be enough after counsel review. | AGPL is designed to preserve source availability for modified network services. | +| Embedding Evydence into proprietary products, private SaaS, closed appliances, or closed internal platforms where AGPL obligations do not fit | Discuss a commercial license exception. | A written exception can grant extra permission for a defined organization, product, deployment, or distribution model. | +| Needing private deployment review, upgrade planning, release evidence review, or integration support | Discuss commercial support. | Support scope, response expectations, and deliverables should be written down before relying on them. | +| Needing legal compliance conclusions, certification, secure-release guarantees, complete SBOM proof, or authoritative vulnerability results | Evydence is not the right source for that conclusion. | Evydence organizes technical evidence and limitations; legal, certification, audit, and security conclusions require separate review. | + +For contribution rights, see [Contributing](CONTRIBUTING.md). For support +boundaries, see [Support](SUPPORT.md). For decision authority and product +language rules, see [Governance](GOVERNANCE.md). + +## Paid Options + +| Offer | Customer hosts? | Evydence hosts? | Notes | +| --- | ---: | ---: | --- | +| Commercial license exception | Yes | No | Written permission for agreed proprietary use cases. | +| Self-hosted support | Yes | No | Deployment review, upgrade help, troubleshooting, and security notices. | +| Release evidence readiness review | Yes | No | One scoped release workflow: install/configuration review, CI evidence upload, first customer-safe package, verification walkthrough, and limitations. | +| Release evidence package | Yes | No | SBOMs, vulnerability scan results, OpenAPI checksum, acceptance evidence, and hardening notes. | +| Production readiness review | Yes | No | Configuration, backup, restore, evidence handling, and deployment review. | +| Custom integration work | Yes | No | Collector adapters, evidence workflows, report templates, or deployment hardening. | + +## Release Evidence Readiness Review + +The default paid services shape is a scoped self-hosted release evidence +readiness review. It is meant for teams that want to prove one practical +workflow before deciding whether Evydence belongs in their release process. + +Typical scope: + +- install or review a self-hosted Evydence deployment profile; +- configure one product, project, release, artifact digest, and redaction + profile; +- connect one CI path for SBOM, vulnerability scan, build, artifact, and release + bundle evidence where the operator already has those files or commands; +- generate one customer-safe package or evidence bundle; +- run the package verifier and walk through reviewer-facing limitations, + assumptions, gaps, and non-claims; +- document follow-up work as repo-local product gaps, operator-owned deployment + responsibilities, or external provider dependencies. + +Deliverables: + +- short written readiness summary; +- completed or gap-marked deployment checklist for the reviewed profile; +- example upload or CI command path for the agreed release; +- one generated package or bundle with manifest, hashes, verification material, + assumptions, limitations, and non-claims; +- prioritized next-step list for production hardening, integrations, or package + sharing. + +Out of scope unless separately agreed: + +- legal compliance advice, certification, audit opinion, regulator acceptance, + secure-release guarantee, complete SBOM proof, or authoritative vulnerability + coverage; +- hosted SaaS operation by Evydence; +- unlimited custom collectors, scanner replacement, broad GRC workflow, or + customer portal development; +- public handling of raw customer evidence, tokens, private keys, database URLs, + provider credentials, or unreleased product details. + +## Commercial License Scope + +A commercial license is a separate written agreement. It does not remove or +reduce the rights granted to the public under AGPL. It can grant extra +permission for a named organization, product, deployment, or distribution model +where AGPL terms are not a fit. + +Commercial terms can cover: + +- proprietary embedding or distribution, +- closed-source network-service operation, +- private modifications without AGPL redistribution obligations, +- support and upgrade commitments, +- signed release and evidence delivery, +- self-hosted deployment review, +- collector or report integration work. + +Commercial terms do not imply legal compliance, certification, secure releases, +complete SBOMs, authoritative vulnerability results, regulator acceptance, or +auditor acceptance. Evydence supports compliance readiness and technical +evidence organization only. + +No SLA, warranty, compliance certification, hosted service, provider-side +completeness guarantee, or legal conclusion is included unless a written +agreement explicitly says so. + +## Contact + +For commercial licensing or paid support, contact Aatu Harju through LinkedIn: + + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..257b2ac --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,89 @@ +# Contributing + +Evydence accepts contributions selectively. The project uses an AGPL public +license plus optional commercial license exceptions, so contribution rights must +be handled deliberately. + +## License Requirement + +By default, Evydence is licensed under `AGPL-3.0-only`. + +Substantive external contributions require explicit maintainer approval and a +contributor license agreement before merge. The purpose is to preserve the +ability to offer commercial license exceptions while keeping the public +repository available under AGPL. Issue reports and high-level discussion do not +require a contributor license agreement. + +Do not submit code, docs, tests, schemas, generated artifacts, provider +fixtures, or release evidence unless you have the right to license them to the +project under terms compatible with this model. + +## First Contribution Path + +Small contributions are welcome when they preserve the project boundaries. Good +starter contributions are: + +- typo fixes in documentation; +- broken link fixes; +- clearer command examples that point back to canonical docs; +- non-sensitive fixture improvements; +- issue reports with sanitized reproduction steps; +- small docs updates that remove overstated compliance, certification, + complete-SBOM, scanner-authority, or secure-release language. + +Before opening a first pull request: + +1. Open or reference an issue for anything larger than a typo or broken link. +2. Keep the change scoped to one topic. +3. Avoid generated artifacts unless the documented project command produced + them and the change requires them. +4. Run `make docs-check` for docs-only changes. Run `make finalize` when code, + examples, OpenAPI, deployment files, SDKs, or release behavior are touched. +5. Expect a contributor license agreement before any substantive change is + merged. + +Issue reports do not need a contributor license agreement, but they must be +safe to share. Include sanitized commands, versions, error codes, and expected +versus actual behavior. Do not include tenant names, customer names, raw +evidence payloads, object-store paths, API keys, collector keys, bearer tokens, +session tokens, portal tokens, private keys, provider credentials, database +URLs, local backups, unpublished customer packages, or release signing +material. + +## Development Rules + +Before opening a change: + +1. Read `AGENTS.md`, `README.md`, `openapi.yaml`, + `docs/reference/source-of-truth.md`, and the relevant docs under `docs/`. +2. Keep OpenAPI, tests, migrations, docs, SDK artifacts, examples, deployment + files, and release evidence aligned when behavior changes. +3. Preserve tenant isolation, append-only evidence behavior, idempotency, + canonical hashing, verification receipts, safe Problem Details responses, + secret redaction, and conservative product language. +4. Do not introduce claims that Evydence provides legal compliance, + certification, complete SBOMs, authoritative scanner results, secure + releases, regulator acceptance, or auditor acceptance. + +Useful checks: + +```sh +make docs-check +make fast-check +make finalize +``` + +For release-readiness changes, also run: + +```sh +make release-acceptance +make release-check +``` + +Database-backed checks require `EVYDENCE_TEST_DATABASE_URL`. Do not point test +commands at production databases, production object stores, KMS keys, or live +provider accounts. + +Do not include API keys, collector keys, bearer tokens, session tokens, portal +tokens, private keys, provider credentials, database URLs, raw evidence +payloads, customer data, backup files, or release evidence artifacts in commits. diff --git a/Dockerfile b/Dockerfile index 6793178..97ee4d6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,16 +1,21 @@ -FROM golang:1.25-alpine AS build +FROM golang:1.26-alpine@sha256:91eda9776261207ea25fd06b5b7fed8d397dd2c0a283e77f2ab6e91bfa71079d AS build WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . . RUN go build -o /out/evydence-api ./cmd/evydence-api +RUN go build -o /out/evydence-migrate ./cmd/evydence-migrate +RUN go build -o /out/evydence-worker ./cmd/evydence-worker RUN go build -o /out/evydence ./cmd/evydence -FROM alpine:3.22 +FROM alpine:3.23@sha256:5b10f432ef3da1b8d4c7eb6c487f2f5a8f096bc91145e68878dd4a5019afde11 RUN addgroup -S evydence && adduser -S -G evydence evydence USER evydence WORKDIR /app COPY --from=build /out/evydence-api /usr/local/bin/evydence-api +COPY --from=build /out/evydence-migrate /usr/local/bin/evydence-migrate +COPY --from=build /out/evydence-worker /usr/local/bin/evydence-worker COPY --from=build /out/evydence /usr/local/bin/evydence +COPY --from=build /src/migrations /app/migrations EXPOSE 8080 ENTRYPOINT ["evydence-api"] diff --git a/GOVERNANCE.md b/GOVERNANCE.md new file mode 100644 index 0000000..3cce282 --- /dev/null +++ b/GOVERNANCE.md @@ -0,0 +1,49 @@ +# Governance + +Evydence is currently maintainer-led. + +## Maintainer + +The project maintainer is Aatu Harju. + +Commercial, support, trademark, security, or governance questions can be routed +through LinkedIn: + + + +## Decision Model + +The maintainer has final decision authority over: + +- roadmap and release scope, +- license and commercial terms, +- security response, +- contribution acceptance, +- release evidence requirements, +- trademark and naming permission, +- claims and non-claims, +- supported collector, report, and deployment boundaries. + +Large changes should preserve the project’s core invariants: + +- evidence core fields remain immutable after creation, +- transitions, approvals, waivers, exceptions, audit entries, and chain entries + stay append-only, +- every primary resource is tenant-scoped, +- API keys, collector keys, session tokens, portal tokens, private keys, raw + evidence payloads, customer data, and unnecessary PII are not logged or + exported, +- raw payload integrity, canonical hashes, signatures, and verification + receipts remain reproducible, +- reports show evidence, gaps, assumptions, exceptions, and limitations, +- compliance and legal language stays conservative. + +## Commercial Boundary + +The public repository remains available under AGPL. Separate commercial license +exceptions, support agreements, release evidence packages, and self-hosted +support packages are handled outside the public issue tracker unless the +maintainer explicitly chooses otherwise. + +Commercial work does not broaden Evydence’s public non-claims unless a signed +agreement explicitly narrows scope for that engagement. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..be3f7b2 --- /dev/null +++ b/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/Makefile b/Makefile index ab02da6..9f87ef9 100644 --- a/Makefile +++ b/Makefile @@ -7,7 +7,9 @@ GOLANGCI_LINT_VERSION ?= v2.11.4 GOSEC_VERSION ?= v2.25.0 GOVULNCHECK_VERSION ?= v1.2.0 -.PHONY: help tools fmt lint vuln gosec test test-race coverage openapi-check docs-check deploy-check sdk-check fast-check finalize compose-up compose-down migrate live-postgres-check postgres-integration-test clean +TAG ?= + +.PHONY: help tools fmt lint vuln gosec test test-race fuzz-smoke coverage coverage-check openapi-check openapi-precision-check rendered-openapi-check meta-check release-truth-check persistence-decomposition-check docs-check deploy-check sdk-check demo-check customer-cve-review-demo-check local-ci-simulation-check reviewer-package-workflow-check black-box-demo-check black-box-release-artifact-check benchmark-check package-viewer-check release-asset-smoke-check marketing-site-check marketing-site-production-check restore-rehearsal-check fast-check finalize release-acceptance release-check production-check release-candidate-check public-release-verify migration-compatibility-check release-check-local-postgres compose-up compose-down migrate live-postgres-check postgres-integration-test clean help: ## Show help @awk 'BEGIN {FS=":.*## "}; /^[a-zA-Z0-9_.-]+:.*## / { printf " %-18s %s\n", $$1, $$2 }' $(MAKEFILE_LIST) @@ -32,6 +34,9 @@ gosec: ## Run gosec when installed test: ## Run unit tests @$(GO) test ./... +fuzz-smoke: ## Run Go fuzz target seed corpora without a fuzzing campaign + @$(GO) test ./internal/app -run '^Fuzz' + test-race: ## Run race tests @$(GO) test ./... -race -count=1 @@ -39,6 +44,9 @@ coverage: ## Run tests with coverage @$(GO) test ./... -coverprofile=coverage.out @$(GO) tool cover -func=coverage.out +coverage-check: ## Enforce the production coverage threshold; requires EVYDENCE_TEST_DATABASE_URL + @scripts/coverage_check.sh + openapi.yaml: ## Generate committed OpenAPI source @$(GO) run ./cmd/openapi > openapi.yaml @@ -46,10 +54,106 @@ openapi-check: openapi.yaml ## Validate OpenAPI generation and route contract te @$(GO) test ./internal/adapters/httpapi -run 'TestRoutesValidateAndOpenAPIRenders' @$(GO) run ./cmd/openapi > /tmp/evydence-openapi.yaml @cmp -s openapi.yaml /tmp/evydence-openapi.yaml + @scripts/render_openapi_docs.py --check + +openapi-precision-check: ## Enforce current OpenAPI precision floor and broad-route ceiling + @python3 scripts/openapi_precision_check.py + +rendered-openapi-check: ## Validate generated static OpenAPI docs + @scripts/render_openapi_docs.py --check + +meta-check: ## Validate root legal, governance, support, and release-evidence metadata + @test -f LICENSE + @test -f COMMERCIAL.md + @test -f GOVERNANCE.md + @test -f CONTRIBUTING.md + @test -f SECURITY.md + @test -f SUPPORT.md + @test -f TRADEMARKS.md + @test -f CODE_OF_CONDUCT.md + @test -f CODEOWNERS + @test -f RELEASE_EVIDENCE.md + @test -f CHANGELOG.md + @test -f .dockerignore + @test -f .github/dependabot.yml + @test -f .github/workflows/scorecard.yml + @test -f .github/workflows/container-image.yml + @test -f .github/workflows/codeql.yml + @test -f .github/workflows/marketing-site-pages.yml + @test -f .github/ISSUE_TEMPLATE.md + @test -f .github/ISSUE_TEMPLATE/bug_report.yml + @test -f .github/ISSUE_TEMPLATE/feature_request.yml + @test -f .github/ISSUE_TEMPLATE/docs.yml + @test -f .github/ISSUE_TEMPLATE/production_support.yml + @test -f .github/pull_request_template.md + @test -x scripts/release_acceptance.sh + @test -x scripts/release_candidate_package.sh + @test -x scripts/release_candidate_validate.sh + @test -x scripts/release_asset_smoke_check.sh + @test -x scripts/release_evidence_metadata.py + @grep -F 'GNU AFFERO GENERAL PUBLIC LICENSE' LICENSE >/dev/null + @grep -F 'AGPL-3.0-only' COMMERCIAL.md >/dev/null + @grep -F 'Commercial license exceptions' COMMERCIAL.md >/dev/null + @grep -F 'License Decision Table' COMMERCIAL.md >/dev/null + @grep -F 'This table is a practical routing aid, not legal advice.' COMMERCIAL.md >/dev/null + @grep -F 'Discuss a commercial license exception' COMMERCIAL.md >/dev/null + @grep -F 'Release Evidence Readiness Review' COMMERCIAL.md >/dev/null + @grep -F 'one customer-safe package or evidence bundle' COMMERCIAL.md docs/commercial/product-landing-copy.md >/dev/null + @grep -F 'Out of scope unless separately agreed' COMMERCIAL.md >/dev/null + @grep -F 'contributor license agreement' CONTRIBUTING.md >/dev/null + @grep -F 'First Contribution Path' CONTRIBUTING.md >/dev/null + @grep -F 'Issue reports do not need a contributor license agreement' CONTRIBUTING.md >/dev/null + @grep -F 'object-store paths' CONTRIBUTING.md >/dev/null + @grep -F 'raw evidence payloads' SECURITY.md >/dev/null + @grep -F 'release evidence artifacts' SUPPORT.md >/dev/null + @grep -F 'Security vulnerability' .github/ISSUE_TEMPLATE/config.yml >/dev/null + @grep -F 'private vulnerability reporting' .github/ISSUE_TEMPLATE.md >/dev/null + @grep -F 'tenant isolation' .github/pull_request_template.md >/dev/null + @grep -F 'internal/app/' CODEOWNERS >/dev/null + @grep -F 'docs/reference/release-evidence-index.md' CODEOWNERS >/dev/null + @grep -F 'OpenSSF Scorecard' .github/workflows/scorecard.yml >/dev/null + @grep -F 'ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a' .github/workflows/scorecard.yml >/dev/null + @grep -F 'OpenSSF Scorecard SARIF' .github/workflows/scorecard-sarif.yml >/dev/null + @grep -F 'ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a' .github/workflows/scorecard-sarif.yml >/dev/null + @grep -F 'github/codeql-action/upload-sarif@7211b7c8077ea37d8641b6271f6a365a22a5fbfa' .github/workflows/scorecard-sarif.yml >/dev/null + @grep -F 'github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa' .github/workflows/codeql.yml >/dev/null + @grep -F 'actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd' .github/workflows/ci.yml >/dev/null + @grep -F 'actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c' .github/workflows/ci.yml >/dev/null + @grep -F 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' .github/workflows/ci.yml >/dev/null + @grep -F 'postgres:16-alpine@sha256:16bc17c64a573ef34162af9298258d1aec548232985b33ed7b1eac33ba35c229' .github/workflows/ci.yml >/dev/null + @grep -F 'ghcr.io/$${{ github.repository }}' .github/workflows/container-image.yml >/dev/null + @grep -F 'cosign sign --yes' .github/workflows/container-image.yml >/dev/null + @grep -F 'cosign verify' .github/workflows/container-image.yml >/dev/null + @grep -F -- '--provenance=true' .github/workflows/container-image.yml >/dev/null + @grep -F -- '--sbom=true' .github/workflows/container-image.yml >/dev/null + @grep -F 'https://evydence.app' .github/workflows/marketing-site-pages.yml >/dev/null + @grep -F 'G-XC2ESEHQ3W' Makefile >/dev/null + @grep -F 'actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e' .github/workflows/marketing-site-pages.yml >/dev/null + @grep -F 'actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa' .github/workflows/marketing-site-pages.yml >/dev/null + @grep -F 'actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b' .github/workflows/marketing-site-pages.yml >/dev/null + @grep -F 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' .github/workflows/marketing-site-pages.yml >/dev/null + @grep -F 'pages: write' .github/workflows/marketing-site-pages.yml >/dev/null + @test -f site/marketing/public/CNAME + @grep -Fx 'evydence.app' site/marketing/public/CNAME >/dev/null + @grep -F 'Evydence fork' TRADEMARKS.md >/dev/null + @grep -F 'Release evidence is not a certification' RELEASE_EVIDENCE.md >/dev/null + @grep -F '.refs' .dockerignore >/dev/null + @grep -F 'release-evidence' .dockerignore >/dev/null + @grep -F 'backups' .dockerignore >/dev/null + @grep -F '*.pem' .dockerignore >/dev/null + +release-truth-check: ## Validate current release metadata against public docs and helper defaults + @scripts/check_release_truth.py -docs-check: ## Validate canonical docs exist and avoid forbidden product claims +persistence-decomposition-check: ## Validate generated persistence decomposition inventory + @scripts/persistence_decomposition_inventory.py --check + +docs-check: meta-check release-truth-check persistence-decomposition-check rendered-openapi-check ## Validate canonical docs exist and avoid forbidden product claims @test -f README.md + @test -f .production.env.example @test -f docs/README.md + @test -f docs/buyer-overview.md + @test -f docs/operator-overview.md @test -f docs/architecture.md @test -f docs/api.md @test -f docs/operations.md @@ -57,38 +161,571 @@ docs-check: ## Validate canonical docs exist and avoid forbidden product claims @test -f docs/air-gapped.md @test -f docs/release-signing.md @test -f docs/production-hardening.md + @test -f docs/tutorials/evaluate-in-10-minutes.md + @test -f docs/tutorials/customer-cve-review-demo.md @test -f docs/tutorials/getting-started.md + @test -f docs/how-to/integrate-ci.md @test -f docs/how-to/install-and-operate.md + @test -f docs/how-to/view-packages.md + @test -f docs/how-to/publish-marketing-site.md + @test -f docs/integrations/tool-templates.md + @test -f docs/reference/configuration.md + @test -f docs/reference/capability-map.md + @test -f docs/reference/api-contract-matrix.md @test -f docs/reference/openapi.md + @test -f docs/openapi/index.html + @test -f site/marketing/public/api/index.html + @test -f docs/reference/vulnerability-decisions.md + @test -f docs/reference/observability.md + @test -f docs/reference/capacity-and-failures.md + @test -f docs/reference/benchmark-results.md + @test -f docs/reference/production-readiness.md + @test -f docs/reference/production-readiness-traceability.md + @test -f docs/reference/production-internal-exit-checklist.md + @test -f docs/reference/production-readiness-audit-closeout.md + @test -f docs/reference/production-internal-score.md + @test -f docs/reference/persistence-decomposition.md + @test -f docs/reference/production-exit-review.md + @test -f docs/reference/stable-v0.1.0-exit-criteria.md + @test -f docs/reference/hardened-reference-deployment.md + @test -f docs/reference/ha-strategy.md + @test -f docs/reference/external-controls-matrix.md + @test -f docs/reference/production-gate-troubleshooting.md + @test -f docs/reference/upgrade-compatibility-policy.md + @test -f docs/reference/release-candidate.md + @test -f docs/reference/release-evidence-index.md + @test -f docs/reference/release-notes-template.md + @test -f docs/reference/release-notes-v0.1.0-rc.1.md + @test -f docs/reference/maintainer-review-policy.md + @test -f docs/reference/roadmap.md + @test -f docs/reference/worker-outbox.md + @test -f docs/reference/release-validation.md + @test -f docs/reference/upload-manifest.md + @test -f docs/how-to/review-customer-package.md + @test -f docs/runbooks/key-rotation.md + @test -f docs/runbooks/object-store-recovery.md + @test -f schemas/upload-manifest.v1.schema.json @test -f docs/explanation/trust-model.md - @! grep -R -i "automatically compliant\|certified secure\|legally sufficient\|SBOM is complete\|all vulnerabilities detected" README.md docs + @test -f docs/collectors/source-snapshots.md + @test -f docs/collectors/supply-chain.md + @test -f docs/github-actions/end-to-end-release-evidence.md + @test -f docs/github-actions/quickstart-release-evidence.yml + @test -f docs/github-actions/release-evidence-workflow.yml + @test -f docs/github-actions/upload-build/action.yml + @test -f docs/gitlab/evydence-release-evidence.gitlab-ci.yml + @test -f .github/workflows/release-artifacts.yml + @test -f .github/workflows/container-image.yml + @test -f .github/workflows/codeql.yml + @test -f docs/sdk/README.md + @test -f docs/sdk/quickstarts.md + @for path in \ + "tutorials/evaluate-in-10-minutes.md" \ + "tutorials/customer-cve-review-demo.md" \ + "tutorials/getting-started.md" \ + "buyer-overview.md" \ + "operator-overview.md" \ + "how-to/install-and-operate.md" \ + "how-to/view-packages.md" \ + "how-to/review-customer-package.md" \ + "how-to/integrate-ci.md" \ + "integrations/tool-templates.md" \ + "api.md" \ + "operations.md" \ + "kubernetes.md" \ + "air-gapped.md" \ + "release-signing.md" \ + "production-hardening.md" \ + "reference/configuration.md" \ + "reference/capability-map.md" \ + "reference/api-contract-matrix.md" \ + "reference/openapi.md" \ + "openapi/index.html" \ + "reference/vulnerability-decisions.md" \ + "reference/observability.md" \ + "reference/capacity-and-failures.md" \ + "reference/benchmark-results.md" \ + "reference/production-readiness.md" \ + "reference/production-readiness-traceability.md" \ + "reference/production-internal-exit-checklist.md" \ + "reference/production-readiness-audit-closeout.md" \ + "reference/production-internal-score.md" \ + "reference/persistence-decomposition.md" \ + "reference/production-exit-review.md" \ + "reference/stable-v0.1.0-exit-criteria.md" \ + "reference/hardened-reference-deployment.md" \ + "reference/ha-strategy.md" \ + "reference/external-controls-matrix.md" \ + "reference/production-gate-troubleshooting.md" \ + "reference/upgrade-compatibility-policy.md" \ + "reference/release-candidate.md" \ + "reference/release-evidence-index.md" \ + "reference/release-notes-template.md" \ + "reference/release-notes-v0.1.0-rc.1.md" \ + "reference/maintainer-review-policy.md" \ + "reference/roadmap.md" \ + "reference/worker-outbox.md" \ + "reference/release-validation.md" \ + "reference/upload-manifest.md" \ + "runbooks/key-rotation.md" \ + "runbooks/object-store-recovery.md" \ + "collectors/source-snapshots.md" \ + "collectors/supply-chain.md" \ + "github-actions/end-to-end-release-evidence.md" \ + "github-actions/quickstart-release-evidence.yml" \ + "github-actions/release-evidence-workflow.yml" \ + "github-actions/upload-build/action.yml" \ + "gitlab/evydence-release-evidence.gitlab-ci.yml" \ + "sdk/README.md" \ + "sdk/quickstarts.md" \ + "architecture.md" \ + "explanation/trust-model.md"; do \ + grep -F "$$path" docs/README.md >/dev/null || { echo "docs/README.md missing link to $$path"; exit 1; }; \ + done + @python3 -c 'import json,re,sys; from pathlib import Path; spec=json.loads(Path("openapi.yaml").read_text()); doc=Path("docs/api.md").read_text(); openapi=set(spec["paths"]); catalog=set(re.findall(r"`(/v1/[^`]+)`", doc)); missing=sorted(openapi-catalog); extra=sorted(catalog-openapi); [print("docs/api.md missing OpenAPI path: "+p) for p in missing]; [print("docs/api.md lists non-OpenAPI path: "+p) for p in extra]; sys.exit(1 if missing or extra else 0)' + @scripts/openapi_contract_matrix.py > /tmp/evydence-api-contract-matrix.md + @cmp -s docs/reference/api-contract-matrix.md /tmp/evydence-api-contract-matrix.md + @grep -F 'case "release"' cmd/evydence/main.go >/dev/null + @grep -F 'case "import-bundle"' cmd/evydence/main.go >/dev/null + @grep -F 'case "upload"' cmd/evydence/main.go >/dev/null + @grep -F './dist/evydence release manifest' docs/release-signing.md >/dev/null + @grep -F './dist/evydence release keygen' docs/release-signing.md >/dev/null + @grep -F './dist/evydence release sign' docs/release-signing.md >/dev/null + @grep -F './dist/evydence release verify' docs/release-signing.md >/dev/null + @grep -F './dist/evydence release manifest' docs/air-gapped.md >/dev/null + @grep -F './evydence release verify' docs/air-gapped.md >/dev/null + @grep -F './evydence import-bundle upload' docs/air-gapped.md >/dev/null + @test -x scripts/public_release_verify.sh + @grep -F 'This CVE appears in your SBOM. Are you affected, why, who approved it, and' docs/tutorials/evaluate-in-10-minutes.md >/dev/null + @grep -F 'sample-customer-package-manifest.json' docs/tutorials/evaluate-in-10-minutes.md >/dev/null + @grep -F 'site/package-viewer/index.html' docs/tutorials/evaluate-in-10-minutes.md >/dev/null + @grep -F 'examples/end-to-end-release-evidence/run-local-demo.sh' docs/tutorials/evaluate-in-10-minutes.md >/dev/null + @grep -F 'Evaluate Evydence in 10 minutes' README.md docs/README.md >/dev/null + @grep -F 'Customer CVE review demo' README.md docs/README.md docs/tutorials/customer-cve-review-demo.md >/dev/null + @grep -F 'The core buyer question is' README.md >/dev/null + @grep -F 'Buyer evaluation overview' README.md docs/README.md >/dev/null + @grep -F 'Operator overview' README.md docs/README.md >/dev/null + @grep -F 'OpenAPI reference' README.md >/dev/null + @grep -F 'Rendered OpenAPI docs' README.md docs/README.md docs/reference/openapi.md >/dev/null + @grep -F 'site/marketing/public/api/index.html' docs/reference/openapi.md >/dev/null + @grep -F 'SDK quickstarts' docs/README.md docs/sdk/README.md docs/sdk/quickstarts.md >/dev/null + @grep -F 'Problem Details' docs/sdk/quickstarts.md >/dev/null + @grep -F 'Idempotency-Key' docs/sdk/quickstarts.md >/dev/null + @grep -F 'dist/evydence package verify' docs/sdk/quickstarts.md >/dev/null + @grep -F 'Install and operate' README.md >/dev/null + @grep -F 'Fastest Buyer Path' docs/buyer-overview.md >/dev/null + @grep -F 'Primary Operator Path' docs/operator-overview.md >/dev/null + @grep -F 'Capability map' README.md docs/README.md >/dev/null + @grep -F 'Production Check' README.md docs/reference/release-evidence-index.md >/dev/null + @grep -F 'coverage.out' README.md docs/reference/release-evidence-index.md >/dev/null + @grep -F 'release-check-summary.txt' README.md docs/reference/release-evidence-index.md >/dev/null + @grep -F 'Production readiness traceability' docs/README.md >/dev/null + @grep -F 'Traceability Matrix' docs/reference/production-readiness-traceability.md >/dev/null + @grep -F 'Unmapped Internal Findings' docs/reference/production-readiness-traceability.md >/dev/null + @grep -F 'External Blockers' docs/reference/production-readiness-traceability.md >/dev/null + @grep -F 'Internal production-readiness exit checklist' docs/README.md >/dev/null + @grep -F 'Required Internal State' docs/reference/production-internal-exit-checklist.md >/dev/null + @grep -F 'make production-check' docs/reference/production-internal-exit-checklist.md >/dev/null + @grep -F 'every external item must have a current owner and status' docs/reference/production-internal-exit-checklist.md >/dev/null + @grep -F 'fresh production-readiness audit finds no new repo-local remediation' docs/reference/production-internal-exit-checklist.md >/dev/null + @grep -F 'Production readiness audit closeout' docs/README.md >/dev/null + @grep -F 'Fresh Audit Result' docs/reference/production-readiness-audit-closeout.md >/dev/null + @grep -F 'found no new repo-local remediation' docs/reference/production-readiness-audit-closeout.md >/dev/null + @grep -F 'Production-usable with caveats' docs/reference/production-readiness-audit-closeout.md >/dev/null + @grep -F 'Highest achievable internal score' docs/README.md >/dev/null + @grep -F 'Overall | 8.3/10' docs/reference/production-internal-score.md >/dev/null + @grep -F 'Remaining External Blockers' docs/reference/production-internal-score.md >/dev/null + @grep -F 'controlled self-hosted production candidate' docs/reference/production-internal-score.md >/dev/null + @! grep -R -F '.audits/' README.md docs + @test -x scripts/production_benchmark_check.sh + @grep -F 'production_benchmark_check.sh' docs/reference/benchmark-results.md >/dev/null + @grep -F 'tmp/production-benchmark/production-benchmark-summary.json' docs/reference/benchmark-results.md >/dev/null + @grep -F 'local regression benchmark' docs/reference/benchmark-results.md >/dev/null + @grep -F 'Implemented-But-Partial Areas' docs/reference/capability-map.md >/dev/null + @grep -F 'VEX/manual' README.md >/dev/null + @test -f docs/assets/package-viewer-preview.svg + @test -f docs/assets/package-viewer-desktop.png + @test -f docs/assets/package-viewer-mobile.png + @grep -F 'package-viewer-preview.svg' docs/how-to/view-packages.md >/dev/null + @grep -F 'package-viewer-desktop.png' docs/how-to/view-packages.md >/dev/null + @grep -F 'package-viewer-mobile.png' docs/how-to/view-packages.md >/dev/null + @grep -F 'scripts/capture_package_viewer_screenshots.sh' docs/how-to/view-packages.md >/dev/null + @grep -F 'report.html' docs/reference/customer-package-manifest.md docs/how-to/view-packages.md >/dev/null + @grep -F 'Redaction Leakage Guard' docs/reference/customer-package-manifest.md >/dev/null + @grep -F 'reviewer_checklist' docs/reference/customer-package-manifest.md >/dev/null + @grep -F 'make restore-rehearsal-check' docs/runbooks/backup-restore.md >/dev/null + @grep -F 'API key' docs/runbooks/key-rotation.md >/dev/null + @grep -F 'Tenant signing key' docs/runbooks/key-rotation.md >/dev/null + @grep -F 'make restore-rehearsal-check' docs/runbooks/object-store-recovery.md >/dev/null + @grep -F 'Do not regenerate or overwrite historical evidence' docs/runbooks/object-store-recovery.md >/dev/null + @test -f docs/how-to/pilot-deployment-checklist.md + @grep -F 'Required: external PostgreSQL' docs/how-to/pilot-deployment-checklist.md >/dev/null + @grep -F 'external controls matrix' docs/reference/production-readiness.md docs/kubernetes.md docs/how-to/pilot-deployment-checklist.md >/dev/null + @grep -F 'Production gate troubleshooting' docs/reference/production-readiness.md docs/reference/release-validation.md docs/README.md >/dev/null + @grep -F 'Stable v0.1.0 exit criteria' docs/reference/production-readiness.md docs/reference/release-validation.md docs/reference/production-exit-review.md docs/reference/roadmap.md docs/README.md >/dev/null + @grep -F 'Upgrade and compatibility policy' docs/runbooks/upgrade.md docs/README.md >/dev/null + @grep -F 'Release candidates may still make breaking API or schema changes' docs/reference/upgrade-compatibility-policy.md >/dev/null + @grep -F 'make migration-compatibility-check' docs/reference/upgrade-compatibility-policy.md >/dev/null + @grep -F 'not legal compliance proof' docs/reference/stable-v0.1.0-exit-criteria.md >/dev/null + @grep -F 'make public-release-verify TAG=' docs/reference/stable-v0.1.0-exit-criteria.md >/dev/null + @grep -F 'one API writer replica' docs/reference/stable-v0.1.0-exit-criteria.md >/dev/null + @grep -F '!.production.env.example' .gitignore >/dev/null + @grep -F 'ENV=production' .production.env.example >/dev/null + @grep -Fx 'EVYDENCE_DATABASE_URL=' .production.env.example >/dev/null + @grep -Fx 'EVYDENCE_API_KEY_PEPPER=' .production.env.example >/dev/null + @grep -F 'EVYDENCE_OBJECT_STORE=s3' .production.env.example >/dev/null + @grep -F 'EVYDENCE_SIGNING_KEY_MODE=external' .production.env.example >/dev/null + @grep -F 'EVYDENCE_RATE_LIMIT_REQUESTS_PER_MINUTE' .production.env.example >/dev/null + @grep -F 'EVYDENCE_PRINT_BOOTSTRAP_SECRET=false' .production.env.example >/dev/null + @! grep -F 'change-me' .production.env.example >/dev/null + @grep -F 'Telemetry and diagnostics' .production.env.example >/dev/null + @grep -F 'Do not export raw evidence payloads' .production.env.example >/dev/null + @grep -F '.production.env.example' docs/reference/configuration.md docs/how-to/install-and-operate.md docs/reference/hardened-reference-deployment.md >/dev/null + @grep -F 'single API writer replica' docs/reference/hardened-reference-deployment.md >/dev/null + @grep -F 'scalable worker replicas' docs/reference/hardened-reference-deployment.md >/dev/null + @grep -F 'external PostgreSQL' docs/reference/hardened-reference-deployment.md >/dev/null + @grep -F 'external S3/MinIO-compatible object storage' docs/reference/hardened-reference-deployment.md >/dev/null + @grep -F 'request body limits' docs/reference/hardened-reference-deployment.md >/dev/null + @grep -F 'make production-check' docs/reference/hardened-reference-deployment.md >/dev/null + @grep -F 'not legal compliance proof' docs/reference/hardened-reference-deployment.md >/dev/null + @grep -F 'Hardened reference deployment' docs/README.md docs/operator-overview.md docs/kubernetes.md docs/production-hardening.md docs/reference/production-readiness.md docs/reference/source-of-truth.md >/dev/null + @grep -F 'single-writer self-hosted appliance' docs/reference/ha-strategy.md >/dev/null + @grep -F 'Multi-writer API high availability is not supported' docs/reference/ha-strategy.md >/dev/null + @grep -F 'Before Multi-Writer API Is Supported' docs/reference/ha-strategy.md >/dev/null + @grep -F 'EVYDENCE_API_WRITER_REPLICAS=1' docs/reference/ha-strategy.md >/dev/null + @grep -F 'HA strategy' docs/README.md docs/operator-overview.md docs/kubernetes.md docs/reference/production-readiness.md docs/reference/capacity-and-failures.md docs/reference/hardened-reference-deployment.md docs/reference/roadmap.md docs/reference/source-of-truth.md >/dev/null + @grep -F 'Persistence decomposition inventory' docs/README.md docs/reference/production-readiness.md docs/reference/capability-map.md docs/reference/ha-strategy.md docs/reference/source-of-truth.md >/dev/null + @grep -F 'scripts/persistence_decomposition_inventory.py --write' docs/reference/persistence-decomposition.md >/dev/null + @grep -F 'Remaining Broad Relational-State Mutations' docs/reference/persistence-decomposition.md >/dev/null + @grep -F 'make persistence-decomposition-check' docs/reference/persistence-decomposition.md >/dev/null + @grep -F 'Required: object paths are tenant-prefixed' docs/how-to/pilot-deployment-checklist.md >/dev/null + @grep -F 'Required: public API access is behind TLS' docs/how-to/pilot-deployment-checklist.md >/dev/null + @test -f docs/commercial/design-partner-pilot.md + @grep -F 'AGPL-3.0-only' docs/commercial/design-partner-pilot.md >/dev/null + @grep -F 'Non-Deliverables' docs/commercial/design-partner-pilot.md >/dev/null + @test -f docs/commercial/product-landing-copy.md + @grep -F 'One-Sentence Pitch' docs/commercial/product-landing-copy.md >/dev/null + @grep -F 'Why Self-Hosted' docs/commercial/product-landing-copy.md >/dev/null + @grep -F 'Pilot CTA' docs/commercial/product-landing-copy.md >/dev/null + @grep -F 'Paid Readiness Offer' docs/commercial/product-landing-copy.md >/dev/null + @grep -F 'Release evidence readiness review' docs/commercial/product-landing-copy.md >/dev/null + @test -f docs/commercial/category-comparison.md + @grep -F 'Vulnerability scanners' docs/commercial/category-comparison.md >/dev/null + @grep -F 'SBOM inventory tools' docs/commercial/category-comparison.md >/dev/null + @grep -F 'Trust centers' docs/commercial/category-comparison.md >/dev/null + @grep -F 'Dependency-Track' docs/commercial/category-comparison.md README.md docs/README.md >/dev/null + @grep -F 'GUAC' docs/commercial/category-comparison.md README.md site/marketing/src/data/site.ts >/dev/null + @grep -F 'OpenVEX tooling' docs/commercial/category-comparison.md >/dev/null + @grep -F 'Vanta, Drata' docs/commercial/category-comparison.md README.md site/marketing/src/data/site.ts >/dev/null + @grep -F 'Internal scripts, spreadsheets, object storage, and ad hoc databases' docs/commercial/category-comparison.md >/dev/null + @grep -F 'release upload-evidence' docs/how-to/integrate-ci.md >/dev/null + @grep -F 'Tool-specific integration templates' docs/README.md docs/how-to/integrate-ci.md docs/integrations/tool-templates.md >/dev/null + @grep -F 'syft dir:.' docs/integrations/tool-templates.md >/dev/null + @grep -F 'grype dir:.' docs/integrations/tool-templates.md >/dev/null + @grep -F 'trivy fs --format json' docs/integrations/tool-templates.md >/dev/null + @grep -F 'Dependency-Track is adjacent inventory' docs/integrations/tool-templates.md >/dev/null + @grep -F 'Jira links are metadata' docs/integrations/tool-templates.md >/dev/null + @grep -F 'EVYDENCE_OBJECT_STORE=s3' docs/integrations/tool-templates.md >/dev/null + @grep -F 'Default repository checks do not call Syft, Grype, Trivy, Dependency-Track, Jira, GitHub, GitLab, S3, or MinIO' docs/integrations/tool-templates.md >/dev/null + @grep -F 'End-to-end GitHub Actions release evidence guide' docs/how-to/integrate-ci.md docs/README.md docs/github-actions/end-to-end-release-evidence.md >/dev/null + @grep -F 'least-privilege' docs/github-actions/end-to-end-release-evidence.md >/dev/null + @grep -F 'do not make live GitHub API calls' docs/github-actions/end-to-end-release-evidence.md >/dev/null + @grep -F 'EVYDENCE_API_KEY' docs/github-actions/end-to-end-release-evidence.md >/dev/null + @grep -F 'release-readiness.json' docs/github-actions/end-to-end-release-evidence.md docs/github-actions/release-evidence-workflow.yml >/dev/null + @grep -F 'upload-output.txt' docs/github-actions/end-to-end-release-evidence.md docs/github-actions/release-evidence-workflow.yml >/dev/null + @grep -F 'actions/upload-artifact@v4' docs/github-actions/release-evidence-workflow.yml >/dev/null + @grep -F 'make local-ci-simulation-check' docs/how-to/integrate-ci.md examples/end-to-end-release-evidence/README.md >/dev/null + @grep -F -- '--dry-run' docs/how-to/integrate-ci.md >/dev/null + @grep -F 'upload validate-manifest' docs/how-to/integrate-ci.md >/dev/null + @grep -F 'evydence-upload-manifest.v1.0.0' docs/reference/upload-manifest.md schemas/upload-manifest.v1.schema.json >/dev/null + @grep -F 'dist/evydence github-actions upload-build' docs/github-actions/quickstart-release-evidence.yml >/dev/null + @grep -F 'upload validate-manifest' docs/github-actions/quickstart-release-evidence.yml >/dev/null + @grep -F 'scripts/github_release_evidence_manifest.py' docs/github-actions/quickstart-release-evidence.yml >/dev/null + @grep -F '/v1/reports/release-readiness' docs/github-actions/quickstart-release-evidence.yml >/dev/null + @grep -F 'dist/evydence github-actions upload-build' docs/github-actions/release-evidence-workflow.yml >/dev/null + @grep -F 'go run ./cmd/evydence "$${args[@]}"' docs/github-actions/upload-build/action.yml >/dev/null + @grep -F 'cat > evydence-upload-manifest.json' docs/gitlab/evydence-release-evidence.gitlab-ci.yml >/dev/null + @grep -F 'artifact.digest' docs/gitlab/evydence-release-evidence.gitlab-ci.yml >/dev/null + @grep -F 'upload validate-manifest' docs/gitlab/evydence-release-evidence.gitlab-ci.yml >/dev/null + @grep -F -- '--manifest evydence-upload-manifest.json' docs/gitlab/evydence-release-evidence.gitlab-ci.yml >/dev/null + @grep -F 'make production-check' .github/workflows/ci.yml >/dev/null + @grep -F 'tmp/black-box-release-artifact/black-box-release-artifact-summary.json' .github/workflows/ci.yml >/dev/null + @grep -F 'black-box release-style binary evidence' docs/reference/release-validation.md >/dev/null + @grep -F 'make black-box-release-artifact-check' docs/reference/production-readiness.md docs/reference/release-validation.md >/dev/null + @grep -F 'make production-check' .github/workflows/release-artifacts.yml >/dev/null + @grep -F 'EVYDENCE_RELEASE_SIGNING_PRIVATE_KEY_B64' .github/workflows/release-artifacts.yml >/dev/null + @grep -F 'EVYDENCE_RELEASE_PUBLISH_TOKEN' .github/workflows/release-artifacts.yml >/dev/null + @grep -F 'scripts/release_candidate_package.sh' .github/workflows/release-artifacts.yml >/dev/null + @grep -F 'actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c' .github/workflows/release-artifacts.yml >/dev/null + @grep -F 'evydence-release-manifest.sig.json' .github/workflows/release-artifacts.yml >/dev/null + @grep -F 'evydence-release-manifest.sig alias' .github/workflows/release-artifacts.yml >/dev/null + @grep -F 'gh release create' .github/workflows/release-artifacts.yml >/dev/null + @grep -F -- '--repo "$${repo}"' .github/workflows/release-artifacts.yml >/dev/null + @grep -F 'contents: read' .github/workflows/release-artifacts.yml >/dev/null + @! grep -F 'contents: write' .github/workflows/release-artifacts.yml >/dev/null + @grep -F 'Container Image' .github/workflows/container-image.yml >/dev/null + @grep -F 'EVYDENCE_GHCR_PUBLISH_TOKEN' .github/workflows/container-image.yml >/dev/null + @! grep -F 'packages: write' .github/workflows/container-image.yml >/dev/null + @grep -F 'evydence-container-image-manifest.json' .github/workflows/container-image.yml >/dev/null + @grep -F 'cosign-keyless' .github/workflows/container-image.yml >/dev/null + @grep -F 'ghcr.io/aatuh/evydence' README.md >/dev/null + @grep -F 'ghcr.io/aatuh/evydence' docs/reference/release-evidence-index.md >/dev/null + @grep -F 'ghcr.io/aatuh/evydence' docs/kubernetes.md >/dev/null + @grep -F 'ghcr.io/aatuh/evydence' deploy/airgap/manifest.yaml >/dev/null + @grep -F 'sha256:38188044a3e5ded3c6094564ab39ce989185f65e22cf4296985ec19ba0eb1888' docs/reference/release-evidence-index.md deploy/airgap/manifest.yaml >/dev/null + @grep -F 'cosign verify' docs/reference/release-evidence-index.md >/dev/null + @grep -F 'github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa' .github/workflows/codeql.yml >/dev/null + @grep -F 'security-and-quality' .github/workflows/codeql.yml >/dev/null + @grep -F 'Controlled self-hosted production candidate' docs/reference/release-candidate.md >/dev/null + @grep -F 'Release evidence index' docs/reference/release-candidate.md >/dev/null + @grep -F 'evydence-release-manifest.sig.json' docs/reference/release-evidence-index.md >/dev/null + @grep -F 'evydence-release-manifest.sig' docs/reference/release-evidence-index.md >/dev/null + @grep -F 'evydence-release-provenance.intoto.jsonl' docs/reference/release-evidence-index.md >/dev/null + @grep -F 'CODEOWNERS' docs/reference/maintainer-review-policy.md >/dev/null + @grep -F 'OpenSSF Scorecard and Scorecard SARIF remain' docs/reference/maintainer-review-policy.md >/dev/null + @grep -F 'one API writer replica' docs/reference/roadmap.md >/dev/null + @grep -F 'Controlled self-hosted production candidate' docs/reference/release-notes-template.md >/dev/null + @grep -F 'not legal compliance proof' docs/reference/release-notes-template.md >/dev/null + @grep -F 'Controlled self-hosted production candidate' docs/reference/release-notes-v0.1.0-rc.1.md >/dev/null + @grep -F 'not legal compliance proof' docs/reference/release-notes-v0.1.0-rc.1.md >/dev/null + @grep -F 'Use one API writer replica' docs/reference/release-candidate.md >/dev/null + @grep -F 'make release-asset-smoke-check' docs/reference/release-validation.md docs/reference/release-evidence-index.md >/dev/null + @grep -F 'checksum, signature, missing-asset, and package-identity mismatch' docs/reference/release-validation.md >/dev/null + @grep -F 'tampered manifest failure' docs/reference/release-evidence-index.md >/dev/null + @grep -F 'private security intake' SECURITY.md >/dev/null + @! grep -R -i "automatically compliant\|certified secure\|legally sufficient\|SBOM is complete\|all vulnerabilities detected\|scanner findings are authoritative\|regulator-ready without review" README.md docs + @grep -F 'make marketing-site-production-check' docs/how-to/publish-marketing-site.md >/dev/null + @grep -F 'G-XC2ESEHQ3W' docs/how-to/publish-marketing-site.md >/dev/null + @grep -F 'evydence.app' docs/how-to/publish-marketing-site.md >/dev/null deploy-check: ## Validate deployment and air-gap skeletons exist + @test -f compose.production-like.yml @test -f deploy/helm/evydence/Chart.yaml @test -f deploy/helm/evydence/values.yaml @test -f deploy/helm/evydence/templates/deployment-api.yaml @test -f deploy/helm/evydence/templates/deployment-worker.yaml + @test -f deploy/helm/evydence/templates/networkpolicy.yaml @test -f deploy/airgap/manifest.yaml + @test -f deploy/observability/prometheus-rules.yaml + @test -f deploy/observability/grafana-dashboard.json + @grep -F 'postgres:16-alpine@sha256:16bc17c64a573ef34162af9298258d1aec548232985b33ed7b1eac33ba35c229' compose.production-like.yml >/dev/null + @grep -F 'minio/minio@sha256:14cea493d9a34af32f524e538b8346cf79f3321eff8e708c1e2960462bd8936e' compose.production-like.yml >/dev/null + @grep -F 'minio/mc@sha256:a7fe349ef4bd8521fb8497f55c6042871b2ae640607cf99d9bede5e9bdf11727' compose.production-like.yml >/dev/null + @! grep -E 'image:[[:space:]]+[^$$]*:latest' compose.production-like.yml >/dev/null + @grep -F 'tag: ""' deploy/helm/evydence/values.yaml >/dev/null + @grep -F 'replicas: 1' deploy/helm/evydence/values.yaml >/dev/null + @grep -F 'writerMode: single' deploy/helm/evydence/values.yaml >/dev/null + @grep -F 'EVYDENCE_API_WRITER_MODE' deploy/helm/evydence/templates/configmap.yaml >/dev/null + @grep -F 'EVYDENCE_API_WRITER_REPLICAS' deploy/helm/evydence/templates/configmap.yaml >/dev/null + @grep -F 'runAsNonRoot: true' deploy/helm/evydence/values.yaml >/dev/null + @grep -F 'allowPrivilegeEscalation: false' deploy/helm/evydence/values.yaml >/dev/null + @grep -F 'required "image.tag is required' deploy/helm/evydence/templates/deployment-api.yaml >/dev/null + @grep -F 'required "image.tag is required' deploy/helm/evydence/templates/deployment-worker.yaml >/dev/null + @grep -F 'evydence-worker' deploy/helm/evydence/templates/deployment-worker.yaml >/dev/null + @grep -F 'healthcheck' deploy/helm/evydence/values.yaml >/dev/null + @grep -F 'single API writer replica' docs/kubernetes.md >/dev/null + @grep -F 'Hardened reference deployment' docs/kubernetes.md docs/reference/hardened-reference-deployment.md >/dev/null + @grep -F 'HA strategy' docs/kubernetes.md docs/reference/ha-strategy.md >/dev/null + @grep -F 'EVYDENCE_API_WRITER_MODE: single' compose.production-like.yml >/dev/null + @grep -F 'EVYDENCE_PRINT_BOOTSTRAP_SECRET: "false"' compose.production-like.yml >/dev/null + @grep -F 'evydence-migrate ./cmd/evydence-migrate' Dockerfile >/dev/null + @grep -F 'entrypoint: ["evydence-migrate"]' compose.production-like.yml >/dev/null + @grep -F 'entrypoint: ["evydence-worker"]' compose.production-like.yml >/dev/null -sdk-check: ## Validate curated SDK example files exist +sdk-check: ## Validate SDK helper and generated route-catalog coverage against OpenAPI @test -f sdk/go/evydence/client.go @test -f sdk/typescript/client.ts @test -f sdk/python/evydence_client.py + @test -f sdk/openapi-route-catalog.json + @python3 scripts/sdk_check.py + +demo-check: ## Validate checked end-to-end evidence demo fixtures + @test -x examples/end-to-end-release-evidence/run-local-demo.sh + @test -x examples/customer-cve-review-demo/run-demo.sh + @test -x scripts/local_ci_simulation_check.sh + @test -x scripts/reviewer_package_workflow_check.sh + @test -x scripts/black_box_release_artifact_check.sh + @test -f examples/end-to-end-release-evidence/README.md + @test -f examples/customer-cve-review-demo/README.md + @test -f examples/customer-cve-review-demo/customer-cve-review-story.json + @test -f examples/customer-cve-review-demo/expected-verification-output.txt + @test -f examples/end-to-end-release-evidence/release-evidence-manifest.json + @test -f examples/end-to-end-release-evidence/sample-readiness-report.json + @test -f examples/end-to-end-release-evidence/sample-security-summary.json + @test -f examples/end-to-end-release-evidence/sample-customer-package-manifest.json + @test -f examples/end-to-end-release-evidence/sample-customer-package-manifest.sha256 + @test -f examples/end-to-end-release-evidence/sample-customer-package.zip + @test -f examples/end-to-end-release-evidence/sample-audit-chain-verification.json + @python3 -c 'import json, pathlib; [json.loads(path.read_text()) for path in pathlib.Path("examples/end-to-end-release-evidence").glob("*.json")]' + @cd examples/end-to-end-release-evidence && sha256sum -c sample-customer-package-manifest.sha256 >/dev/null + @$(GO) run ./cmd/evydence package verify --archive examples/end-to-end-release-evidence/sample-customer-package.zip --expected-package-id csp_example --expected-product-id prod_example --expected-release-id rel_example >/dev/null + @grep -F '/v1/reports/release-readiness' examples/end-to-end-release-evidence/run-local-demo.sh >/dev/null + @grep -F '/security-summary' examples/end-to-end-release-evidence/run-local-demo.sh >/dev/null + @grep -F '/v1/audit-chain/verify' examples/end-to-end-release-evidence/run-local-demo.sh >/dev/null + @grep -F '/v1/customer-packages' examples/end-to-end-release-evidence/run-local-demo.sh >/dev/null + @grep -F 'not legal' examples/end-to-end-release-evidence/README.md >/dev/null + @grep -F 'CVE-2026-0002' examples/customer-cve-review-demo/customer-cve-review-story.json examples/customer-cve-review-demo/README.md docs/tutorials/customer-cve-review-demo.md >/dev/null + @grep -F 'approved_for_customer_package' examples/customer-cve-review-demo/customer-cve-review-story.json examples/customer-cve-review-demo/README.md docs/tutorials/customer-cve-review-demo.md >/dev/null + @grep -F 'not legal compliance proof' examples/customer-cve-review-demo/README.md docs/tutorials/customer-cve-review-demo.md >/dev/null + @grep -F 'reviewer_checklist' examples/end-to-end-release-evidence/sample-customer-package-manifest.json >/dev/null + @grep -F 'escalation_path' examples/end-to-end-release-evidence/sample-customer-package-manifest.json >/dev/null + @grep -F 'sbom_component_purl' examples/end-to-end-release-evidence/sample-customer-package-manifest.json >/dev/null + @scripts/reviewer_package_workflow_check.sh + @$(MAKE) customer-cve-review-demo-check + +customer-cve-review-demo-check: ## Validate deterministic customer CVE review demo + @examples/customer-cve-review-demo/run-demo.sh >/dev/null + +local-ci-simulation-check: ## Run local one-command CI evidence simulation without external services + @scripts/local_ci_simulation_check.sh + +reviewer-package-workflow-check: ## Validate offline reviewer package verification, extraction, and report inspection + @scripts/reviewer_package_workflow_check.sh + +black-box-demo-check: ## Run live PostgreSQL black-box API/worker demo; requires EVYDENCE_TEST_DATABASE_URL + @scripts/black_box_demo_check.sh + +black-box-release-artifact-check: ## Run live PostgreSQL black-box demo against release-style local binaries + @scripts/black_box_release_artifact_check.sh + +benchmark-check: ## Run the checked app-layer release evidence benchmark + @$(GO) test ./internal/app -bench BenchmarkReleaseEvidenceIngestion -benchtime=100x -run '^$$' -benchmem + @if [ -n "$$EVYDENCE_TEST_DATABASE_URL" ]; then scripts/production_benchmark_check.sh; else echo "EVYDENCE_TEST_DATABASE_URL not set; skipping production black-box benchmark"; fi + @grep -F 'BenchmarkReleaseEvidenceIngestion' docs/reference/benchmark-results.md >/dev/null + @grep -F 'production_benchmark_check.sh' docs/reference/benchmark-results.md >/dev/null + @grep -F 'evydence-production-benchmark.v1.0.0' docs/reference/benchmark-results.md >/dev/null + @grep -F 'API writer replicas: `1`' docs/reference/capacity-and-failures.md >/dev/null + +package-viewer-check: ## Validate local package viewer and walkthrough + @test -f site/package-viewer/index.html + @test -f docs/how-to/view-packages.md + @test -x scripts/capture_package_viewer_screenshots.sh + @test -f docs/assets/package-viewer-desktop.png + @test -f docs/assets/package-viewer-mobile.png + @test -f docs/assets/reviewer-journey.svg + @grep -F 'Load bundled demo' site/package-viewer/index.html >/dev/null + @grep -F 'textContent' site/package-viewer/index.html >/dev/null + @! grep -F 'innerHTML' site/package-viewer/index.html >/dev/null + @grep -F 'Release Summary' site/package-viewer/index.html >/dev/null + @grep -F 'Reviewer Dossier' site/package-viewer/index.html docs/how-to/view-packages.md >/dev/null + @grep -F 'read-only package scope' site/package-viewer/index.html docs/how-to/view-packages.md >/dev/null + @grep -F 'Package verification result' site/package-viewer/index.html >/dev/null + @grep -F 'Offline verification instructions' site/package-viewer/index.html >/dev/null + @grep -F 'evydence package verify' site/package-viewer/index.html docs/how-to/view-packages.md >/dev/null + @grep -F 'Manifest Summary And Proof' site/package-viewer/index.html docs/how-to/view-packages.md >/dev/null + @grep -F 'Evidence Link Status' site/package-viewer/index.html docs/how-to/view-packages.md >/dev/null + @grep -F 'Package Verification Result' site/package-viewer/index.html >/dev/null + @grep -F 'Signed release bundle material' site/package-viewer/index.html docs/how-to/view-packages.md >/dev/null + @grep -F 'limitation/non-claim copy' docs/how-to/view-packages.md >/dev/null + @grep -F 'Vulnerability / VEX Decisions' site/package-viewer/index.html >/dev/null + @grep -F 'Verification Status' site/package-viewer/index.html >/dev/null + @grep -F 'Reviewer checklist' site/package-viewer/index.html >/dev/null + @grep -F 'reviewer_checklist' site/package-viewer/index.html >/dev/null + @grep -F 'review_due_at' site/package-viewer/index.html >/dev/null + @grep -F 'sbom_component_purl' site/package-viewer/index.html >/dev/null + @grep -F 'examples/end-to-end-release-evidence/sample-customer-package-manifest.json' docs/how-to/view-packages.md >/dev/null + @grep -F 'package-viewer-desktop.png' docs/how-to/view-packages.md >/dev/null + @grep -F 'package-viewer-mobile.png' docs/how-to/view-packages.md >/dev/null + @grep -F 'not package verification evidence' docs/how-to/view-packages.md >/dev/null + @grep -F 'reviewer-journey.svg' docs/how-to/view-packages.md >/dev/null + @grep -F 'hash/signature verification' docs/how-to/view-packages.md >/dev/null + @grep -F 'Release Summary' docs/assets/reviewer-journey.svg >/dev/null + @grep -F 'VEX Decisions' docs/assets/reviewer-journey.svg >/dev/null + @grep -F 'Evidence Contents' docs/assets/reviewer-journey.svg >/dev/null + @grep -F 'Verification Status' docs/assets/reviewer-journey.svg >/dev/null + @grep -F 'Gaps' docs/assets/reviewer-journey.svg >/dev/null + @grep -F 'Limitations' docs/assets/reviewer-journey.svg >/dev/null + +release-asset-smoke-check: ## Verify local release asset checksums, signature, package verification, and failure cases + @scripts/release_asset_smoke_check.sh + +marketing-site-check: ## Build and validate the static marketing site + @npm --prefix site/marketing run check + +marketing-site-production-check: ## Build and validate the marketing site for evydence.app + @PUBLIC_SITE_URL=https://evydence.app PUBLIC_SITE_BASE=/ PUBLIC_GA_MEASUREMENT_ID=G-XC2ESEHQ3W npm --prefix site/marketing run check + +restore-rehearsal-check: ## Run repository-owned backup/restore rehearsal tests + @$(GO) test ./internal/app -run TestBackupRestoreRehearsalPreservesLedgerAndObjectPayloads -count=1 + @$(GO) test ./internal/adapters/postgres -run TestPostgresBackupRestoreRehearsalPreservesLedgerAndObjects -count=1 fast-check: ## Run non-mutating fast validation @$(MAKE) test + @$(MAKE) fuzz-smoke @$(MAKE) openapi-check + @$(MAKE) openapi-precision-check @$(MAKE) docs-check @$(MAKE) deploy-check @$(MAKE) sdk-check + @$(MAKE) demo-check + @$(MAKE) package-viewer-check finalize: ## Thorough validity check @$(MAKE) fmt @$(MAKE) test @$(MAKE) openapi-check + @$(MAKE) openapi-precision-check @$(MAKE) docs-check @$(MAKE) deploy-check @$(MAKE) sdk-check + @$(MAKE) demo-check + @$(MAKE) package-viewer-check + +release-acceptance: ## Run deterministic release metadata acceptance checks + @scripts/release_acceptance.sh + @$(MAKE) release-asset-smoke-check + +release-check: ## Release validation with security, race, and configured live integration gates + @$(MAKE) finalize + @$(MAKE) release-acceptance + @$(MAKE) lint + @$(MAKE) gosec + @$(MAKE) vuln + @$(MAKE) test-race + @$(MAKE) live-postgres-check + @$(MAKE) postgres-integration-test + @mkdir -p tmp + @{ \ + echo "evydence release-check summary"; \ + echo "generated_at=$$(date -u +%Y-%m-%dT%H:%M:%SZ)"; \ + echo "finalize=passed"; \ + echo "lint=passed"; \ + echo "gosec=passed"; \ + echo "govulncheck=passed"; \ + echo "race=passed"; \ + if [ -n "$$EVYDENCE_TEST_DATABASE_URL" ]; then \ + echo "live_postgres=passed"; \ + echo "postgres_integration=passed"; \ + else \ + echo "live_postgres=skipped EVYDENCE_TEST_DATABASE_URL unset"; \ + echo "postgres_integration=skipped EVYDENCE_TEST_DATABASE_URL unset"; \ + fi; \ + } | tee tmp/release-check-summary.txt + +production-check: ## Strict self-hosted production readiness gate; requires live PostgreSQL and coverage threshold + @scripts/production_check.sh + +release-candidate-check: ## Build and validate a signed release-candidate package with explicit TAG=vX.Y.Z-rc.N + @scripts/release_candidate_package.sh "$(TAG)" + +public-release-verify: ## Download and verify public release assets with TAG=vX.Y.Z-rc.N + @scripts/public_release_verify.sh "$(TAG)" + +migration-compatibility-check: ## Verify every committed migration prefix upgrades to current schema + @$(GO) test ./internal/adapters/postgres -run TestMigrationCompatibilityFromEveryCommittedState -count=1 + +release-check-local-postgres: ## Start Compose Postgres, load .test.env or .test.env.example, and run release-check + @docker compose up -d postgres + @echo "waiting for compose postgres..." + @for i in $$(seq 1 30); do \ + if docker compose exec -T postgres pg_isready -U "$${POSTGRES_USER:-evydence}" >/dev/null 2>&1; then break; fi; \ + if [ "$$i" = "30" ]; then echo "postgres did not become ready"; exit 1; fi; \ + sleep 1; \ + done + @env_file=".test.env"; \ + if [ ! -f "$$env_file" ]; then env_file=".test.env.example"; fi; \ + echo "loading $$env_file for release-check-local-postgres"; \ + set -a; . "./$$env_file"; set +a; \ + $(MAKE) release-check compose-up: ## Start local dependencies @docker compose up -d diff --git a/README.md b/README.md index 5c29e44..00bb143 100644 --- a/README.md +++ b/README.md @@ -1,27 +1,215 @@ # Evydence -Evydence is a self-hosted, API-first evidence ledger for software release evidence. It supports compliance readiness by organizing and verifying technical evidence, producing tamper-evident records, and showing gaps, assumptions, exceptions, and limitations. +[![CI](https://github.com/aatuh/evydence/actions/workflows/ci.yml/badge.svg)](https://github.com/aatuh/evydence/actions/workflows/ci.yml) +[![OpenSSF Scorecard](https://github.com/aatuh/evydence/actions/workflows/scorecard.yml/badge.svg)](https://github.com/aatuh/evydence/actions/workflows/scorecard.yml) +[![License: AGPL-3.0-only](https://img.shields.io/badge/license-AGPL--3.0--only-blue.svg)](LICENSE) +![Go Version](https://img.shields.io/badge/go-1.25+-00ADD8.svg) +![OpenAPI](https://img.shields.io/badge/OpenAPI-186%20precise%20operations-brightgreen.svg) +![Coverage Gate](https://img.shields.io/badge/production%20coverage-80%25+-brightgreen.svg) -It does not make legal compliance conclusions, certify releases as secure, prove SBOM completeness, or treat scanner findings as authoritative. +Evydence is a self-hosted, API-first release evidence ledger for product +security, AppSec, platform, release engineering, and compliance-readiness teams +that need to answer customer CVE, SBOM, provenance, and release-review +questions with signed, customer-safe release evidence bundles. + +Website: + +The core buyer question is: "This CVE appears in your SBOM for this release. +Are you affected, why or why not, who approved that decision, and what evidence +can we verify?" Evydence records the SBOM, vulnerability scan, VEX or manual +decision, build provenance, artifact digest, exceptions, release bundle, and +customer package that support the answer. + +| Start here | Use this when you want to | +| --- | --- | +| [Customer CVE review demo](examples/customer-cve-review-demo/README.md) | Inspect the no-external-services buyer proof path. | +| [Buyer evaluation overview](docs/buyer-overview.md) | Follow the buyer package, demo, release-evidence, and API review path. | +| [Operator overview](docs/operator-overview.md) | Follow the self-hosted install, runbook, and production-gate path. | +| [OpenAPI reference](docs/reference/openapi.md) | Review the `/v1` API contract and generated schemas. | +| [Rendered OpenAPI docs](docs/openapi/index.html) | Browse the generated static operation and schema reference. | +| [Install and operate](docs/how-to/install-and-operate.md) | Run the self-hosted API/worker path with PostgreSQL and object storage. | +| [Release evidence index](docs/reference/release-evidence-index.md) | Verify public release artifacts, checksums, production-check evidence, and limitations. | + +Before Evydence, teams often stitch together scanner exports, CI logs, Slack +approvals, spreadsheets, object-storage folders, and one-off customer answers. +After Evydence, the same release has tenant-scoped API records, append-only +decisions, tamper-evident audit entries, signed bundles, reproducible reports, +and scoped packages that state gaps, assumptions, exceptions, and limitations. + +It does not make legal compliance conclusions, grant certification, prove SBOM +completeness, treat scanner findings as authoritative, or guarantee release +security. + +## Current Status + +- Best for: evaluation, pilots, and controlled internal self-hosted use after operator review. +- Not for: broad HA production, regulated production without review, hosted SaaS, or legal/compliance conclusions. +- Current public release candidate metadata is tracked in + [`release/current.json`](release/current.json); GitHub Releases remains the + external source of truth for published tags and assets. +- Current release evidence links the Release Artifacts workflow, public CI + `Production Check` workflow run, CodeQL run, `coverage.out`, and + `release-check-summary.txt` from the + [Release evidence index](docs/reference/release-evidence-index.md). +- See [Production readiness](docs/reference/production-readiness.md), + [Pilot deployment checklist](docs/how-to/pilot-deployment-checklist.md), and + [Release evidence index](docs/reference/release-evidence-index.md) before + running beyond local evaluation. + +## What Question Does Evydence Answer? + +Evydence is designed to make release-risk answers traceable to concrete +objects instead of unsupported prose: + +| Customer or internal review question | Evydence object that carries the answer | +| --- | --- | +| Are we affected by `CVE-X` in release `Y`? | Vulnerability finding plus VEX/manual decision, exception, or remediation record linked to the release. | +| Which SBOM was used for this answer? | SBOM record linked to the release and artifact, with raw payload hash and object reference. | +| Which scanner result raised the finding? | Vulnerability scan and normalized finding record with scanner metadata. | +| Who approved or recorded the decision? | Append-only vulnerability decision, exception, approval, and audit-chain entries with actor context. | +| What evidence supports "not affected" or "fixed"? | VEX impact/action statements, linked evidence, artifact digest, build provenance, and verification receipts. | +| What can be safely shared with a customer? | Redaction profile, customer package, evidence bundle, and package manifest with limitations. | +| How can the customer verify the package? | Signed release bundle, package manifest hash, OpenAPI-backed API records, audit-chain verification, and offline verifier paths. | + +## Current Limitations + +- Current status is a controlled self-hosted production candidate for + evaluation, pilots, and controlled internal production after operator review. +- Production API deployments use one API writer replica; workers may scale + through PostgreSQL outbox locking. +- Container publication uses the maintainer-run GHCR workflow and must be + verified by immutable digest and cosign evidence; Helm installs should not use + floating image tags. +- Native PKCS#11/HSM module execution, broad provider-side WORM enforcement + proof beyond recorded object-lock verification metadata, direct + provider-specific management API clients, external group synchronization, + regulated production, and hosted SaaS production remain outside the current + supported profile unless a deployment review closes those gaps. ## Current Implementation -This repository now contains the release-ledger MVP scaffold: - -- Go module `github.com/aatuh/evydence`. -- HTTP API under `/v1` using `github.com/aatuh/api-toolkit/v3` route contracts, OpenAPI generation, response helpers, and Problem Details. -- Multi-tenant scoped API keys with one-time secret output, HMAC-SHA256 storage, and server-side scope checks. -- Products, projects, releases, release candidates, artifacts, container images, artifact signatures, cosign-style verification metadata, evidence search, evidence lifecycle events, CycloneDX and SPDX SBOM upload, SBOM diffs, OpenVEX and CycloneDX VEX upload, generic vulnerability scan upload, vulnerability decisions/workflow records/posture reports, security scans, manual security documents, incidents/remediation/timeline reports, organizations, human users, SSO provider/session records, role bindings, instance admin diagnostics, legal holds, retention overrides, customer portal package access, questionnaire templates/packages, commercial collector definitions, waivers, approvals, customer security packages, redaction profiles, HTML report packages, custom report templates, evidence bundle export/import, exceptions, OpenAPI upload and contract diffs, API security scan evidence, custom policy definitions/evaluations, collectors, build runs, DSSE/in-toto build attestation upload and trust-root signature verification, source repositories/commits/branches/pull requests, deployment environments/events, expanded control framework template packs, control frameworks, control evidence links, policy evaluation, missing-evidence, release-readiness, control-coverage and CRA-readiness reports, signing keys, key revocation, signing-provider records, Merkle checkpoint batches, transparency checkpoint records, object-retention policy records, backup manifests, safe metrics, readiness, admin audit-log querying, signed release bundles, and verification endpoints. -- In-process store for local demos and unit-test execution when `EVYDENCE_DATABASE_URL` is unset. -- PostgreSQL-backed durable ledger state, tenant-scoped relational resource projection rows, and persisted outbox jobs when `EVYDENCE_DATABASE_URL` is set. -- Filesystem or S3/MinIO-compatible object storage for raw upload payload bytes, keyed under tenant-prefixed paths. -- Schema migrations applied by `make migrate` or by the API/worker startup path unless `EVYDENCE_SKIP_MIGRATIONS=true`. -- Docker Compose dependencies for PostgreSQL and MinIO. -- A polling `cmd/evydence-worker` process that claims persisted outbox jobs with PostgreSQL row locking and records retry or terminal status. -- A local `cmd/evydence` helper for hashing, manifest verification, and GitHub Actions build provenance upload. -- Offline `cmd/evydence` verification for exported evidence bundle manifests, release artifact manifest signing/verification, bulk upload manifests, and air-gapped evidence bundle import. -- Kubernetes Helm chart, air-gapped package manifest, SDK examples, GitHub Actions release workflow example, and GitLab CI template. -- Documentation portal structure under `docs/` with tutorial, how-to, reference, and explanation entry points. +This repository contains a Go implementation under module +`github.com/aatuh/evydence`. The current public release candidate is +[`v0.1.0-rc.7`](https://github.com/aatuh/evydence/releases/tag/v0.1.0-rc.7), +published as a prerelease with signed archives, checksums, OpenAPI and +migration checksums, coverage output, production-check summary, SBOM/provenance +metadata, release notes, and a signed release manifest. + +Use GitHub Releases and the checked release evidence artifacts as the operator +install source for the release-candidate line. Source checkout remains the +development path. + +Container images for the release-candidate line are published, when the +maintainer image workflow has run for the tag, as +`ghcr.io/aatuh/evydence:`. Treat the digest and cosign evidence as the +operator trust input, not the mutable tag alone. The current public release +candidate metadata distinguishes release archive evidence from the last +verified project-owned image evidence because image publication is a separate +workflow. + +## Fastest Proof Path + +For a reviewer-first path, follow +[Evaluate Evydence in 10 minutes](docs/tutorials/evaluate-in-10-minutes.md). +For a first local API flow, follow [Getting started](docs/tutorials/getting-started.md). +For durable local evaluation, run the production-like Compose rehearsal in +[Install and operate](docs/how-to/install-and-operate.md). +For the release-evidence path to inspect first, use the +[end-to-end release evidence example](examples/end-to-end-release-evidence/README.md). +For a no-external-services buyer proof, run the +[customer CVE review demo](examples/customer-cve-review-demo/README.md). +For the first CI wiring example, start with the +[GitHub Actions quickstart release evidence workflow](docs/github-actions/quickstart-release-evidence.yml), +then move to the scanner-oriented workflow once your runner has pinned scanner +versions. +For concrete JSON outputs to inspect without running a full stack, open the +sample readiness report, +[customer-package manifest](examples/end-to-end-release-evidence/sample-customer-package-manifest.json), +[downloadable customer package](examples/end-to-end-release-evidence/sample-customer-package.zip), +and audit-chain verification fixtures in that example or load the bundled +package in the [local package viewer](docs/how-to/view-packages.md). + +Release-candidate artifacts and their verification commands are indexed in +[Release evidence index](docs/reference/release-evidence-index.md). Start with +the public `v0.1.0-rc.7` release if you want to evaluate release verification +before running the API. + +To verify the public release assets from a clean temporary directory on Linux +amd64, run: + +```sh +make public-release-verify TAG=v0.1.0-rc.7 +``` + +The VEX-first evidence flow to evaluate first is: + +1. Create a product, release, and artifact. +2. Upload SBOM evidence for the artifact. +3. Upload vulnerability scan evidence for the release. +4. Record a VEX decision or approved exception for an intentionally blocking finding. +5. Generate a release-readiness report. +6. Create a signed release bundle and customer-safe package or evidence bundle. +7. View the package locally and verify the bundle, package manifest, and audit chain. + +For a visual preview of the customer-package review surface, see the +[package viewer guide](docs/how-to/view-packages.md). The preview uses +non-sensitive bundled sample data, includes desktop and mobile screenshots, and +does not upload files. + +## Why Evydence Instead Of Existing Tools? + +- Dependency-Track and vulnerability-management tools are strong for SBOM and + finding workflows; Evydence focuses on release-level evidence, decisions, + bundles, audit chains, controls, customer packages, and review limitations. +- GUAC-style supply-chain graph tools are strong for relationship exploration; + Evydence focuses on release manifests, verification receipts, and + reviewer-facing package evidence. +- OpenVEX-focused tooling is strong for authoring or consuming VEX statements; + Evydence stores VEX as evidence and links normalized decisions to releases, + scans, SBOM context, exceptions, and customer-safe packages. +- Vanta, Drata, and similar SaaS GRC tools are broad compliance platforms; + Evydence is a self-hosted technical evidence ledger and does not claim legal + compliance or certification. +- Building this in-house with object storage, scripts, spreadsheets, and ad hoc + Postgres tables is possible; Evydence provides a versioned API, OpenAPI + contract, tenant scoping, idempotency, audit chains, release evidence, and + checked non-claim language from the start. + +### Core Product Path + +- `/v1` API, committed OpenAPI contract, idempotent create/action requests, and + tenant-scoped Problem Details responses. +- Products, releases, artifacts, SBOMs, vulnerability scans, VEX/manual + decisions, exceptions, approvals, release bundles, audit-chain verification, + and customer-safe packages. +- Release-readiness, vulnerability-decision, control-coverage, CRA-readiness, + security-summary, package, retention, and backup reports with assumptions and + limitations. +- PostgreSQL, object storage, outbox worker, CLI upload/verification helpers, + GitHub Actions/GitLab examples, SDK wrappers, Compose, Helm, and air-gapped + packaging paths. + +For the full advanced capability inventory, including identity, controls, +source/deployment/incident evidence, retention, backup, signing-provider +profiles, provider verification, transparency records, and +implemented-but-partial areas, see [Capability map](docs/reference/capability-map.md). + +## License, Security, Support, And Governance + +Evydence is licensed under `AGPL-3.0-only`; see [LICENSE](LICENSE). +Commercial license exceptions and paid support are described in +[COMMERCIAL.md](COMMERCIAL.md). Project governance, contribution expectations, +security reporting, support paths, trademark guidance, release-evidence +expectations, and release notes are documented in [GOVERNANCE.md](GOVERNANCE.md), +[CONTRIBUTING.md](CONTRIBUTING.md), [SECURITY.md](SECURITY.md), +[SUPPORT.md](SUPPORT.md), [TRADEMARKS.md](TRADEMARKS.md), +[RELEASE_EVIDENCE.md](RELEASE_EVIDENCE.md), and [CHANGELOG.md](CHANGELOG.md). + +These files preserve the same product boundary as the rest of the repository: +Evydence supports compliance readiness and technical evidence organization, but +does not make legal compliance conclusions, grant certification, prove SBOM +completeness, treat scanner output as authoritative, or guarantee release +security. ## Local API @@ -35,13 +223,24 @@ The API listens on `EVYDENCE_ADDR`, defaulting to `:8080`. Local bootstrap outpu Use the secret as: -```sh +```http Authorization: Bearer Idempotency-Key: ``` +For a runnable first evidence flow, use [Getting started](docs/tutorials/getting-started.md). + ## Validation +The canonical release validation reference is [docs/reference/release-validation.md](docs/reference/release-validation.md). +The self-hosted production-readiness profile is [docs/reference/production-readiness.md](docs/reference/production-readiness.md). +The release-candidate checklist is [docs/reference/release-candidate.md](docs/reference/release-candidate.md). +The release evidence artifact map is [docs/reference/release-evidence-index.md](docs/reference/release-evidence-index.md). +The maintainer review policy for high-risk paths is [docs/reference/maintainer-review-policy.md](docs/reference/maintainer-review-policy.md). +The public roadmap and release cadence are [docs/reference/roadmap.md](docs/reference/roadmap.md). + +Common local checks: + ```sh make test make openapi-check @@ -57,4 +256,6 @@ make live-postgres-check make postgres-integration-test ``` -`make finalize` runs the project-owned formatting, unit, OpenAPI, and docs gates. +`make finalize` runs the project-owned formatting, unit, OpenAPI, docs, deployment, and SDK gates. `make release-check` extends that with lint, gosec, govulncheck, race tests, and live PostgreSQL gates when `EVYDENCE_TEST_DATABASE_URL` is configured. `make coverage` is the no-database local coverage view; `make coverage-check` is the production coverage gate and requires `EVYDENCE_TEST_DATABASE_URL` so PostgreSQL-backed coverage is included. + +`make production-check` is stricter: it requires `EVYDENCE_TEST_DATABASE_URL`, enforces the configured coverage threshold, and runs a release artifact signing smoke test. Passing the gate is required release-candidate evidence, but it does not by itself close the remaining service decomposition, PKCS#11/native HSM custody, direct provider-specific management API/group synchronization, broader object-lock enforcement beyond configured bucket/sample-object checks, HA, and exit-review work. Production API and worker processes default to relational-only PostgreSQL loads and skip compatibility snapshot writes; the compatibility snapshot remains for migration, recovery, and local workflows. Critical runtime mutations for tenants, credential hashes, idempotency, audit-chain entries, release bundles, signatures, verification results, vulnerability decisions, and outbox jobs use focused PostgreSQL write paths when available. Release-ledger and evidence-core mutations for products, projects, releases, artifacts, evidence items, evidence lifecycle events, SBOMs, vulnerability scans, OpenAPI contracts, VEX documents, audit-chain entries, and parser outbox jobs also use focused PostgreSQL write paths when available. Remaining aggregate persistence calls use PostgreSQL relational synchronization without writing the compatibility snapshot when that store is configured. Current self-hosted production guidance still uses a single API writer replica; production API startup rejects unsupported writer modes and declared replica counts above one, then enforces that stance with a PostgreSQL advisory writer lease. Worker replicas may scale through PostgreSQL outbox row locking. diff --git a/RELEASE_EVIDENCE.md b/RELEASE_EVIDENCE.md new file mode 100644 index 0000000..1ced505 --- /dev/null +++ b/RELEASE_EVIDENCE.md @@ -0,0 +1,58 @@ +# Release Evidence + +This file is the release-evidence router. The canonical release validation +reference is: + +- [`docs/reference/release-validation.md`](docs/reference/release-validation.md) + +Local acceptance gates start with: + +```sh +make release-acceptance +make release-check +make production-check +``` + +`make release-acceptance` is a deterministic local meta gate. It checks this +repository’s legal, governance, support, security, trademark, release-evidence, +Docker-build-context, docs, OpenAPI, deployment, SDK, and unit-test readiness +without requiring live PostgreSQL, Docker, KMS, S3, GitHub, GitLab, or other +external providers. + +`make release-check` is the stronger project-owned release validation gate. It +runs `make finalize`, lint, gosec, govulncheck, race tests, and live PostgreSQL +checks when `EVYDENCE_TEST_DATABASE_URL` is configured. + +`make production-check` is the strict self-hosted production-readiness gate. It +requires live PostgreSQL through `EVYDENCE_TEST_DATABASE_URL`, enforces the +coverage threshold, and runs a local release artifact signing smoke test. A +failure means the build has not yet met the self-hosted production profile. + +`make release-candidate-check TAG=` is the controlled release +candidate packaging gate. It requires a clean worktree, live PostgreSQL, and +release signing material, then writes release archives, checksums, OpenAPI and +migration checksums, coverage output, release-check summary, checked release +notes, a signed release manifest, and a manifest signature under `dist//`. + +GitHub release and GHCR publication use explicit repository secrets instead of +broad `GITHUB_TOKEN` write permissions: `EVYDENCE_RELEASE_PUBLISH_TOKEN` for the +separate draft-release publication job and `EVYDENCE_GHCR_PUBLISH_TOKEN` for +container image publication. The container signing job is the only +container-image job with `id-token: write` for keyless cosign signing. Those +secrets are external repository settings and are not release evidence artifacts. + +Release-candidate tagging is documented in +[`docs/reference/release-candidate.md`](docs/reference/release-candidate.md). +The required evidence set is a passing production-check summary, coverage +summary, OpenAPI checksum, migration checksum, signed artifact manifest, +checksums for published artifacts, and release notes with assumptions, +limitations, and unresolved hardening work. + +Commercial release evidence packages may include signed release manifests, +image digests, SBOMs, vulnerability scan outputs, OpenAPI checksums, migration +checks, acceptance evidence, support notes, deployment hardening notes, and +upgrade guidance. + +Release evidence is not a certification. It does not claim legal compliance, +secure releases, complete SBOMs, authoritative scanner results, regulator +acceptance, auditor acceptance, or provider-side completeness. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..c5bc35d --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,74 @@ +# Security Policy + +Evydence is high-trust compliance-readiness and release-evidence +infrastructure. Security reports are welcome, especially around tenant isolation, +authorization, API keys, SSO sessions, collector identity, evidence +immutability, canonical hashes, signatures, audit chains, release bundles, +object storage, reports, exports, and release evidence. + +## Reporting A Vulnerability + +If you believe you found a vulnerability, use GitHub private vulnerability +reporting for this repository when the "Report a vulnerability" button is +available on GitHub. The expected intake URL is +. If the button or +URL is unavailable for your account or region, use the private security intake +channel listed for the current release notes or request a private channel from +the maintainer without including vulnerability details in the first contact. + +The repository file cannot itself prove that GitHub private vulnerability +reporting or a dedicated mailbox is enabled; that is an operator setting that +must be verified on the public repository before relying on it. + +Do not include API keys, collector secrets, bearer tokens, session tokens, +portal tokens, private keys, provider credentials, database URLs, raw evidence +payloads, customer data, exploit payloads against third-party systems, or other +sensitive material in public issues, pull requests, screenshots, logs, or first +contact messages. + +## What To Include + +Once a private channel is established, include: + +- affected commit, tag, image digest, or deployment profile, +- concise impact statement, +- reproduction steps or proof of concept, +- affected endpoints, commands, packages, collectors, workers, or report paths, +- whether secrets, tenant data, raw payloads, release evidence, audit records, + customer packages, or exports are exposed, +- whether object storage, PostgreSQL, signing keys, SSO, provider metadata, or + CI collectors are involved, +- suggested fix if known. + +## Supported Versions And Scope + +Security support focuses on the current `master` branch and current release +candidate or release tags. Older releases are best effort unless a commercial +support agreement says otherwise. Reports should identify the affected commit, +tag, image digest, Helm chart version, or deployment profile so triage can +reproduce the issue without production data. + +## Advisory And Disclosure Expectations + +Evydence handles tenant-scoped evidence, credentials, signing metadata, object +payload references, and customer-package boundaries. Security fixes should be +coordinated privately until maintainers have a reasonable opportunity to +triage, patch, publish release evidence, and document upgrade guidance. Public +advisories should avoid raw exploit payloads, customer evidence, secrets, or +instructions that increase exposure before users can update. + +Out of scope: + +- denial-of-service reports that require unrealistic local resource access, +- issues caused only by unsupported production configuration, +- findings that depend on publishing secrets or raw customer payloads in public + channels, +- reports that rely on live third-party provider abuse rather than local + reproduction or responsible provider disclosure, +- requests for legal compliance, certification, complete SBOM, scanner + authority, secure-release, regulator-acceptance, or auditor-acceptance claims. + +Do not post secrets, raw evidence payloads, private keys, provider credentials, +session tokens, bearer tokens, database URLs, customer data, or unredacted +customer package contents in public issues, pull requests, screenshots, logs, +or release evidence. diff --git a/SUPPORT.md b/SUPPORT.md new file mode 100644 index 0000000..39f3802 --- /dev/null +++ b/SUPPORT.md @@ -0,0 +1,41 @@ +# Support + +Evydence has community and commercial support paths. + +## Community Support + +Use GitHub issues for reproducible bugs, documentation problems, and feature +discussion. Community support is best effort and has no response-time +commitment. + +Good bug reports include: + +- Evydence commit or tag, +- operating system and Docker/Go versions, +- exact command, API endpoint, CLI command, or worker mode, +- sanitized request shape, response status, and Problem Details response, +- sanitized logs, +- expected result and actual result, +- whether the issue reproduces with `make fast-check` or Docker Compose, +- whether a collector, raw payload, object-store path, signing key, SSO path, + release bundle, report, customer package, backup, restore, or export is + involved. + +Do not post API keys, collector secrets, bearer tokens, session tokens, portal +tokens, private keys, provider credentials, database URLs, raw evidence +payloads, customer data, local backups, or release evidence artifacts in public +issues. + +## Commercial Support + +Commercial support is available for organizations that need private +troubleshooting, security notices, upgrade planning, release evidence, +self-hosted deployment review, or integration review. + +Contact Aatu Harju through LinkedIn: + + + +Do not rely on an SLA, warranty, compliance certification, legal conclusion, +secure-release claim, hosted-service availability, or provider-side +completeness guarantee unless it is explicitly included in a signed agreement. diff --git a/TRADEMARKS.md b/TRADEMARKS.md new file mode 100644 index 0000000..f27037a --- /dev/null +++ b/TRADEMARKS.md @@ -0,0 +1,29 @@ +# Trademarks + +This document describes conservative use of the Evydence name and project +identity. It is not a registered-trademark notice unless a separate +registration exists. + +You may use the Evydence name to: + +- refer truthfully to the public project, +- identify compatibility with unmodified Evydence APIs, +- link to the public repository, +- describe forks as forks. + +You may not use the Evydence name or project identity to imply: + +- endorsement by the project maintainer, +- official release status for modified builds, +- commercial support coverage, +- legal compliance, certification, complete SBOM coverage, authoritative + vulnerability results, secure releases, regulator acceptance, or auditor + acceptance. + +Modified distributions should use a clear name such as "Evydence fork" or +"based on Evydence" and should not present themselves as official releases. + +For permission around commercial naming, partner distribution, or support +language, contact Aatu Harju through LinkedIn: + + diff --git a/cmd/evydence-api/main.go b/cmd/evydence-api/main.go index 154cec8..9f4ecd2 100644 --- a/cmd/evydence-api/main.go +++ b/cmd/evydence-api/main.go @@ -9,13 +9,23 @@ import ( "net/http" "os" "path/filepath" + "strconv" "strings" "time" "github.com/aatuh/evydence/internal/adapters/httpapi" + "github.com/aatuh/evydence/internal/adapters/identity/httpvalidator" + "github.com/aatuh/evydence/internal/adapters/identity/oidcdiscovery" + "github.com/aatuh/evydence/internal/adapters/identity/oidcuserinfo" "github.com/aatuh/evydence/internal/adapters/objectstore/filesystem" s3store "github.com/aatuh/evydence/internal/adapters/objectstore/s3" "github.com/aatuh/evydence/internal/adapters/postgres" + "github.com/aatuh/evydence/internal/adapters/signing/awskms" + "github.com/aatuh/evydence/internal/adapters/signing/azurekeyvault" + "github.com/aatuh/evydence/internal/adapters/signing/gcpkms" + signinggateway "github.com/aatuh/evydence/internal/adapters/signing/httpgateway" + "github.com/aatuh/evydence/internal/adapters/transparency/httpfetcher" + transparencygateway "github.com/aatuh/evydence/internal/adapters/transparency/httpgateway" "github.com/aatuh/evydence/internal/app" ) @@ -29,34 +39,77 @@ func run() error { production := strings.EqualFold(os.Getenv("ENV"), "production") databaseURL := strings.TrimSpace(os.Getenv("EVYDENCE_DATABASE_URL")) pepper := strings.TrimSpace(os.Getenv("EVYDENCE_API_KEY_PEPPER")) - if production { - if databaseURL == "" { - return errors.New("production requires EVYDENCE_DATABASE_URL") - } - if pepper == "" || pepper == "local-dev-pepper-change-me" { - return errors.New("production requires a non-default EVYDENCE_API_KEY_PEPPER") - } - if strings.TrimSpace(os.Getenv("EVYDENCE_SIGNING_KEY_MODE")) != "external" { - return errors.New("production requires EVYDENCE_SIGNING_KEY_MODE=external; plaintext local signing keys are dev-only") - } + if err := validateRuntimeConfig( + production, + databaseURL, + pepper, + strings.TrimSpace(os.Getenv("EVYDENCE_SIGNING_KEY_MODE")), + strings.TrimSpace(os.Getenv("EVYDENCE_SIGNING_EXECUTOR_URL")), + strings.EqualFold(os.Getenv("EVYDENCE_PRINT_BOOTSTRAP_SECRET"), "true"), + ); err != nil { + return err + } + if err := validateAPIWriterMode(production, os.Getenv("EVYDENCE_API_WRITER_MODE"), os.Getenv("EVYDENCE_API_WRITER_REPLICAS")); err != nil { + return err } cfg := app.Config{APIKeyPepper: pepper} + cfg.WorkerOwnedParserSideEffects = boolEnv("EVYDENCE_WORKER_OWNED_PARSER_SIDE_EFFECTS") + cfg.OIDC = oidcdiscovery.New(oidcdiscovery.Config{ + AllowInsecureForLocalhost: strings.EqualFold(os.Getenv("EVYDENCE_OIDC_DISCOVERY_ALLOW_INSECURE_LOCALHOST"), "true"), + Timeout: time.Duration(intEnv("EVYDENCE_OIDC_DISCOVERY_TIMEOUT_SECONDS", 10)) * time.Second, + }) + providerValidator, err := openProviderIdentityValidator() + if err != nil { + return err + } + cfg.ProviderAPI = providerValidator + transparencyFetcher, err := openTransparencyProofFetcher() + if err != nil { + return err + } + cfg.Transparency = transparencyFetcher + if signer, err := openSigningExecutor(); err != nil { + return err + } else { + cfg.Signer = signer + } var closeStore func() + var releaseWriterLease func() if databaseURL != "" { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - pgStore, err := postgres.Open(ctx, databaseURL) + loadMode, err := postgres.ResolveLoadMode(os.Getenv("EVYDENCE_POSTGRES_LOAD_MODE"), production) + if err != nil { + return err + } + if production { + if err := postgres.ValidateProductionLoadMode(loadMode); err != nil { + return err + } + } + pgStore, err := postgres.OpenWithOptions(ctx, databaseURL, postgres.StoreOptions{LoadMode: loadMode, DisableSnapshotWrites: production}) if err != nil { return err } closeStore = pgStore.Close + if production { + releaseWriterLease, err = pgStore.AcquireAPIWriterLease(ctx) + if err != nil { + closeStore() + return fmt.Errorf("acquire api writer lease: %w", err) + } + } + migrationsDir := envDefault("EVYDENCE_MIGRATIONS_DIR", "migrations") if !strings.EqualFold(os.Getenv("EVYDENCE_SKIP_MIGRATIONS"), "true") { - if _, err := pgStore.ApplyMigrations(ctx, envDefault("EVYDENCE_MIGRATIONS_DIR", "migrations")); err != nil { + if _, err := pgStore.ApplyMigrations(ctx, migrationsDir); err != nil { closeStore() return fmt.Errorf("apply migrations: %w", err) } + } else if err := pgStore.RequireNoPendingMigrations(ctx, migrationsDir); err != nil { + closeStore() + return fmt.Errorf("check migrations: %w", err) } - objectStore, objectDescription, err := openObjectStore(ctx) + objectStore, _, err := openObjectStore(ctx) if err != nil { closeStore() return err @@ -64,11 +117,14 @@ func run() error { cfg.Store = pgStore cfg.Outbox = pgStore cfg.ObjectStore = objectStore - log.Printf("evydence api using postgres state store and %s object store", objectDescription) + log.Print("evydence api using postgres state store and configured object store") } if closeStore != nil { defer closeStore() } + if releaseWriterLease != nil { + defer releaseWriterLease() + } ledger, err := app.NewLedgerWithError(cfg) if err != nil { return fmt.Errorf("create ledger: %w", err) @@ -88,7 +144,9 @@ func run() error { log.Printf("bootstrapped tenant %s and key %s; set EVYDENCE_PRINT_BOOTSTRAP_SECRET=true for local-only secret output", tenant.ID, key.ID) } } - server, err := httpapi.NewServer(ledger) + server, err := httpapi.NewServerWithOptions(ledger, httpapi.ServerOptions{ + RateLimitRequestsPerMinute: intEnv("EVYDENCE_RATE_LIMIT_REQUESTS_PER_MINUTE", 0), + }) if err != nil { return fmt.Errorf("create server: %w", err) } @@ -102,6 +160,185 @@ func run() error { return httpServer.ListenAndServe() } +func openSigningExecutor() (app.SigningExecutor, error) { + mode := normalizeSigningKeyMode(os.Getenv("EVYDENCE_SIGNING_KEY_MODE")) + if mode == "aws_kms" { + region := strings.TrimSpace(os.Getenv("EVYDENCE_AWS_REGION")) + if region == "" { + region = strings.TrimSpace(os.Getenv("AWS_REGION")) + } + executor, err := awskms.New(context.Background(), awskms.Config{ + Region: region, + KeyID: os.Getenv("EVYDENCE_AWS_KMS_KEY_ID"), + Endpoint: os.Getenv("EVYDENCE_AWS_KMS_ENDPOINT"), + SigningAlgorithm: os.Getenv("EVYDENCE_AWS_KMS_SIGNING_ALGORITHM"), + Timeout: time.Duration(intEnv("EVYDENCE_AWS_KMS_TIMEOUT_SECONDS", 10)) * time.Second, + }) + if err != nil { + return nil, fmt.Errorf("configure AWS KMS signing executor: %w", err) + } + return executor, nil + } + if mode == "gcp_kms" && strings.TrimSpace(os.Getenv("EVYDENCE_GCP_KMS_ACCESS_TOKEN")) != "" { + executor, err := gcpkms.New(gcpkms.Config{ + Endpoint: os.Getenv("EVYDENCE_GCP_KMS_ENDPOINT"), + AccessToken: os.Getenv("EVYDENCE_GCP_KMS_ACCESS_TOKEN"), + KeyName: os.Getenv("EVYDENCE_GCP_KMS_KEY_NAME"), + Timeout: time.Duration(intEnv("EVYDENCE_GCP_KMS_TIMEOUT_SECONDS", 10)) * time.Second, + }) + if err != nil { + return nil, fmt.Errorf("configure GCP KMS signing executor: %w", err) + } + return executor, nil + } + if mode == "azure_key_vault" && strings.TrimSpace(os.Getenv("EVYDENCE_AZURE_KEY_VAULT_ACCESS_TOKEN")) != "" { + executor, err := azurekeyvault.New(azurekeyvault.Config{ + VaultURL: os.Getenv("EVYDENCE_AZURE_KEY_VAULT_URL"), + AccessToken: os.Getenv("EVYDENCE_AZURE_KEY_VAULT_ACCESS_TOKEN"), + KeyName: os.Getenv("EVYDENCE_AZURE_KEY_VAULT_KEY_NAME"), + KeyVersion: os.Getenv("EVYDENCE_AZURE_KEY_VAULT_KEY_VERSION"), + Algorithm: os.Getenv("EVYDENCE_AZURE_KEY_VAULT_ALGORITHM"), + APIVersion: os.Getenv("EVYDENCE_AZURE_KEY_VAULT_API_VERSION"), + Timeout: time.Duration(intEnv("EVYDENCE_AZURE_KEY_VAULT_TIMEOUT_SECONDS", 10)) * time.Second, + }) + if err != nil { + return nil, fmt.Errorf("configure Azure Key Vault signing executor: %w", err) + } + return executor, nil + } + endpoint := strings.TrimSpace(os.Getenv("EVYDENCE_SIGNING_EXECUTOR_URL")) + if endpoint == "" && signingModeRequiresGateway(mode) { + return nil, fmt.Errorf("EVYDENCE_SIGNING_KEY_MODE=%s requires direct provider credentials or EVYDENCE_SIGNING_EXECUTOR_URL", mode) + } + if endpoint == "" { + return nil, nil + } + executor, err := signinggateway.New(signinggateway.Config{ + Endpoint: endpoint, + BearerToken: os.Getenv("EVYDENCE_SIGNING_EXECUTOR_TOKEN"), + AllowInsecureForLocalhost: strings.EqualFold(os.Getenv("EVYDENCE_SIGNING_EXECUTOR_ALLOW_INSECURE_LOCALHOST"), "true"), + Timeout: time.Duration(intEnv("EVYDENCE_SIGNING_EXECUTOR_TIMEOUT_SECONDS", 10)) * time.Second, + }) + if err != nil { + return nil, fmt.Errorf("configure signing executor: %w", err) + } + return executor, nil +} + +func openTransparencyProofFetcher() (app.TransparencyProofFetcher, error) { + endpoint := strings.TrimSpace(os.Getenv("EVYDENCE_TRANSPARENCY_PROOF_GATEWAY_URL")) + if endpoint != "" { + fetcher, err := transparencygateway.New(transparencygateway.Config{ + Endpoint: endpoint, + BearerToken: os.Getenv("EVYDENCE_TRANSPARENCY_PROOF_GATEWAY_TOKEN"), + AllowInsecureForLocalhost: strings.EqualFold(os.Getenv("EVYDENCE_TRANSPARENCY_PROOF_GATEWAY_ALLOW_INSECURE_LOCALHOST"), "true"), + Timeout: time.Duration(intEnv("EVYDENCE_TRANSPARENCY_PROOF_GATEWAY_TIMEOUT_SECONDS", 10)) * time.Second, + }) + if err != nil { + return nil, fmt.Errorf("configure transparency proof gateway: %w", err) + } + return fetcher, nil + } + return httpfetcher.New(httpfetcher.Config{ + AllowInsecureForLocalhost: strings.EqualFold(os.Getenv("EVYDENCE_TRANSPARENCY_FETCH_ALLOW_INSECURE_LOCALHOST"), "true"), + Timeout: time.Duration(intEnv("EVYDENCE_TRANSPARENCY_FETCH_TIMEOUT_SECONDS", 10)) * time.Second, + }), nil +} + +func openProviderIdentityValidator() (app.ProviderIdentityValidator, error) { + endpoint := strings.TrimSpace(os.Getenv("EVYDENCE_PROVIDER_VALIDATION_GATEWAY_URL")) + if endpoint != "" { + validator, err := httpvalidator.New(httpvalidator.Config{ + Endpoint: endpoint, + BearerToken: os.Getenv("EVYDENCE_PROVIDER_VALIDATION_GATEWAY_TOKEN"), + AllowInsecureForLocalhost: strings.EqualFold(os.Getenv("EVYDENCE_PROVIDER_VALIDATION_GATEWAY_ALLOW_INSECURE_LOCALHOST"), "true"), + Timeout: time.Duration(intEnv("EVYDENCE_PROVIDER_VALIDATION_GATEWAY_TIMEOUT_SECONDS", 10)) * time.Second, + }) + if err != nil { + return nil, fmt.Errorf("configure provider validation gateway: %w", err) + } + return validator, nil + } + return oidcuserinfo.New(oidcuserinfo.Config{ + AllowInsecureForLocalhost: strings.EqualFold(os.Getenv("EVYDENCE_OIDC_USERINFO_ALLOW_INSECURE_LOCALHOST"), "true"), + Timeout: time.Duration(intEnv("EVYDENCE_OIDC_USERINFO_TIMEOUT_SECONDS", 10)) * time.Second, + }), nil +} + +func validateRuntimeConfig(production bool, databaseURL, pepper, signingKeyMode, signingExecutorURL string, printBootstrapSecret bool) error { + if !production { + return nil + } + if strings.TrimSpace(databaseURL) == "" { + return errors.New("production requires EVYDENCE_DATABASE_URL") + } + if strings.TrimSpace(pepper) == "" || strings.TrimSpace(pepper) == "local-dev-pepper-change-me" { + return errors.New("production requires a non-default EVYDENCE_API_KEY_PEPPER") + } + normalizedMode := normalizeSigningKeyMode(signingKeyMode) + if !productionSigningKeyMode(normalizedMode) { + return errors.New("production requires EVYDENCE_SIGNING_KEY_MODE=external, aws-kms, gcp-kms, azure-key-vault, or pkcs11-hsm; plaintext local signing keys are dev-only") + } + if normalizedMode == "pkcs11_hsm" && strings.TrimSpace(signingExecutorURL) == "" { + return fmt.Errorf("production EVYDENCE_SIGNING_KEY_MODE=%s requires EVYDENCE_SIGNING_EXECUTOR_URL", normalizedMode) + } + if printBootstrapSecret { + return errors.New("production refuses EVYDENCE_PRINT_BOOTSTRAP_SECRET=true") + } + return nil +} + +func normalizeSigningKeyMode(value string) string { + normalized := strings.ToLower(strings.TrimSpace(value)) + normalized = strings.ReplaceAll(normalized, "-", "_") + return normalized +} + +func productionSigningKeyMode(normalizedMode string) bool { + switch normalizedMode { + case "external", "aws_kms", "gcp_kms", "azure_key_vault", "pkcs11_hsm": + return true + default: + return false + } +} + +func signingModeRequiresGateway(normalizedMode string) bool { + switch normalizedMode { + case "gcp_kms", "azure_key_vault", "pkcs11_hsm": + return true + default: + return false + } +} + +func validateAPIWriterMode(production bool, mode, replicas string) error { + normalizedMode := strings.ToLower(strings.TrimSpace(mode)) + if normalizedMode == "" { + normalizedMode = "single" + } + switch normalizedMode { + case "single", "single-writer": + default: + if production { + return fmt.Errorf("production supports only EVYDENCE_API_WRITER_MODE=single until multi-writer concurrency controls are implemented") + } + } + + replicaValue := strings.TrimSpace(replicas) + if replicaValue == "" { + return nil + } + replicaCount, err := strconv.Atoi(replicaValue) + if err != nil || replicaCount < 1 { + return fmt.Errorf("EVYDENCE_API_WRITER_REPLICAS must be a positive integer") + } + if production && replicaCount != 1 { + return fmt.Errorf("production supports only one API writer replica until multi-writer concurrency controls are implemented") + } + return nil +} + func envDefault(name, fallback string) string { if value := strings.TrimSpace(os.Getenv(name)); value != "" { return value @@ -109,6 +346,22 @@ func envDefault(name, fallback string) string { return fallback } +func intEnv(name string, fallback int) int { + value := strings.TrimSpace(os.Getenv(name)) + if value == "" { + return fallback + } + parsed, err := strconv.Atoi(value) + if err != nil || parsed < 0 { + return fallback + } + return parsed +} + +func boolEnv(name string) bool { + return strings.EqualFold(strings.TrimSpace(os.Getenv(name)), "true") +} + func openObjectStore(ctx context.Context) (app.ObjectStore, string, error) { switch strings.ToLower(strings.TrimSpace(os.Getenv("EVYDENCE_OBJECT_STORE"))) { case "", "file", "filesystem": diff --git a/cmd/evydence-api/main_test.go b/cmd/evydence-api/main_test.go new file mode 100644 index 0000000..c570c3d --- /dev/null +++ b/cmd/evydence-api/main_test.go @@ -0,0 +1,307 @@ +package main + +import ( + "strings" + "testing" + + "github.com/aatuh/evydence/internal/adapters/postgres" +) + +func TestValidateRuntimeConfigRejectsProductionBootstrapSecretPrinting(t *testing.T) { + err := validateRuntimeConfig(true, "postgres://example", "not-default", "external", "", true) + if err == nil { + t.Fatal("expected production bootstrap secret printing to be rejected") + } + if !strings.Contains(err.Error(), "EVYDENCE_PRINT_BOOTSTRAP_SECRET") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestValidateRuntimeConfigAllowsLocalBootstrapSecretPrinting(t *testing.T) { + if err := validateRuntimeConfig(false, "", "", "", "", true); err != nil { + t.Fatalf("local config should allow explicit bootstrap secret printing: %v", err) + } +} + +func TestValidateRuntimeConfigRejectsProductionDefaults(t *testing.T) { + tests := []struct { + name string + database string + pepper string + signing string + wantSubstr string + }{ + {name: "missing database", database: "", pepper: "not-default", signing: "external", wantSubstr: "EVYDENCE_DATABASE_URL"}, + {name: "missing pepper", database: "postgres://example", pepper: "", signing: "external", wantSubstr: "EVYDENCE_API_KEY_PEPPER"}, + {name: "default pepper", database: "postgres://example", pepper: "local-dev-pepper-change-me", signing: "external", wantSubstr: "EVYDENCE_API_KEY_PEPPER"}, + {name: "local signing", database: "postgres://example", pepper: "not-default", signing: "local", wantSubstr: "EVYDENCE_SIGNING_KEY_MODE"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateRuntimeConfig(true, tt.database, tt.pepper, tt.signing, "", false) + if err == nil || !strings.Contains(err.Error(), tt.wantSubstr) { + t.Fatalf("err=%v, want %q", err, tt.wantSubstr) + } + }) + } +} + +func TestValidateRuntimeConfigAllowsProductionAWSKMSMode(t *testing.T) { + if err := validateRuntimeConfig(true, "postgres://example", "not-default", "aws-kms", "", false); err != nil { + t.Fatalf("aws-kms production mode should be accepted: %v", err) + } +} + +func TestValidateRuntimeConfigAllowsGatewayBackedKMSModesWithExecutor(t *testing.T) { + for _, mode := range []string{"gcp-kms", "azure-key-vault", "pkcs11-hsm"} { + t.Run(mode, func(t *testing.T) { + if err := validateRuntimeConfig(true, "postgres://example", "not-default", mode, "https://signer.example.test/sign", false); err != nil { + t.Fatalf("%s production gateway mode should be accepted: %v", mode, err) + } + }) + } +} + +func TestValidateRuntimeConfigAllowsDirectCloudKMSModesWithoutGateway(t *testing.T) { + for _, mode := range []string{"gcp-kms", "azure-key-vault"} { + t.Run(mode, func(t *testing.T) { + if err := validateRuntimeConfig(true, "postgres://example", "not-default", mode, "", false); err != nil { + t.Fatalf("%s direct mode should pass runtime config validation: %v", mode, err) + } + }) + } +} + +func TestValidateRuntimeConfigRejectsPKCS11ModeWithoutGateway(t *testing.T) { + err := validateRuntimeConfig(true, "postgres://example", "not-default", "pkcs11-hsm", "", false) + if err == nil || !strings.Contains(err.Error(), "EVYDENCE_SIGNING_EXECUTOR_URL") { + t.Fatalf("pkcs11 missing gateway err=%v", err) + } +} + +func TestValidateAPIWriterModeAllowsProductionSingleWriter(t *testing.T) { + if err := validateAPIWriterMode(true, "single", "1"); err != nil { + t.Fatalf("single writer production mode should be accepted: %v", err) + } + if err := validateAPIWriterMode(true, "", ""); err != nil { + t.Fatalf("default writer mode should be accepted: %v", err) + } +} + +func TestValidateAPIWriterModeRejectsProductionMultiWriter(t *testing.T) { + err := validateAPIWriterMode(true, "multi", "1") + if err == nil || !strings.Contains(err.Error(), "EVYDENCE_API_WRITER_MODE") { + t.Fatalf("multi-writer production mode err=%v", err) + } +} + +func TestValidateAPIWriterModeRejectsProductionReplicaCountAboveOne(t *testing.T) { + err := validateAPIWriterMode(true, "single", "2") + if err == nil || !strings.Contains(err.Error(), "one API writer replica") { + t.Fatalf("production replica count err=%v", err) + } +} + +func TestValidateAPIWriterModeRejectsInvalidReplicaCount(t *testing.T) { + for _, value := range []string{"0", "-1", "not-a-number"} { + t.Run(value, func(t *testing.T) { + err := validateAPIWriterMode(false, "multi", value) + if err == nil || !strings.Contains(err.Error(), "EVYDENCE_API_WRITER_REPLICAS") { + t.Fatalf("invalid replica count err=%v", err) + } + }) + } +} + +func TestValidateAPIWriterModeAllowsLocalExperimentalMode(t *testing.T) { + if err := validateAPIWriterMode(false, "multi", "3"); err != nil { + t.Fatalf("local experimental writer mode should not be production-blocked: %v", err) + } +} + +func TestPostgresLoadModeDefaultsToRelationalOnlyInProduction(t *testing.T) { + mode, err := postgres.ResolveLoadMode("", true) + if err != nil { + t.Fatalf("resolve production load mode: %v", err) + } + if mode != postgres.LoadModeRelationalOnly { + t.Fatalf("production load mode = %q, want %q", mode, postgres.LoadModeRelationalOnly) + } + + mode, err = postgres.ResolveLoadMode("", false) + if err != nil { + t.Fatalf("resolve local load mode: %v", err) + } + if mode != postgres.LoadModeSnapshotPreferred { + t.Fatalf("local load mode = %q, want %q", mode, postgres.LoadModeSnapshotPreferred) + } + + if err := postgres.ValidateProductionLoadMode(postgres.LoadModeRelationalPreferred); err == nil || !strings.Contains(err.Error(), "EVYDENCE_POSTGRES_LOAD_MODE") { + t.Fatalf("relational-preferred production validation err=%v", err) + } +} + +func TestEnvDefaultAndObjectStoreSelection(t *testing.T) { + t.Setenv("EVYDENCE_TEST_VALUE", " configured ") + if got := envDefault("EVYDENCE_TEST_VALUE", "fallback"); got != "configured" { + t.Fatalf("envDefault configured = %q", got) + } + if got := envDefault("EVYDENCE_MISSING_VALUE", "fallback"); got != "fallback" { + t.Fatalf("envDefault fallback = %q", got) + } + + t.Setenv("EVYDENCE_OBJECT_STORE", "filesystem") + t.Setenv("EVYDENCE_OBJECT_DIR", t.TempDir()) + store, description, err := openObjectStore(t.Context()) + if err != nil { + t.Fatalf("filesystem object store: %v", err) + } + if store == nil || !strings.Contains(description, "filesystem root") { + t.Fatalf("store=%T description=%q", store, description) + } + + t.Setenv("EVYDENCE_OBJECT_STORE", "unknown") + if _, _, err := openObjectStore(t.Context()); err == nil || !strings.Contains(err.Error(), "unsupported") { + t.Fatalf("unsupported object store err=%v", err) + } +} + +func TestIntEnvParsesRateLimitConfiguration(t *testing.T) { + t.Setenv("EVYDENCE_RATE_LIMIT_REQUESTS_PER_MINUTE", "25") + if got := intEnv("EVYDENCE_RATE_LIMIT_REQUESTS_PER_MINUTE", 0); got != 25 { + t.Fatalf("configured int env = %d", got) + } + t.Setenv("EVYDENCE_RATE_LIMIT_REQUESTS_PER_MINUTE", "-1") + if got := intEnv("EVYDENCE_RATE_LIMIT_REQUESTS_PER_MINUTE", 7); got != 7 { + t.Fatalf("negative int env fallback = %d", got) + } +} + +func TestBoolEnvRequiresExplicitTrue(t *testing.T) { + t.Setenv("EVYDENCE_WORKER_OWNED_PARSER_SIDE_EFFECTS", " true ") + if !boolEnv("EVYDENCE_WORKER_OWNED_PARSER_SIDE_EFFECTS") { + t.Fatal("expected explicit true env to be enabled") + } + t.Setenv("EVYDENCE_WORKER_OWNED_PARSER_SIDE_EFFECTS", "yes") + if boolEnv("EVYDENCE_WORKER_OWNED_PARSER_SIDE_EFFECTS") { + t.Fatal("expected non-true env value to be disabled") + } +} + +func TestOpenObjectStoreRejectsIncompleteS3Config(t *testing.T) { + t.Setenv("EVYDENCE_OBJECT_STORE", "s3") + t.Setenv("EVYDENCE_S3_ENDPOINT", "localhost:9000") + if _, _, err := openObjectStore(t.Context()); err == nil { + t.Fatal("expected incomplete S3 config to be rejected") + } +} + +func TestOpenSigningExecutorRequiresHTTPSUnlessLocalOverride(t *testing.T) { + t.Setenv("EVYDENCE_SIGNING_EXECUTOR_URL", "http://signer.example.test/sign") + if _, err := openSigningExecutor(); err == nil || !strings.Contains(err.Error(), "https") { + t.Fatalf("remote http signer err=%v, want https rejection", err) + } + t.Setenv("EVYDENCE_SIGNING_EXECUTOR_URL", "http://127.0.0.1/sign") + t.Setenv("EVYDENCE_SIGNING_EXECUTOR_ALLOW_INSECURE_LOCALHOST", "true") + signer, err := openSigningExecutor() + if err != nil { + t.Fatalf("localhost signer should be accepted: %v", err) + } + if signer == nil { + t.Fatal("expected signer") + } +} + +func TestOpenSigningExecutorUsesGatewayForNonAWSKMSModes(t *testing.T) { + t.Setenv("EVYDENCE_SIGNING_KEY_MODE", "gcp-kms") + t.Setenv("EVYDENCE_SIGNING_EXECUTOR_URL", "http://127.0.0.1/sign") + t.Setenv("EVYDENCE_SIGNING_EXECUTOR_ALLOW_INSECURE_LOCALHOST", "true") + signer, err := openSigningExecutor() + if err != nil { + t.Fatalf("gcp-kms gateway signer should be accepted: %v", err) + } + if signer == nil { + t.Fatal("expected signer") + } +} + +func TestOpenSigningExecutorRequiresGatewayForNonAWSKMSModes(t *testing.T) { + t.Setenv("EVYDENCE_SIGNING_KEY_MODE", "pkcs11-hsm") + if _, err := openSigningExecutor(); err == nil || !strings.Contains(err.Error(), "EVYDENCE_SIGNING_EXECUTOR_URL") { + t.Fatalf("missing gateway err=%v", err) + } +} + +func TestOpenSigningExecutorConfiguresDirectGCPKMS(t *testing.T) { + t.Setenv("EVYDENCE_SIGNING_KEY_MODE", "gcp-kms") + t.Setenv("EVYDENCE_GCP_KMS_ENDPOINT", "https://kms.example.test") + t.Setenv("EVYDENCE_GCP_KMS_ACCESS_TOKEN", "access-token") + signer, err := openSigningExecutor() + if err != nil { + t.Fatalf("gcp-kms direct signer should be accepted: %v", err) + } + if signer == nil { + t.Fatal("expected signer") + } +} + +func TestOpenSigningExecutorConfiguresDirectAzureKeyVault(t *testing.T) { + t.Setenv("EVYDENCE_SIGNING_KEY_MODE", "azure-key-vault") + t.Setenv("EVYDENCE_AZURE_KEY_VAULT_URL", "https://vault.example.test") + t.Setenv("EVYDENCE_AZURE_KEY_VAULT_ACCESS_TOKEN", "access-token") + t.Setenv("EVYDENCE_AZURE_KEY_VAULT_KEY_NAME", "evydence") + t.Setenv("EVYDENCE_AZURE_KEY_VAULT_KEY_VERSION", "v1") + signer, err := openSigningExecutor() + if err != nil { + t.Fatalf("azure-key-vault direct signer should be accepted: %v", err) + } + if signer == nil { + t.Fatal("expected signer") + } +} + +func TestOpenSigningExecutorRejectsIncompleteAWSKMSConfig(t *testing.T) { + t.Setenv("EVYDENCE_SIGNING_KEY_MODE", "aws-kms") + t.Setenv("EVYDENCE_AWS_REGION", "eu-north-1") + if _, err := openSigningExecutor(); err == nil { + t.Fatal("expected missing AWS KMS key id to be rejected") + } +} + +func TestOpenProviderIdentityValidatorUsesGatewayWhenConfigured(t *testing.T) { + t.Setenv("EVYDENCE_PROVIDER_VALIDATION_GATEWAY_URL", "http://127.0.0.1/provider") + t.Setenv("EVYDENCE_PROVIDER_VALIDATION_GATEWAY_ALLOW_INSECURE_LOCALHOST", "true") + validator, err := openProviderIdentityValidator() + if err != nil { + t.Fatalf("provider validation gateway should be accepted: %v", err) + } + if validator == nil { + t.Fatal("expected provider validator") + } +} + +func TestOpenProviderIdentityValidatorRequiresHTTPSForRemoteGateway(t *testing.T) { + t.Setenv("EVYDENCE_PROVIDER_VALIDATION_GATEWAY_URL", "http://provider.example.test/validate") + if _, err := openProviderIdentityValidator(); err == nil || !strings.Contains(err.Error(), "https") { + t.Fatalf("remote http provider validator err=%v, want https rejection", err) + } +} + +func TestOpenTransparencyProofFetcherUsesGatewayWhenConfigured(t *testing.T) { + t.Setenv("EVYDENCE_TRANSPARENCY_PROOF_GATEWAY_URL", "http://127.0.0.1/proof") + t.Setenv("EVYDENCE_TRANSPARENCY_PROOF_GATEWAY_ALLOW_INSECURE_LOCALHOST", "true") + fetcher, err := openTransparencyProofFetcher() + if err != nil { + t.Fatalf("transparency proof gateway should be accepted: %v", err) + } + if fetcher == nil { + t.Fatal("expected transparency proof fetcher") + } +} + +func TestOpenTransparencyProofFetcherRequiresHTTPSForRemoteGateway(t *testing.T) { + t.Setenv("EVYDENCE_TRANSPARENCY_PROOF_GATEWAY_URL", "http://transparency.example.test/proof") + if _, err := openTransparencyProofFetcher(); err == nil || !strings.Contains(err.Error(), "https") { + t.Fatalf("remote http transparency gateway err=%v, want https rejection", err) + } +} diff --git a/cmd/evydence-migrate/main_test.go b/cmd/evydence-migrate/main_test.go new file mode 100644 index 0000000..a7f7569 --- /dev/null +++ b/cmd/evydence-migrate/main_test.go @@ -0,0 +1,32 @@ +package main + +import ( + "strings" + "testing" +) + +func TestRunRequiresDatabaseURL(t *testing.T) { + t.Setenv("EVYDENCE_DATABASE_URL", "") + err := run() + if err == nil || !strings.Contains(err.Error(), "EVYDENCE_DATABASE_URL") { + t.Fatalf("err=%v, want database URL requirement", err) + } +} + +func TestEnvDefault(t *testing.T) { + t.Setenv("EVYDENCE_MIGRATIONS_DIR_TEST", " custom ") + if got := envDefault("EVYDENCE_MIGRATIONS_DIR_TEST", "migrations"); got != "custom" { + t.Fatalf("configured envDefault = %q", got) + } + if got := envDefault("EVYDENCE_MISSING_MIGRATIONS_DIR_TEST", "migrations"); got != "migrations" { + t.Fatalf("fallback envDefault = %q", got) + } +} + +func TestRunReturnsPostgresOpenFailure(t *testing.T) { + t.Setenv("EVYDENCE_DATABASE_URL", "postgres://invalid-host.invalid/evydence") + err := run() + if err == nil { + t.Fatal("expected postgres open failure") + } +} diff --git a/cmd/evydence-worker/main.go b/cmd/evydence-worker/main.go index 34260b2..9cb92fc 100644 --- a/cmd/evydence-worker/main.go +++ b/cmd/evydence-worker/main.go @@ -1,45 +1,100 @@ package main import ( + "bytes" "context" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" "errors" + "fmt" + "io" "log" "os" + "path/filepath" + "sort" "strconv" "strings" "time" + "github.com/getkin/kin-openapi/openapi3" + + "github.com/aatuh/evydence/internal/adapters/objectstore/filesystem" + s3store "github.com/aatuh/evydence/internal/adapters/objectstore/s3" "github.com/aatuh/evydence/internal/adapters/postgres" + "github.com/aatuh/evydence/internal/app" + "github.com/aatuh/evydence/internal/domain" ) +const defaultMaxWorkerPayloadBytes = 20 << 20 + +var expectedParserVersions = map[string]string{ + "parse_sbom": app.ParserVersionCycloneDXJSON, + "parse_vulnerability_scan": app.ParserVersionGenericVulnerabilityJSON, + "parse_openapi_contract": app.ParserVersionOpenAPIJSON, + "parse_vex": app.ParserVersionOpenVEXJSON, + "verify_attestation": app.ParserVersionDSSEInTotoJSON, +} + func main() { - if err := run(); err != nil { + if err := runWithArgs(os.Args[1:]); err != nil { log.Fatal(err) } } func run() error { + return runWithArgs(nil) +} + +func runWithArgs(args []string) error { + if len(args) > 0 { + switch args[0] { + case "healthcheck", "--healthcheck": + return nil + default: + return fmt.Errorf("unsupported worker command %q", args[0]) + } + } + production := strings.EqualFold(os.Getenv("ENV"), "production") databaseURL := strings.TrimSpace(os.Getenv("EVYDENCE_DATABASE_URL")) if databaseURL == "" { return errors.New("worker requires EVYDENCE_DATABASE_URL") } ctx := context.Background() - store, err := postgres.Open(ctx, databaseURL) + loadMode, err := postgres.ResolveLoadMode(os.Getenv("EVYDENCE_POSTGRES_LOAD_MODE"), production) + if err != nil { + return err + } + if production { + if err := postgres.ValidateProductionLoadMode(loadMode); err != nil { + return err + } + } + store, err := postgres.OpenWithOptions(ctx, databaseURL, postgres.StoreOptions{LoadMode: loadMode, DisableSnapshotWrites: production}) if err != nil { return err } defer store.Close() + migrationsDir := envDefault("EVYDENCE_MIGRATIONS_DIR", "migrations") + migrateCtx, cancel := context.WithTimeout(ctx, 30*time.Second) if !strings.EqualFold(os.Getenv("EVYDENCE_SKIP_MIGRATIONS"), "true") { - migrateCtx, cancel := context.WithTimeout(ctx, 30*time.Second) - if _, err := store.ApplyMigrations(migrateCtx, envDefault("EVYDENCE_MIGRATIONS_DIR", "migrations")); err != nil { + if _, err := store.ApplyMigrations(migrateCtx, migrationsDir); err != nil { cancel() return err } + } else if err := store.RequireNoPendingMigrations(migrateCtx, migrationsDir); err != nil { cancel() + return fmt.Errorf("check migrations: %w", err) + } + cancel() + objectStore, _, err := openObjectStore(ctx) + if err != nil { + return err } pollInterval := durationEnv("EVYDENCE_WORKER_POLL_INTERVAL", time.Second) batchSize := intEnv("EVYDENCE_WORKER_BATCH_SIZE", 10) - log.Printf("evydence worker started with postgres outbox polling interval %s", pollInterval) + log.Printf("evydence worker started with postgres outbox, configured object store, polling interval %s", pollInterval) for { if err := ctx.Err(); err != nil { return err @@ -56,7 +111,7 @@ func run() error { } for _, job := range jobs { log.Printf("processing outbox job id=%s kind=%s subject_type=%s subject_id=%s attempt=%d", job.ID, job.Kind, job.SubjectType, job.SubjectID, job.Attempts) - if err := processJob(ctx, job); err != nil { + if err := processJobWithObjects(ctx, store, objectStore, job); err != nil { log.Printf("outbox job failed id=%s kind=%s: %v", job.ID, job.Kind, err) if failErr := store.FailJob(ctx, job.ID, err); failErr != nil { log.Printf("record outbox failure failed id=%s: %v", job.ID, failErr) @@ -70,16 +125,1257 @@ func run() error { } } -func processJob(ctx context.Context, job postgres.ClaimedJob) error { +type jobStateLoader interface { + LoadState(context.Context) (app.PersistedState, bool, error) +} + +type jobStateStore interface { + jobStateLoader + SaveState(context.Context, app.PersistedState) error +} + +type jobReleaseLedgerMutationStore interface { + jobStateLoader + ApplyReleaseLedgerMutation(context.Context, app.ReleaseLedgerMutation) error +} + +type jobObjectGetter interface { + Get(context.Context, string) (app.Object, error) +} + +func processJob(ctx context.Context, state jobStateLoader, job postgres.ClaimedJob) error { + return processJobInternal(ctx, state, nil, job, false) +} + +func processJobWithObjects(ctx context.Context, state jobStateLoader, objects jobObjectGetter, job postgres.ClaimedJob) error { + return processJobInternal(ctx, state, objects, job, true) +} + +func processJobInternal(ctx context.Context, state jobStateLoader, objects jobObjectGetter, job postgres.ClaimedJob, requireObjectReplay bool) error { if err := ctx.Err(); err != nil { return err } + if state == nil { + return errors.New("outbox job handler requires durable state") + } + var replayed app.Object + var hasReplayedObject bool + if requireObjectReplay { + object, ok, err := verifyJobObject(ctx, objects, job) + if err != nil { + recordVEXImportReportFailure(ctx, state, job, err) + return err + } + replayed, hasReplayedObject = object, ok + } + snapshot, ok, err := state.LoadState(ctx) + if err != nil { + return errors.New("load durable state for outbox job") + } + if !ok { + return errors.New("durable state is not initialized") + } + if err := requireParserVersion(job); err != nil { + if job.Kind == "parse_vex" { + if vex, ok := snapshot.VEXDocuments[job.SubjectID]; ok && vex.TenantID == job.TenantID { + return failVEXImportReportWithSnapshot(ctx, state, &snapshot, job, vex, err) + } + } + return err + } + stateChanged := false switch job.Kind { - case "parse_sbom", "parse_vulnerability_scan", "parse_openapi_contract", "parse_vex", "sign_bundle", "verify_subject", "verify_attestation": + case "parse_sbom": + sbom, ok := snapshot.SBOMs[job.SubjectID] + if !ok || sbom.TenantID != job.TenantID { + return errors.New("parsed sbom is not available in durable state") + } + if hasReplayedObject { + parsed, err := parseReplayedSBOM(replayed.Bytes) + if err != nil { + return err + } + if err := verifyReplayedSBOM(parsed, sbom); err != nil { + return err + } + if updated, changed := mergeReplayedSBOM(sbom, parsed); changed { + snapshot.SBOMs[job.SubjectID] = updated + stateChanged = true + } + } + if err := requirePayloadHash(job, ""); err != nil { + return err + } + case "parse_vulnerability_scan": + scan, ok := snapshot.Scans[job.SubjectID] + if !ok || scan.TenantID != job.TenantID { + return errors.New("parsed vulnerability scan is not available in durable state") + } + if hasReplayedObject { + parsed, err := parseReplayedVulnerabilityScan(replayed.Bytes, job.SubjectID) + if err != nil { + return err + } + if err := verifyReplayedVulnerabilityScan(parsed, scan); err != nil { + return err + } + if updated, changed := mergeReplayedVulnerabilityScan(scan, parsed); changed { + snapshot.Scans[job.SubjectID] = updated + stateChanged = true + } + } + if err := requirePayloadHash(job, ""); err != nil { + return err + } + case "parse_openapi_contract": + contract, ok := snapshot.Contracts[job.SubjectID] + if !ok || contract.TenantID != job.TenantID { + return errors.New("parsed openapi contract is not available in durable state") + } + if hasReplayedObject { + parsed, err := parseReplayedOpenAPIContract(ctx, replayed.Bytes) + if err != nil { + return err + } + if err := verifyReplayedOpenAPIContract(replayed.Bytes, parsed, contract); err != nil { + return err + } + if updated, changed := mergeReplayedOpenAPIContract(contract, parsed); changed { + snapshot.Contracts[job.SubjectID] = updated + stateChanged = true + } + } + if err := requirePayloadHash(job, contract.Hash); err != nil { + return err + } + case "parse_vex": + vex, ok := snapshot.VEXDocuments[job.SubjectID] + if !ok || vex.TenantID != job.TenantID { + return errors.New("parsed vex document is not available in durable state") + } + if hasReplayedObject { + parsed, err := parseReplayedVEX(replayed.Bytes) + if err != nil { + return failVEXImportReportWithSnapshot(ctx, state, &snapshot, job, vex, err) + } + if err := verifyReplayedVEX(parsed, vex); err != nil { + return failVEXImportReportWithSnapshot(ctx, state, &snapshot, job, vex, err) + } + if updated, changed := mergeReplayedVEX(vex, parsed); changed { + snapshot.VEXDocuments[job.SubjectID] = updated + stateChanged = true + } + if payloadBool(job, "worker_create_decisions") { + created, superseded, mappingFailures, err := applyReplayedVEXDecisions(&snapshot, job, vex, parsed, replayed.Digest) + if err != nil { + return failVEXImportReportWithSnapshot(ctx, state, &snapshot, job, vex, err) + } + if created > 0 { + stateChanged = true + } + if updateVEXImportReport(&snapshot, job, vex, parsed, created, superseded, mappingFailures) { + stateChanged = true + } + } + } + if err := requirePayloadHash(job, ""); err != nil { + return err + } + case "sign_bundle": + bundle, ok := snapshot.Bundles[job.SubjectID] + if !ok || bundle.TenantID != job.TenantID { + return errors.New("release bundle is not available in durable state") + } + if len(bundle.SignatureRefs) == 0 { + return errors.New("release bundle signature is missing") + } + return requirePayloadHash(job, bundle.ManifestHash) + case "verify_subject": + resultID := payloadString(job, "result_id") + if resultID == "" { + return errors.New("verification result reference is missing") + } + result, ok := snapshot.Verifications[resultID] + if !ok || result.TenantID != job.TenantID || result.SubjectType != job.SubjectType || result.SubjectID != job.SubjectID { + return errors.New("verification result is not available in durable state") + } + if result.Result == "" { + return errors.New("verification result is incomplete") + } return nil + case "verify_attestation": + attestation, ok := snapshot.BuildAttestations[job.SubjectID] + if !ok || attestation.TenantID != job.TenantID { + return errors.New("build attestation is not available in durable state") + } + if attestation.VerificationStatus == "" { + return errors.New("build attestation verification status is incomplete") + } + if hasReplayedObject { + parsed, err := parseReplayedAttestation(replayed.Bytes) + if err != nil { + return err + } + if err := verifyReplayedAttestation(replayed.Bytes, parsed, attestation); err != nil { + return err + } + if updated, changed := mergeReplayedAttestation(attestation, parsed, replayed.Bytes); changed { + snapshot.BuildAttestations[job.SubjectID] = updated + stateChanged = true + } + } + if err := requirePayloadHash(job, attestation.PayloadHash); err != nil { + return err + } default: return errors.New("unsupported outbox job kind") } + if stateChanged { + return persistParserSideEffects(ctx, state, snapshot, job.Kind) + } + return nil +} + +func persistParserSideEffects(ctx context.Context, state jobStateLoader, snapshot app.PersistedState, kind string) error { + if focused, ok := state.(jobReleaseLedgerMutationStore); ok && releaseLedgerParserJob(kind) { + if err := focused.ApplyReleaseLedgerMutation(ctx, app.ReleaseLedgerMutationFromState(snapshot)); err != nil { + return errors.New("persist durable parser side effects") + } + return nil + } + stateStore, ok := state.(jobStateStore) + if !ok { + return errors.New("durable parser side effects require writable state") + } + if err := stateStore.SaveState(ctx, snapshot); err != nil { + return errors.New("persist durable parser side effects") + } + return nil +} + +func releaseLedgerParserJob(kind string) bool { + switch kind { + case "parse_sbom", "parse_vulnerability_scan", "parse_openapi_contract", "parse_vex": + return true + default: + return false + } +} + +func requireParserVersion(job postgres.ClaimedJob) error { + expected, ok := expectedParserVersions[job.Kind] + if !ok { + return nil + } + got := payloadString(job, "parser_version") + if got == "" { + return nil + } + if job.Kind == "parse_vex" && (got == app.ParserVersionOpenVEXJSON || got == app.ParserVersionCycloneDXVEXJSON) { + return nil + } + if got != expected { + return errors.New("unsupported outbox parser version") + } + return nil +} + +func verifyJobObject(ctx context.Context, objects jobObjectGetter, job postgres.ClaimedJob) (app.Object, bool, error) { + key := payloadObjectKey(job) + if key == "" { + return app.Object{}, false, nil + } + if !strings.HasPrefix(key, "tenants/"+job.TenantID+"/") { + return app.Object{}, false, errors.New("outbox payload object key is not tenant-prefixed") + } + if objects == nil { + return app.Object{}, false, errors.New("outbox object store is not configured") + } + object, err := objects.Get(ctx, key) + if err != nil { + return app.Object{}, false, errors.New("read outbox payload object") + } + if object.TenantID != "" && object.TenantID != job.TenantID { + return app.Object{}, false, errors.New("outbox payload object tenant mismatch") + } + if len(object.Bytes) > intEnv("EVYDENCE_WORKER_MAX_PAYLOAD_BYTES", defaultMaxWorkerPayloadBytes) { + return app.Object{}, false, errors.New("outbox payload object exceeds worker size limit") + } + want := payloadString(job, "payload_hash") + if want == "" { + return object, true, nil + } + if object.Digest != "" && object.Digest != want { + return app.Object{}, false, errors.New("outbox payload object metadata digest mismatch") + } + if digestBytes(object.Bytes) != want { + return app.Object{}, false, errors.New("outbox payload object digest mismatch") + } + return object, true, nil +} + +func requirePayloadHash(job postgres.ClaimedJob, recordedHash string) error { + want := payloadString(job, "payload_hash") + if want == "" || recordedHash == "" { + return nil + } + if want != recordedHash { + return errors.New("outbox payload hash does not match durable state") + } + return nil +} + +func payloadString(job postgres.ClaimedJob, key string) string { + if job.Payload == nil { + return "" + } + value, _ := job.Payload[key].(string) + return strings.TrimSpace(value) +} + +func payloadBool(job postgres.ClaimedJob, key string) bool { + if job.Payload == nil { + return false + } + value, _ := job.Payload[key].(bool) + return value +} + +func payloadObjectKey(job postgres.ClaimedJob) string { + ref := payloadString(job, "payload_ref") + return strings.TrimPrefix(ref, "object://") +} + +type replayedSBOM struct { + SpecVersion string + ComponentCount int + Components []domain.SBOMComponent +} + +func verifyReplayedSBOM(parsed replayedSBOM, sbom domain.SBOM) error { + if sbom.SpecVersion != "" && parsed.SpecVersion != sbom.SpecVersion { + return errors.New("replayed sbom payload does not match durable state") + } + if sbom.ComponentCount != 0 && parsed.ComponentCount != sbom.ComponentCount { + return errors.New("replayed sbom payload does not match durable state") + } + if len(sbom.Components) != 0 && parsed.ComponentCount != len(sbom.Components) { + return errors.New("replayed sbom payload does not match durable state") + } + return nil +} + +func mergeReplayedSBOM(sbom domain.SBOM, parsed replayedSBOM) (domain.SBOM, bool) { + changed := false + if sbom.SpecVersion == "" && parsed.SpecVersion != "" { + sbom.SpecVersion = parsed.SpecVersion + changed = true + } + if sbom.ComponentCount == 0 && parsed.ComponentCount != 0 { + sbom.ComponentCount = parsed.ComponentCount + changed = true + } + if len(sbom.Components) == 0 && len(parsed.Components) != 0 { + sbom.Components = append([]domain.SBOMComponent(nil), parsed.Components...) + changed = true + } + return sbom, changed +} + +func parseReplayedSBOM(raw []byte) (replayedSBOM, error) { + var doc struct { + BOMFormat string `json:"bomFormat"` + SpecVersion string `json:"specVersion"` + Components []struct { + Name string `json:"name"` + Version string `json:"version"` + PURL string `json:"purl"` + } `json:"components"` + } + if err := strictDecodeWorker(raw, &doc); err != nil || strings.ToLower(strings.TrimSpace(doc.BOMFormat)) != "cyclonedx" { + return replayedSBOM{}, errors.New("replayed sbom payload is invalid") + } + components := make([]domain.SBOMComponent, 0, len(doc.Components)) + for _, component := range doc.Components { + if strings.TrimSpace(component.Name) == "" { + return replayedSBOM{}, errors.New("replayed sbom payload is invalid") + } + components = append(components, domain.SBOMComponent{Name: strings.TrimSpace(component.Name), Version: strings.TrimSpace(component.Version), PURL: strings.TrimSpace(component.PURL)}) + } + return replayedSBOM{SpecVersion: strings.TrimSpace(doc.SpecVersion), ComponentCount: len(doc.Components), Components: components}, nil +} + +type replayedVulnerabilityScan struct { + Scanner string + TargetRef string + FindingCount int + Summary map[string]int + Findings []domain.VulnerabilityFinding +} + +func verifyReplayedVulnerabilityScan(parsed replayedVulnerabilityScan, scan domain.VulnerabilityScan) error { + if scan.Scanner != "" && parsed.Scanner != scan.Scanner { + return errors.New("replayed vulnerability scan payload does not match durable state") + } + if scan.TargetRef != "" && parsed.TargetRef != scan.TargetRef { + return errors.New("replayed vulnerability scan payload does not match durable state") + } + if len(scan.Findings) != 0 && parsed.FindingCount != len(scan.Findings) { + return errors.New("replayed vulnerability scan payload does not match durable state") + } + for severity, count := range scan.Summary { + if parsed.Summary[severity] != count { + return errors.New("replayed vulnerability scan payload does not match durable state") + } + } + return nil +} + +func mergeReplayedVulnerabilityScan(scan domain.VulnerabilityScan, parsed replayedVulnerabilityScan) (domain.VulnerabilityScan, bool) { + changed := false + if scan.Scanner == "" && parsed.Scanner != "" { + scan.Scanner = parsed.Scanner + changed = true + } + if scan.TargetRef == "" && parsed.TargetRef != "" { + scan.TargetRef = parsed.TargetRef + changed = true + } + if scan.Summary == nil && parsed.Summary != nil { + scan.Summary = cloneIntMap(parsed.Summary) + changed = true + } + if len(scan.Findings) == 0 && len(parsed.Findings) != 0 { + scan.Findings = append([]domain.VulnerabilityFinding(nil), parsed.Findings...) + changed = true + } + return scan, changed +} + +func parseReplayedVulnerabilityScan(raw []byte, subjectID string) (replayedVulnerabilityScan, error) { + var doc struct { + Scanner string `json:"scanner"` + TargetRef string `json:"target_ref"` + Findings []struct { + Vulnerability string `json:"vulnerability"` + Component string `json:"component"` + Severity string `json:"severity"` + State string `json:"state"` + } `json:"findings"` + ReleaseID string `json:"release_id"` + } + if err := strictDecodeWorker(raw, &doc); err != nil || strings.TrimSpace(doc.Scanner) == "" || strings.TrimSpace(doc.TargetRef) == "" || strings.TrimSpace(doc.ReleaseID) == "" { + return replayedVulnerabilityScan{}, errors.New("replayed vulnerability scan payload is invalid") + } + summary := map[string]int{} + findings := make([]domain.VulnerabilityFinding, 0, len(doc.Findings)) + for i, finding := range doc.Findings { + if strings.TrimSpace(finding.Vulnerability) == "" || strings.TrimSpace(finding.Severity) == "" { + return replayedVulnerabilityScan{}, errors.New("replayed vulnerability scan payload is invalid") + } + severity := strings.ToLower(strings.TrimSpace(finding.Severity)) + summary[severity]++ + findings = append(findings, domain.VulnerabilityFinding{ + ID: fmt.Sprintf("%s:finding:%d", strings.TrimSpace(subjectID), i+1), + Vulnerability: strings.TrimSpace(finding.Vulnerability), + Component: strings.TrimSpace(finding.Component), + Severity: severity, + State: nonEmptyWorker(finding.State, "open"), + }) + } + return replayedVulnerabilityScan{Scanner: strings.TrimSpace(doc.Scanner), TargetRef: strings.TrimSpace(doc.TargetRef), FindingCount: len(doc.Findings), Summary: summary, Findings: findings}, nil +} + +type replayedOpenAPIContract struct { + PathCount int + Operations []domain.OpenAPIOperation +} + +func parseReplayedOpenAPIContract(ctx context.Context, raw []byte) (replayedOpenAPIContract, error) { + loader := openapi3.NewLoader() + doc, err := loader.LoadFromData(raw) + if err != nil { + return replayedOpenAPIContract{}, errors.New("replayed openapi contract payload is invalid") + } + if err := doc.Validate(ctx); err != nil { + return replayedOpenAPIContract{}, errors.New("replayed openapi contract payload is invalid") + } + pathCount := 0 + operations := []domain.OpenAPIOperation{} + if doc.Paths != nil { + paths := doc.Paths.Map() + pathCount = len(paths) + pathNames := make([]string, 0, len(paths)) + for path := range paths { + pathNames = append(pathNames, path) + } + sort.Strings(pathNames) + for _, path := range pathNames { + item := paths[path] + if item == nil { + continue + } + for _, method := range []string{"get", "put", "post", "delete", "options", "head", "patch", "trace"} { + operation := operationForMethod(item, method) + if operation == nil { + continue + } + statuses := []string{} + if operation.Responses != nil { + for status := range operation.Responses.Map() { + statuses = append(statuses, status) + } + sort.Strings(statuses) + } + operations = append(operations, domain.OpenAPIOperation{Path: path, Method: method, OperationID: operation.OperationID, Deprecated: operation.Deprecated, ResponseStatuses: statuses}) + } + } + } + return replayedOpenAPIContract{PathCount: pathCount, Operations: operations}, nil +} + +func verifyReplayedOpenAPIContract(raw []byte, parsed replayedOpenAPIContract, contract domain.OpenAPIContract) error { + if contract.PathCount != 0 && parsed.PathCount != contract.PathCount { + return errors.New("replayed openapi contract payload does not match durable state") + } + if contract.Hash != "" && digestBytes(raw) != contract.Hash { + return errors.New("replayed openapi contract payload does not match durable state") + } + return nil +} + +func mergeReplayedOpenAPIContract(contract domain.OpenAPIContract, parsed replayedOpenAPIContract) (domain.OpenAPIContract, bool) { + changed := false + if contract.PathCount == 0 && parsed.PathCount != 0 { + contract.PathCount = parsed.PathCount + changed = true + } + if len(contract.Operations) == 0 && len(parsed.Operations) != 0 { + contract.Operations = append([]domain.OpenAPIOperation(nil), parsed.Operations...) + changed = true + } + return contract, changed +} + +type replayedVEX struct { + Author string + StatementCount int + StatusSummary map[string]int + Statements []replayedVEXStatement +} + +type replayedVEXStatement struct { + Vulnerability string + Products map[string]struct{} + Status string + Justification string + ImpactStatement string + ActionStatement string +} + +func verifyReplayedVEX(parsed replayedVEX, vex domain.VEXDocument) error { + if vex.Author != "" && parsed.Author != vex.Author { + return errors.New("replayed vex payload does not match durable state") + } + if vex.StatementCount != 0 && parsed.StatementCount != vex.StatementCount { + return errors.New("replayed vex payload does not match durable state") + } + for status, count := range vex.StatusSummary { + if parsed.StatusSummary[status] != count { + return errors.New("replayed vex payload does not match durable state") + } + } + return nil +} + +func mergeReplayedVEX(vex domain.VEXDocument, parsed replayedVEX) (domain.VEXDocument, bool) { + changed := false + if vex.Author == "" && parsed.Author != "" { + vex.Author = parsed.Author + changed = true + } + if vex.StatementCount == 0 && parsed.StatementCount != 0 { + vex.StatementCount = parsed.StatementCount + changed = true + } + if vex.StatusSummary == nil && parsed.StatusSummary != nil { + vex.StatusSummary = cloneIntMap(parsed.StatusSummary) + changed = true + } + return vex, changed +} + +func parseReplayedVEX(raw []byte) (replayedVEX, error) { + var probe struct { + BOMFormat string `json:"bomFormat"` + } + _ = json.Unmarshal(raw, &probe) + if strings.EqualFold(strings.TrimSpace(probe.BOMFormat), "cyclonedx") { + return parseReplayedCycloneDXVEX(raw) + } + return parseReplayedOpenVEX(raw) +} + +func parseReplayedOpenVEX(raw []byte) (replayedVEX, error) { + var doc struct { + Context any `json:"@context"` + ID string `json:"@id"` + Author string `json:"author"` + Timestamp string `json:"timestamp"` + Version any `json:"version"` + Statements []struct { + Vulnerability struct { + Name string `json:"name"` + } `json:"vulnerability"` + Products []map[string]any `json:"products"` + Status string `json:"status"` + Justification string `json:"justification"` + ImpactStatement string `json:"impact_statement"` + ActionStatement string `json:"action_statement"` + } `json:"statements"` + } + if err := strictDecodeWorker(raw, &doc); err != nil || strings.TrimSpace(doc.Author) == "" || strings.TrimSpace(doc.Timestamp) == "" || len(doc.Statements) == 0 { + return replayedVEX{}, errors.New("replayed vex payload is invalid") + } + summary := map[string]int{} + statements := make([]replayedVEXStatement, 0, len(doc.Statements)) + for _, statement := range doc.Statements { + status := strings.TrimSpace(statement.Status) + if strings.TrimSpace(statement.Vulnerability.Name) == "" || status == "" || len(statement.Products) == 0 { + return replayedVEX{}, errors.New("replayed vex payload is invalid") + } + switch status { + case "affected", "not_affected", "fixed", "under_investigation": + default: + return replayedVEX{}, errors.New("replayed vex payload is invalid") + } + summary[status]++ + statements = append(statements, replayedVEXStatement{ + Vulnerability: strings.TrimSpace(statement.Vulnerability.Name), + Products: replayedVEXProductIDs(statement.Products), + Status: status, + Justification: strings.TrimSpace(statement.Justification), + ImpactStatement: strings.TrimSpace(statement.ImpactStatement), + ActionStatement: strings.TrimSpace(statement.ActionStatement), + }) + } + return replayedVEX{Author: strings.TrimSpace(doc.Author), StatementCount: len(doc.Statements), StatusSummary: summary, Statements: statements}, nil +} + +func parseReplayedCycloneDXVEX(raw []byte) (replayedVEX, error) { + var doc struct { + BOMFormat string `json:"bomFormat"` + SpecVersion string `json:"specVersion"` + Vulnerabilities []struct { + ID string `json:"id"` + Affects []struct { + Ref string `json:"ref"` + } `json:"affects,omitempty"` + Analysis struct { + State string `json:"state"` + Justification string `json:"justification"` + Detail string `json:"detail"` + Response []string `json:"response"` + } `json:"analysis"` + } `json:"vulnerabilities"` + } + if err := strictDecodeWorker(raw, &doc); err != nil || !strings.EqualFold(strings.TrimSpace(doc.BOMFormat), "cyclonedx") || len(doc.Vulnerabilities) == 0 { + return replayedVEX{}, errors.New("replayed vex payload is invalid") + } + summary := map[string]int{} + statements := make([]replayedVEXStatement, 0, len(doc.Vulnerabilities)) + for _, vuln := range doc.Vulnerabilities { + status := workerCycloneDXAnalysisStatus(vuln.Analysis.State) + if strings.TrimSpace(vuln.ID) == "" || status == "" { + continue + } + products := map[string]struct{}{} + for _, affect := range vuln.Affects { + if ref := strings.TrimSpace(affect.Ref); ref != "" { + products[ref] = struct{}{} + } + } + summary[status]++ + statements = append(statements, replayedVEXStatement{ + Vulnerability: strings.TrimSpace(vuln.ID), + Products: products, + Status: status, + Justification: nonEmptyWorker(strings.TrimSpace(vuln.Analysis.Justification), "cyclonedx_vex"), + ImpactStatement: strings.TrimSpace(vuln.Analysis.Detail), + ActionStatement: strings.Join(vuln.Analysis.Response, ","), + }) + } + if len(statements) == 0 { + return replayedVEX{}, errors.New("replayed vex payload is invalid") + } + return replayedVEX{Author: "cyclonedx", StatementCount: len(doc.Vulnerabilities), StatusSummary: summary, Statements: statements}, nil +} + +func workerCycloneDXAnalysisStatus(state string) string { + switch strings.ToLower(strings.TrimSpace(state)) { + case "resolved", "fixed": + return "fixed" + case "not_affected": + return "not_affected" + case "exploitable", "affected": + return "affected" + case "in_triage", "under_investigation": + return "under_investigation" + default: + return "" + } +} + +func replayedVEXProductIDs(products []map[string]any) map[string]struct{} { + out := map[string]struct{}{} + var walk func([]map[string]any) + walk = func(items []map[string]any) { + for _, item := range items { + if id, ok := item["@id"].(string); ok { + if id = strings.TrimSpace(id); id != "" { + out[id] = struct{}{} + } + } + if children, ok := item["subcomponents"].([]any); ok { + mapped := make([]map[string]any, 0, len(children)) + for _, child := range children { + if childMap, ok := child.(map[string]any); ok { + mapped = append(mapped, childMap) + } + } + walk(mapped) + } + } + } + walk(products) + return out +} + +func applyReplayedVEXDecisions(state *app.PersistedState, job postgres.ClaimedJob, vex domain.VEXDocument, parsed replayedVEX, payloadHash string) (int, int, []domain.VEXImportIssue, error) { + if state.Decisions == nil { + state.Decisions = map[string]domain.VulnerabilityDecision{} + } + actorType := nonEmptyWorker(payloadString(job, "actor_type"), "worker") + actorID := nonEmptyWorker(payloadString(job, "actor_id"), job.ID) + evidenceID := payloadString(job, "evidence_id") + now := time.Now().UTC() + created := 0 + superseded := 0 + mappingFailures := []domain.VEXImportIssue{} + scanIDs := make([]string, 0, len(state.Scans)) + for id, scan := range state.Scans { + if scan.TenantID == job.TenantID && scan.ReleaseID == vex.ReleaseID { + scanIDs = append(scanIDs, id) + } + } + sort.Strings(scanIDs) + for index, statement := range parsed.Statements { + statementMatched := false + for _, scanID := range scanIDs { + scan := state.Scans[scanID] + for _, finding := range scan.Findings { + if finding.Vulnerability != statement.Vulnerability { + continue + } + if len(statement.Products) > 0 && finding.Component != "" { + if _, ok := statement.Products[finding.Component]; !ok { + continue + } + } + statementMatched = true + if replayedVEXDecisionExists(state.Decisions, job.TenantID, vex.ID, finding.ID) { + continue + } + decisionID := replayedVEXDecisionID(vex.ID, finding.ID, statement.Status) + if _, exists := state.Decisions[decisionID]; exists { + continue + } + supersedes := "" + for id, existing := range state.Decisions { + if existing.TenantID == job.TenantID && existing.FindingID == finding.ID && existing.SupersededBy == "" { + supersedes = existing.ID + existing.SupersededBy = decisionID + state.Decisions[id] = existing + } + } + state.Decisions[decisionID] = domain.VulnerabilityDecision{ + ID: decisionID, + TenantID: job.TenantID, + FindingID: finding.ID, + ScanID: scan.ID, + ReleaseID: scan.ReleaseID, + Vulnerability: finding.Vulnerability, + Component: finding.Component, + Status: statement.Status, + Justification: statement.Justification, + ImpactStatement: statement.ImpactStatement, + ActionStatement: statement.ActionStatement, + CustomerVisible: strings.TrimSpace(statement.ImpactStatement) != "", + Source: "vex", + EvidenceID: evidenceID, + EvidenceIDs: workerDecisionEvidenceIDs(evidenceID), + VEXDocumentID: vex.ID, + Supersedes: supersedes, + ApprovedBy: actorID, + SchemaVersion: domain.VulnerabilityDecisionVersion, + CreatedAt: now, + } + if supersedes != "" { + superseded++ + if _, err := app.AppendPersistedChainEntry(state, now, job.TenantID, "vulnerability_decision.superseded", "vulnerability_decision", supersedes, actorType, actorID, payloadHash, ""); err != nil { + return created, superseded, mappingFailures, errors.New("append replayed vex decision supersession audit entry") + } + } + if _, err := app.AppendPersistedChainEntry(state, now, job.TenantID, "vulnerability_decision.created", "vulnerability_finding", finding.ID, actorType, actorID, payloadHash, ""); err != nil { + return created, superseded, mappingFailures, errors.New("append replayed vex decision audit entry") + } + created++ + } + } + if !statementMatched { + mappingFailures = append(mappingFailures, domain.VEXImportIssue{ + StatementIndex: index + 1, + Code: "finding_not_found", + Detail: "No matching vulnerability scan finding was found for this VEX statement.", + }) + } + } + return created, superseded, mappingFailures, nil +} + +func replayedVEXDecisionExists(decisions map[string]domain.VulnerabilityDecision, tenantID, vexID, findingID string) bool { + for _, decision := range decisions { + if decision.TenantID == tenantID && decision.VEXDocumentID == vexID && decision.FindingID == findingID { + return true + } + } + return false +} + +func updateVEXImportReport(state *app.PersistedState, job postgres.ClaimedJob, vex domain.VEXDocument, parsed replayedVEX, created, superseded int, mappingFailures []domain.VEXImportIssue) bool { + reportID := payloadString(job, "import_report_id") + if reportID == "" { + for id, report := range state.VEXImportReports { + if report.TenantID == job.TenantID && report.VEXDocumentID == vex.ID { + reportID = id + break + } + } + } + if reportID == "" { + return false + } + if state.VEXImportReports == nil { + state.VEXImportReports = map[string]domain.VEXImportReport{} + } + report, ok := state.VEXImportReports[reportID] + if !ok || report.TenantID != job.TenantID || report.VEXDocumentID != vex.ID { + return false + } + updated := report + changed := false + if updated.Status != "parsed" { + updated.Status = "parsed" + changed = true + } + if updated.FailureCode != "" || updated.FailureDetail != "" { + updated.FailureCode = "" + updated.FailureDetail = "" + changed = true + } + if updated.StatementCount != parsed.StatementCount { + updated.StatementCount = parsed.StatementCount + changed = true + } + if created > updated.DecisionsCreated { + updated.DecisionsCreated = created + changed = true + } + if superseded > updated.DecisionsSuperseded { + updated.DecisionsSuperseded = superseded + changed = true + } + if !vexImportIssuesEqual(updated.MappingFailures, mappingFailures) { + updated.MappingFailures = append([]domain.VEXImportIssue(nil), mappingFailures...) + changed = true + } + if updated.SchemaVersion == "" { + updated.SchemaVersion = domain.VEXImportReportSchemaVersion + changed = true + } + if !changed { + return false + } + updated.UpdatedAt = time.Now().UTC() + state.VEXImportReports[reportID] = updated + return true +} + +func failVEXImportReportWithSnapshot(ctx context.Context, state jobStateLoader, snapshot *app.PersistedState, job postgres.ClaimedJob, vex domain.VEXDocument, cause error) error { + if updateVEXImportReportFailure(snapshot, job, vex, cause) { + if err := persistParserSideEffects(ctx, state, *snapshot, job.Kind); err != nil { + return err + } + } + return cause +} + +func recordVEXImportReportFailure(ctx context.Context, state jobStateLoader, job postgres.ClaimedJob, cause error) { + if job.Kind != "parse_vex" || state == nil { + return + } + snapshot, ok, err := state.LoadState(ctx) + if err != nil || !ok { + return + } + vex, ok := snapshot.VEXDocuments[job.SubjectID] + if !ok || vex.TenantID != job.TenantID { + return + } + if !updateVEXImportReportFailure(&snapshot, job, vex, cause) { + return + } + _ = persistParserSideEffects(ctx, state, snapshot, job.Kind) +} + +func updateVEXImportReportFailure(state *app.PersistedState, job postgres.ClaimedJob, vex domain.VEXDocument, cause error) bool { + reportID := payloadString(job, "import_report_id") + if reportID == "" { + for id, report := range state.VEXImportReports { + if report.TenantID == job.TenantID && report.VEXDocumentID == vex.ID { + reportID = id + break + } + } + } + if reportID == "" { + return false + } + if state.VEXImportReports == nil { + state.VEXImportReports = map[string]domain.VEXImportReport{} + } + report, ok := state.VEXImportReports[reportID] + if !ok || report.TenantID != job.TenantID || report.VEXDocumentID != vex.ID { + return false + } + code := safeVEXParserFailureCode(cause) + detail := safeVEXParserFailureDetail(code) + updated := report + changed := false + if updated.Status != "failed" { + updated.Status = "failed" + changed = true + } + if updated.FailureCode != code { + updated.FailureCode = code + changed = true + } + if updated.FailureDetail != detail { + updated.FailureDetail = detail + changed = true + } + if updated.SchemaVersion == "" { + updated.SchemaVersion = domain.VEXImportReportSchemaVersion + changed = true + } + if !changed { + return false + } + updated.UpdatedAt = time.Now().UTC() + state.VEXImportReports[reportID] = updated + return true +} + +func safeVEXParserFailureCode(cause error) string { + if cause == nil { + return "parser_failed" + } + message := cause.Error() + switch { + case strings.Contains(message, "read outbox payload object"): + return "payload_read_failed" + case strings.Contains(message, "tenant-prefixed"): + return "payload_ref_invalid" + case strings.Contains(message, "tenant mismatch"): + return "payload_tenant_mismatch" + case strings.Contains(message, "digest mismatch"), strings.Contains(message, "payload hash"): + return "payload_digest_mismatch" + case strings.Contains(message, "size limit"): + return "payload_too_large" + case strings.Contains(message, "unsupported outbox parser version"): + return "unsupported_parser_version" + case strings.Contains(message, "durable state"), strings.Contains(message, "not available"): + return "durable_state_mismatch" + case strings.Contains(message, "replayed vex payload is invalid"): + return "payload_invalid" + default: + return "parser_failed" + } +} + +func safeVEXParserFailureDetail(code string) string { + switch code { + case "payload_read_failed": + return "The worker could not read the referenced VEX payload object." + case "payload_ref_invalid": + return "The VEX payload reference was not tenant-prefixed." + case "payload_tenant_mismatch": + return "The VEX payload object tenant did not match the job tenant." + case "payload_digest_mismatch": + return "The VEX payload digest did not match the expected digest." + case "payload_too_large": + return "The VEX payload exceeded the worker replay size limit." + case "unsupported_parser_version": + return "The VEX parser version is not supported by this worker." + case "durable_state_mismatch": + return "The replayed VEX payload did not match durable state for the job." + case "payload_invalid": + return "The VEX payload could not be parsed as a supported VEX document." + default: + return "The worker could not parse or verify the VEX payload." + } +} + +func vexImportIssuesEqual(a, b []domain.VEXImportIssue) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +func replayedVEXDecisionID(vexID, findingID, status string) string { + sum := strings.TrimPrefix(digestBytes([]byte(vexID+"\n"+findingID+"\n"+status)), "sha256:") + if len(sum) > 32 { + sum = sum[:32] + } + return "vd_" + sum +} + +func workerDecisionEvidenceIDs(evidenceID string) []string { + evidenceID = strings.TrimSpace(evidenceID) + if evidenceID == "" { + return nil + } + return []string{evidenceID} +} + +func verifyReplayedAttestation(raw []byte, parsed replayedAttestation, attestation domain.BuildAttestation) error { + if attestation.PayloadHash != "" && digestBytes(raw) != attestation.PayloadHash { + return errors.New("replayed build attestation payload does not match durable state") + } + if len(attestation.SubjectDigests) != 0 && !equalStringSets(parsed.SubjectDigests, attestation.SubjectDigests) { + return errors.New("replayed build attestation payload does not match durable state") + } + if attestation.PredicateType != "" && parsed.PredicateType != attestation.PredicateType { + return errors.New("replayed build attestation payload does not match durable state") + } + return nil +} + +type replayedAttestation struct { + PredicateType string + SubjectDigests []string + PayloadType string + SignatureCount int + BuilderID string + BuildType string + MaterialsCount int +} + +func parseReplayedAttestation(raw []byte) (replayedAttestation, error) { + var envelope struct { + PayloadType string `json:"payloadType"` + Payload string `json:"payload"` + Signatures []struct { + KeyID string `json:"keyid,omitempty"` + Sig string `json:"sig"` + } `json:"signatures"` + } + if err := strictDecodeWorker(raw, &envelope); err != nil || strings.TrimSpace(envelope.PayloadType) == "" || strings.TrimSpace(envelope.Payload) == "" || len(envelope.Signatures) == 0 { + return replayedAttestation{}, errors.New("replayed build attestation payload is invalid") + } + for _, signature := range envelope.Signatures { + if strings.TrimSpace(signature.Sig) == "" { + return replayedAttestation{}, errors.New("replayed build attestation payload is invalid") + } + } + payload, err := base64.StdEncoding.DecodeString(envelope.Payload) + if err != nil { + return replayedAttestation{}, errors.New("replayed build attestation payload is invalid") + } + var statement struct { + Type string `json:"_type"` + PredicateType string `json:"predicateType"` + Subject []struct { + Name string `json:"name"` + Digest map[string]string `json:"digest"` + } `json:"subject"` + Predicate map[string]any `json:"predicate"` + } + if err := strictDecodeWorker(payload, &statement); err != nil || strings.TrimSpace(statement.Type) == "" || strings.TrimSpace(statement.PredicateType) == "" || len(statement.Subject) == 0 { + return replayedAttestation{}, errors.New("replayed build attestation payload is invalid") + } + digests := make([]string, 0, len(statement.Subject)) + for _, subject := range statement.Subject { + digest := "sha256:" + strings.ToLower(strings.TrimSpace(subject.Digest["sha256"])) + if !validWorkerDigest(digest) { + return replayedAttestation{}, errors.New("replayed build attestation payload is invalid") + } + digests = append(digests, digest) + } + sort.Strings(digests) + builderID, _ := nestedString(statement.Predicate, "builder", "id") + buildType, _ := statement.Predicate["buildType"].(string) + materialsCount := 0 + if materials, ok := statement.Predicate["materials"].([]any); ok { + materialsCount = len(materials) + } + return replayedAttestation{ + PayloadType: strings.TrimSpace(envelope.PayloadType), + PredicateType: strings.TrimSpace(statement.PredicateType), + SubjectDigests: digests, + SignatureCount: len(envelope.Signatures), + BuilderID: strings.TrimSpace(builderID), + BuildType: strings.TrimSpace(buildType), + MaterialsCount: materialsCount, + }, nil +} + +func mergeReplayedAttestation(attestation domain.BuildAttestation, parsed replayedAttestation, raw []byte) (domain.BuildAttestation, bool) { + changed := false + if attestation.PayloadHash == "" { + attestation.PayloadHash = digestBytes(raw) + changed = true + } + if attestation.PayloadSize == 0 { + attestation.PayloadSize = int64(len(raw)) + changed = true + } + if attestation.PayloadType == "" && parsed.PayloadType != "" { + attestation.PayloadType = parsed.PayloadType + changed = true + } + if attestation.PredicateType == "" && parsed.PredicateType != "" { + attestation.PredicateType = parsed.PredicateType + changed = true + } + if len(attestation.SubjectDigests) == 0 && len(parsed.SubjectDigests) != 0 { + attestation.SubjectDigests = append([]string(nil), parsed.SubjectDigests...) + changed = true + } + if attestation.SignatureCount == 0 && parsed.SignatureCount != 0 { + attestation.SignatureCount = parsed.SignatureCount + changed = true + } + if attestation.BuilderID == "" && parsed.BuilderID != "" { + attestation.BuilderID = parsed.BuilderID + changed = true + } + if attestation.BuildType == "" && parsed.BuildType != "" { + attestation.BuildType = parsed.BuildType + changed = true + } + if attestation.MaterialsCount == 0 && parsed.MaterialsCount != 0 { + attestation.MaterialsCount = parsed.MaterialsCount + changed = true + } + if attestation.VerificationStatus == "" || attestation.VerificationStatus == "accepted" { + attestation.VerificationStatus = "structurally_valid" + changed = true + } + return attestation, changed +} + +func operationForMethod(item *openapi3.PathItem, method string) *openapi3.Operation { + switch method { + case "get": + return item.Get + case "put": + return item.Put + case "post": + return item.Post + case "delete": + return item.Delete + case "options": + return item.Options + case "head": + return item.Head + case "patch": + return item.Patch + case "trace": + return item.Trace + default: + return nil + } +} + +func cloneIntMap(in map[string]int) map[string]int { + if in == nil { + return nil + } + out := make(map[string]int, len(in)) + for key, value := range in { + out[key] = value + } + return out +} + +func nonEmptyWorker(value, fallback string) string { + value = strings.TrimSpace(value) + if value == "" { + return fallback + } + return value +} + +func nestedString(in map[string]any, outer, inner string) (string, bool) { + rawOuter, ok := in[outer].(map[string]any) + if !ok { + return "", false + } + value, ok := rawOuter[inner].(string) + return value, ok +} + +func equalStringSets(a, b []string) bool { + if len(a) != len(b) { + return false + } + left := append([]string(nil), a...) + right := append([]string(nil), b...) + sort.Strings(left) + sort.Strings(right) + for i := range left { + if left[i] != right[i] { + return false + } + } + return true +} + +func strictDecodeWorker(raw []byte, out any) error { + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(out); err != nil { + return err + } + if err := dec.Decode(&struct{}{}); err != io.EOF { + return errors.New("trailing json") + } + return nil +} + +func validWorkerDigest(value string) bool { + if !strings.HasPrefix(value, "sha256:") || len(value) != len("sha256:")+64 { + return false + } + _, err := hex.DecodeString(strings.TrimPrefix(value, "sha256:")) + return err == nil } func envDefault(name, fallback string) string { @@ -112,3 +1408,35 @@ func intEnv(name string, fallback int) int { } return parsed } + +func digestBytes(body []byte) string { + sum := sha256.Sum256(body) + return "sha256:" + hex.EncodeToString(sum[:]) +} + +func openObjectStore(ctx context.Context) (app.ObjectStore, string, error) { + switch strings.ToLower(strings.TrimSpace(os.Getenv("EVYDENCE_OBJECT_STORE"))) { + case "", "file", "filesystem": + objectRoot := envDefault("EVYDENCE_OBJECT_DIR", filepath.Join("tmp", "objects")) + objectStore, err := filesystem.New(objectRoot) + if err != nil { + return nil, "", err + } + return objectStore, "filesystem root " + objectRoot, nil + case "s3", "minio": + objectStore, err := s3store.New(ctx, s3store.Config{ + Endpoint: os.Getenv("EVYDENCE_S3_ENDPOINT"), + AccessKeyID: os.Getenv("EVYDENCE_S3_ACCESS_KEY_ID"), + SecretAccessKey: os.Getenv("EVYDENCE_S3_SECRET_ACCESS_KEY"), + Bucket: os.Getenv("EVYDENCE_S3_BUCKET"), + Region: os.Getenv("EVYDENCE_S3_REGION"), + UseSSL: strings.EqualFold(os.Getenv("EVYDENCE_S3_USE_SSL"), "true"), + }) + if err != nil { + return nil, "", err + } + return objectStore, "S3-compatible bucket " + envDefault("EVYDENCE_S3_BUCKET", ""), nil + default: + return nil, "", errors.New("unsupported EVYDENCE_OBJECT_STORE") + } +} diff --git a/cmd/evydence-worker/main_test.go b/cmd/evydence-worker/main_test.go new file mode 100644 index 0000000..6c3d9bd --- /dev/null +++ b/cmd/evydence-worker/main_test.go @@ -0,0 +1,887 @@ +package main + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/getkin/kin-openapi/openapi3" + + "github.com/aatuh/evydence/internal/adapters/postgres" + "github.com/aatuh/evydence/internal/app" + "github.com/aatuh/evydence/internal/domain" +) + +type fakeStateLoader struct { + state app.PersistedState + ok bool + err error +} + +func (f fakeStateLoader) LoadState(context.Context) (app.PersistedState, bool, error) { + return f.state, f.ok, f.err +} + +type fakeStateStore struct { + state app.PersistedState + saved app.PersistedState + ok bool + err error +} + +func (f *fakeStateStore) LoadState(context.Context) (app.PersistedState, bool, error) { + return f.state, f.ok, f.err +} + +func (f *fakeStateStore) SaveState(_ context.Context, state app.PersistedState) error { + f.saved = state + return nil +} + +type fakeReleaseLedgerMutationStore struct { + state app.PersistedState + mutation app.ReleaseLedgerMutation + saveCalls int + ok bool + err error +} + +func (f *fakeReleaseLedgerMutationStore) LoadState(context.Context) (app.PersistedState, bool, error) { + return f.state, f.ok, f.err +} + +func (f *fakeReleaseLedgerMutationStore) ApplyReleaseLedgerMutation(_ context.Context, mutation app.ReleaseLedgerMutation) error { + f.mutation = mutation + return nil +} + +func (f *fakeReleaseLedgerMutationStore) SaveState(context.Context, app.PersistedState) error { + f.saveCalls++ + return nil +} + +type fakeObjectGetter struct { + object app.Object + err error + wantKey string +} + +func (f fakeObjectGetter) Get(_ context.Context, key string) (app.Object, error) { + if f.err != nil { + return app.Object{}, f.err + } + if f.wantKey != "" && key != f.wantKey { + return app.Object{}, errors.New("unexpected object key") + } + return f.object, nil +} + +func TestProcessJobVerifiesConfiguredJobState(t *testing.T) { + job := postgres.ClaimedJob{ + ID: "job_test", + TenantID: "ten_test", + Kind: "verify_subject", + SubjectType: "release_bundle", + SubjectID: "rb_test", + Payload: map[string]any{"result_id": "vr_test", "payload_ref": "object://tenants/ten_test/payloads/raw-secret-name"}, + } + state := app.PersistedState{Verifications: map[string]domain.VerificationResult{ + "vr_test": {ID: "vr_test", TenantID: "ten_test", SubjectType: "release_bundle", SubjectID: "rb_test", Result: "passed", VerifiedAt: time.Now().UTC()}, + }} + if err := processJob(context.Background(), fakeStateLoader{state: state, ok: true}, job); err != nil { + t.Fatalf("process configured verification job: %v", err) + } +} + +func TestProcessJobWithObjectsPersistsParserDerivedFields(t *testing.T) { + body := []byte(`{"bomFormat":"CycloneDX","specVersion":"1.6","components":[{"name":"api","version":"1.0.0","purl":"pkg:generic/api@1.0.0"}]}`) + hash := digestBytes(body) + job := postgres.ClaimedJob{ + TenantID: "ten_test", + Kind: "parse_sbom", + SubjectID: "sbom_test", + Payload: map[string]any{"payload_ref": "object://tenants/ten_test/payloads/sbom.json", "payload_hash": hash}, + } + store := &fakeStateStore{ + ok: true, + state: app.PersistedState{SBOMs: map[string]domain.SBOM{ + "sbom_test": {ID: "sbom_test", TenantID: "ten_test"}, + }}, + } + object := app.Object{Key: "tenants/ten_test/payloads/sbom.json", TenantID: "ten_test", Digest: hash, Bytes: body} + if err := processJobWithObjects(context.Background(), store, fakeObjectGetter{object: object}, job); err != nil { + t.Fatalf("process object-backed job: %v", err) + } + updated := store.saved.SBOMs["sbom_test"] + if updated.SpecVersion != "1.6" || updated.ComponentCount != 1 || len(updated.Components) != 1 || updated.Components[0].Name != "api" { + t.Fatalf("saved sbom = %#v", updated) + } +} + +func TestProcessJobWithObjectsUsesFocusedReleaseLedgerMutationForParserSideEffects(t *testing.T) { + body := []byte(`{"bomFormat":"CycloneDX","specVersion":"1.6","components":[{"name":"api","version":"1.0.0"}]}`) + hash := digestBytes(body) + job := postgres.ClaimedJob{ + TenantID: "ten_test", + Kind: "parse_sbom", + SubjectID: "sbom_test", + Payload: map[string]any{"payload_ref": "object://tenants/ten_test/payloads/sbom.json", "payload_hash": hash}, + } + store := &fakeReleaseLedgerMutationStore{ + ok: true, + state: app.PersistedState{SBOMs: map[string]domain.SBOM{ + "sbom_test": {ID: "sbom_test", TenantID: "ten_test"}, + }}, + } + object := app.Object{Key: "tenants/ten_test/payloads/sbom.json", TenantID: "ten_test", Digest: hash, Bytes: body} + if err := processJobWithObjects(context.Background(), store, fakeObjectGetter{object: object}, job); err != nil { + t.Fatalf("process object-backed job: %v", err) + } + if store.saveCalls != 0 || len(store.mutation.SBOMs) != 1 { + t.Fatalf("focused parser persistence save=%d mutation=%#v", store.saveCalls, store.mutation) + } + updated := store.mutation.SBOMs[0] + if updated.SpecVersion != "1.6" || updated.ComponentCount != 1 || len(updated.Components) != 1 { + t.Fatalf("focused sbom mutation = %#v", updated) + } +} + +func TestProcessJobWithObjectsRequiresWritableStateForParserSideEffects(t *testing.T) { + body := []byte(`{"bomFormat":"CycloneDX","specVersion":"1.6","components":[{"name":"api"}]}`) + hash := digestBytes(body) + job := postgres.ClaimedJob{ + TenantID: "ten_test", + Kind: "parse_sbom", + SubjectID: "sbom_test", + Payload: map[string]any{"payload_ref": "tenants/ten_test/payloads/sbom.json", "payload_hash": hash}, + } + state := app.PersistedState{SBOMs: map[string]domain.SBOM{ + "sbom_test": {ID: "sbom_test", TenantID: "ten_test"}, + }} + object := app.Object{Key: "tenants/ten_test/payloads/sbom.json", TenantID: "ten_test", Digest: hash, Bytes: body} + err := processJobWithObjects(context.Background(), fakeStateLoader{state: state, ok: true}, fakeObjectGetter{object: object}, job) + if err == nil || !strings.Contains(err.Error(), "writable state") { + t.Fatalf("err=%v", err) + } +} + +func TestProcessJobRejectsUnsupportedParserVersion(t *testing.T) { + job := postgres.ClaimedJob{ + TenantID: "ten_test", + Kind: "parse_sbom", + SubjectID: "sbom_test", + Payload: map[string]any{"parser_version": "cyclonedx-json.v0.0.1"}, + } + state := app.PersistedState{SBOMs: map[string]domain.SBOM{ + "sbom_test": {ID: "sbom_test", TenantID: "ten_test"}, + }} + err := processJob(context.Background(), fakeStateLoader{state: state, ok: true}, job) + if err == nil || !strings.Contains(err.Error(), "unsupported outbox parser version") { + t.Fatalf("err=%v", err) + } +} + +func TestProcessJobWithObjectsVerifiesTenantPrefixedPayload(t *testing.T) { + body := []byte(`{"bomFormat":"CycloneDX"}`) + hash := digestBytes(body) + job := postgres.ClaimedJob{ + ID: "job_test", + TenantID: "ten_test", + Kind: "parse_sbom", + SubjectID: "sbom_test", + Payload: map[string]any{"payload_ref": "object://tenants/ten_test/payloads/sbom.json", "payload_hash": hash}, + } + state := app.PersistedState{SBOMs: map[string]domain.SBOM{ + "sbom_test": {ID: "sbom_test", TenantID: "ten_test"}, + }} + object := app.Object{Key: "tenants/ten_test/payloads/sbom.json", TenantID: "ten_test", Digest: hash, Bytes: body} + if err := processJobWithObjects(context.Background(), fakeStateLoader{state: state, ok: true}, fakeObjectGetter{object: object, wantKey: "tenants/ten_test/payloads/sbom.json"}, job); err != nil { + t.Fatalf("process object-backed job: %v", err) + } +} + +func TestProcessJobWithObjectsParsesPayloadAndChecksDurableState(t *testing.T) { + now := time.Now().UTC() + attestationBody := dsseEnvelopeForTest(t, "sha256:"+strings.Repeat("a", 64)) + tests := []struct { + name string + body []byte + job postgres.ClaimedJob + state app.PersistedState + object app.Object + }{ + { + name: "sbom component count", + body: []byte(`{"bomFormat":"CycloneDX","specVersion":"1.6","components":[{"name":"api"},{"name":"worker"}]}`), + job: postgres.ClaimedJob{TenantID: "ten_test", Kind: "parse_sbom", SubjectID: "sbom_test"}, + state: app.PersistedState{SBOMs: map[string]domain.SBOM{ + "sbom_test": {ID: "sbom_test", TenantID: "ten_test", SpecVersion: "1.6", ComponentCount: 2}, + }}, + }, + { + name: "vulnerability scan summary", + body: []byte(`{"scanner":"grype","target_ref":"pkg:oci/api","release_id":"rel_test","findings":[{"vulnerability":"CVE-1","severity":"critical"},{"vulnerability":"CVE-2","severity":"high"}]}`), + job: postgres.ClaimedJob{TenantID: "ten_test", Kind: "parse_vulnerability_scan", SubjectID: "scan_test"}, + state: app.PersistedState{Scans: map[string]domain.VulnerabilityScan{ + "scan_test": {ID: "scan_test", TenantID: "ten_test", Scanner: "grype", TargetRef: "pkg:oci/api", Summary: map[string]int{"critical": 1, "high": 1}, Findings: []domain.VulnerabilityFinding{{ID: "vf_1"}, {ID: "vf_2"}}}, + }}, + }, + { + name: "openapi contract path count", + body: []byte(`{"openapi":"3.1.0","info":{"title":"API","version":"1"},"paths":{"/v1/a":{"get":{"responses":{"200":{"description":"ok"}}}}}}`), + job: postgres.ClaimedJob{TenantID: "ten_test", Kind: "parse_openapi_contract", SubjectID: "oas_test"}, + state: app.PersistedState{Contracts: map[string]domain.OpenAPIContract{ + "oas_test": {ID: "oas_test", TenantID: "ten_test", PathCount: 1}, + }}, + }, + { + name: "openvex statement count", + body: []byte(`{"@context":"https://openvex.dev/ns/v0.2.0","@id":"https://example.test/vex","author":"security@example.test","timestamp":"2026-05-28T12:00:00Z","version":1,"statements":[{"vulnerability":{"name":"CVE-1"},"products":[{"@id":"pkg:oci/api"}],"status":"fixed","justification":"fixed","impact_statement":"fixed","action_statement":"none"}]}`), + job: postgres.ClaimedJob{TenantID: "ten_test", Kind: "parse_vex", SubjectID: "vex_test"}, + state: app.PersistedState{VEXDocuments: map[string]domain.VEXDocument{ + "vex_test": {ID: "vex_test", TenantID: "ten_test", Format: "openvex", Author: "security@example.test", StatementCount: 1, StatusSummary: map[string]int{"fixed": 1}}, + }}, + }, + { + name: "dsse attestation subject digest", + body: attestationBody, + job: postgres.ClaimedJob{TenantID: "ten_test", Kind: "verify_attestation", SubjectID: "att_test"}, + state: app.PersistedState{BuildAttestations: map[string]domain.BuildAttestation{ + "att_test": {ID: "att_test", TenantID: "ten_test", PayloadHash: digestBytes(attestationBody), SubjectDigests: []string{"sha256:" + strings.Repeat("a", 64)}, VerificationStatus: "structurally_valid", CreatedAt: now}, + }}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + hash := digestBytes(tt.body) + tt.job.Payload = map[string]any{"payload_ref": "tenants/ten_test/payloads/replay.json", "payload_hash": hash} + tt.object = app.Object{Key: "tenants/ten_test/payloads/replay.json", TenantID: "ten_test", Digest: hash, Bytes: tt.body} + if tt.job.Kind == "parse_openapi_contract" { + contract := tt.state.Contracts[tt.job.SubjectID] + contract.Hash = hash + tt.state.Contracts[tt.job.SubjectID] = contract + } + store := &fakeStateStore{state: tt.state, ok: true} + if err := processJobWithObjects(context.Background(), store, fakeObjectGetter{object: tt.object}, tt.job); err != nil { + t.Fatalf("process replay payload: %v", err) + } + }) + } +} + +func TestProcessJobWithObjectsCreatesVEXDecisionsIdempotently(t *testing.T) { + now := time.Date(2026, 5, 28, 12, 0, 0, 0, time.UTC) + body := []byte(`{"@context":"https://openvex.dev/ns/v0.2.0","@id":"https://example.test/vex","author":"security@example.test","timestamp":"2026-05-28T12:00:00Z","version":1,"statements":[{"vulnerability":{"name":"CVE-1"},"products":[{"@id":"pkg:oci/api"}],"status":"fixed","justification":"fixed","impact_statement":"patched","action_statement":"ship"}]}`) + hash := digestBytes(body) + job := postgres.ClaimedJob{ + ID: "job_vex", + TenantID: "ten_test", + Kind: "parse_vex", + SubjectID: "vex_test", + Payload: map[string]any{ + "payload_ref": "object://tenants/ten_test/payloads/vex.json", + "payload_hash": hash, + "parser_version": app.ParserVersionOpenVEXJSON, + "worker_create_decisions": true, + "actor_type": "api_key", + "actor_id": "key_test", + "evidence_id": "ev_vex", + "import_report_id": "vex_report", + }, + } + store := &fakeStateStore{ok: true, state: app.PersistedState{ + VEXDocuments: map[string]domain.VEXDocument{ + "vex_test": {ID: "vex_test", TenantID: "ten_test", ReleaseID: "rel_test", EvidenceID: "ev_vex", Format: "openvex"}, + }, + VEXImportReports: map[string]domain.VEXImportReport{ + "vex_report": {ID: "vex_report", TenantID: "ten_test", VEXDocumentID: "vex_test", EvidenceID: "ev_vex", Status: "accepted", SchemaVersion: domain.VEXImportReportSchemaVersion, CreatedAt: now, UpdatedAt: now}, + }, + Scans: map[string]domain.VulnerabilityScan{ + "scan_test": {ID: "scan_test", TenantID: "ten_test", ReleaseID: "rel_test", Findings: []domain.VulnerabilityFinding{{ID: "finding_1", Vulnerability: "CVE-1", Component: "pkg:oci/api", Severity: "critical", State: "open"}}}, + }, + Decisions: map[string]domain.VulnerabilityDecision{}, + Chain: map[string][]domain.AuditChainEntry{}, + }} + object := app.Object{Key: "tenants/ten_test/payloads/vex.json", TenantID: "ten_test", Digest: hash, Bytes: body} + if err := processJobWithObjects(context.Background(), store, fakeObjectGetter{object: object}, job); err != nil { + t.Fatalf("process vex job: %v", err) + } + if len(store.saved.Decisions) != 1 { + t.Fatalf("decisions = %#v, want one", store.saved.Decisions) + } + for _, decision := range store.saved.Decisions { + if decision.FindingID != "finding_1" || decision.Status != "fixed" || decision.VEXDocumentID != "vex_test" || decision.ApprovedBy != "key_test" { + t.Fatalf("decision = %#v", decision) + } + } + if got := len(store.saved.Chain["ten_test"]); got != 1 { + t.Fatalf("chain entries = %d, want 1", got) + } + report := store.saved.VEXImportReports["vex_report"] + if report.Status != "parsed" || report.StatementCount != 1 || report.DecisionsCreated != 1 || report.DecisionsSuperseded != 0 { + t.Fatalf("import report = %#v", report) + } + + previous := store.saved + store.state = previous + store.saved = app.PersistedState{} + if err := processJobWithObjects(context.Background(), store, fakeObjectGetter{object: object}, job); err != nil { + t.Fatalf("reprocess vex job: %v", err) + } + if len(store.saved.Decisions) != 0 || len(previous.Decisions) != 1 || len(previous.Chain["ten_test"]) != 1 { + t.Fatalf("replay duplicated side effects: saved=%#v previous=%#v", store.saved.Decisions, previous.Decisions) + } +} + +func TestProcessJobWithObjectsRecordsVEXImportReportFailure(t *testing.T) { + now := time.Date(2026, 5, 28, 12, 0, 0, 0, time.UTC) + validBody := []byte(`{"@context":"https://openvex.dev/ns/v0.2.0","@id":"https://example.test/vex","author":"security@example.test","timestamp":"2026-05-28T12:00:00Z","version":1,"statements":[{"vulnerability":{"name":"CVE-1"},"products":[{"@id":"pkg:oci/api"}],"status":"fixed","justification":"fixed"}]}`) + baseJob := postgres.ClaimedJob{ + ID: "job_vex", + TenantID: "ten_test", + Kind: "parse_vex", + SubjectID: "vex_test", + Payload: map[string]any{ + "payload_ref": "object://tenants/ten_test/payloads/vex.json", + "payload_hash": digestBytes(validBody), + "parser_version": app.ParserVersionOpenVEXJSON, + "import_report_id": "vex_report", + }, + } + baseState := func() app.PersistedState { + return app.PersistedState{ + VEXDocuments: map[string]domain.VEXDocument{ + "vex_test": {ID: "vex_test", TenantID: "ten_test", ReleaseID: "rel_test", EvidenceID: "ev_vex", Format: "openvex"}, + }, + VEXImportReports: map[string]domain.VEXImportReport{ + "vex_report": {ID: "vex_report", TenantID: "ten_test", VEXDocumentID: "vex_test", EvidenceID: "ev_vex", Status: "accepted", SchemaVersion: domain.VEXImportReportSchemaVersion, CreatedAt: now, UpdatedAt: now}, + }, + } + } + tests := []struct { + name string + object fakeObjectGetter + payloadHash string + want string + noLeak string + wantErr string + }{ + { + name: "malformed payload", + object: fakeObjectGetter{object: app.Object{Key: "tenants/ten_test/payloads/vex.json", TenantID: "ten_test", Digest: digestBytes([]byte(`{"not":"vex"}`)), Bytes: []byte(`{"not":"vex"}`)}}, + payloadHash: digestBytes([]byte(`{"not":"vex"}`)), + want: "payload_invalid", + noLeak: `{"not":"vex"}`, + wantErr: "replayed vex payload is invalid", + }, + { + name: "missing object", + object: fakeObjectGetter{err: errors.New("backend leaked secret")}, + want: "payload_read_failed", + noLeak: "backend leaked secret", + wantErr: "read outbox payload object", + }, + { + name: "digest mismatch", + object: fakeObjectGetter{object: app.Object{Key: "tenants/ten_test/payloads/vex.json", TenantID: "ten_test", Digest: digestBytes([]byte("other")), Bytes: []byte("other")}}, + want: "payload_digest_mismatch", + noLeak: "other", + wantErr: "metadata digest mismatch", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + store := &fakeStateStore{ok: true, state: baseState()} + job := baseJob + if tt.payloadHash != "" { + payload := map[string]any{} + for key, value := range baseJob.Payload { + payload[key] = value + } + payload["payload_hash"] = tt.payloadHash + job.Payload = payload + } + err := processJobWithObjects(context.Background(), store, tt.object, job) + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("err=%v want %q", err, tt.wantErr) + } + report := store.saved.VEXImportReports["vex_report"] + if report.Status != "failed" || report.FailureCode != tt.want || report.FailureDetail == "" { + t.Fatalf("saved report = %#v", report) + } + text := report.FailureDetail + strings.Join(report.Warnings, "\n") + if strings.Contains(text, tt.noLeak) || strings.Contains(text, "vex.json") { + t.Fatalf("failure report leaked unsafe details: %#v", report) + } + }) + } +} + +func TestProcessJobWithObjectsRecordsVEXMappingFailure(t *testing.T) { + now := time.Date(2026, 5, 28, 12, 0, 0, 0, time.UTC) + body := []byte(`{"@context":"https://openvex.dev/ns/v0.2.0","@id":"https://example.test/vex","author":"security@example.test","timestamp":"2026-05-28T12:00:00Z","version":1,"statements":[{"vulnerability":{"name":"CVE-missing"},"products":[{"@id":"pkg:oci/api"}],"status":"fixed","justification":"fixed"}]}`) + hash := digestBytes(body) + job := postgres.ClaimedJob{ + ID: "job_vex", + TenantID: "ten_test", + Kind: "parse_vex", + SubjectID: "vex_test", + Payload: map[string]any{ + "payload_ref": "object://tenants/ten_test/payloads/vex.json", + "payload_hash": hash, + "parser_version": app.ParserVersionOpenVEXJSON, + "worker_create_decisions": true, + "actor_type": "api_key", + "actor_id": "key_test", + "evidence_id": "ev_vex", + "import_report_id": "vex_report", + }, + } + store := &fakeStateStore{ok: true, state: app.PersistedState{ + VEXDocuments: map[string]domain.VEXDocument{ + "vex_test": {ID: "vex_test", TenantID: "ten_test", ReleaseID: "rel_test", EvidenceID: "ev_vex", Format: "openvex"}, + }, + VEXImportReports: map[string]domain.VEXImportReport{ + "vex_report": {ID: "vex_report", TenantID: "ten_test", VEXDocumentID: "vex_test", EvidenceID: "ev_vex", Status: "accepted", SchemaVersion: domain.VEXImportReportSchemaVersion, CreatedAt: now, UpdatedAt: now}, + }, + Scans: map[string]domain.VulnerabilityScan{}, + Decisions: map[string]domain.VulnerabilityDecision{}, + Chain: map[string][]domain.AuditChainEntry{}, + }} + object := app.Object{Key: "tenants/ten_test/payloads/vex.json", TenantID: "ten_test", Digest: hash, Bytes: body} + if err := processJobWithObjects(context.Background(), store, fakeObjectGetter{object: object}, job); err != nil { + t.Fatalf("process vex job: %v", err) + } + report := store.saved.VEXImportReports["vex_report"] + if report.Status != "parsed" || report.DecisionsCreated != 0 || len(report.MappingFailures) != 1 || report.MappingFailures[0].Code != "finding_not_found" { + t.Fatalf("report = %#v", report) + } +} + +func TestParseReplayedVEXSupportsCycloneDX(t *testing.T) { + body := []byte(`{"bomFormat":"CycloneDX","specVersion":"1.6","vulnerabilities":[{"id":"CVE-2026-2001","affects":[{"ref":"pkg:oci/api"}],"analysis":{"state":"resolved","justification":"code_not_reachable","detail":"patched in release artifact","response":["update"]}},{"id":"CVE-2026-2999","analysis":{"state":"unknown"}}]}`) + parsed, err := parseReplayedVEX(body) + if err != nil { + t.Fatalf("parse cyclonedx vex replay: %v", err) + } + if parsed.Author != "cyclonedx" || parsed.StatementCount != 2 || parsed.StatusSummary["fixed"] != 1 { + t.Fatalf("parsed cyclonedx vex summary = %#v", parsed) + } + if len(parsed.Statements) != 1 { + t.Fatalf("statements = %#v, want one valid statement", parsed.Statements) + } + statement := parsed.Statements[0] + if statement.Vulnerability != "CVE-2026-2001" || statement.Status != "fixed" || statement.Justification != "code_not_reachable" || statement.ImpactStatement != "patched in release artifact" || statement.ActionStatement != "update" { + t.Fatalf("statement = %#v", statement) + } + if _, ok := statement.Products["pkg:oci/api"]; !ok { + t.Fatalf("products = %#v, want affected ref", statement.Products) + } +} + +func TestProcessJobWithObjectsFailsSafelyForParserMismatches(t *testing.T) { + body := []byte(`{"bomFormat":"CycloneDX","specVersion":"1.6","components":[{"name":"api"},{"name":"worker"}]}`) + hash := digestBytes(body) + job := postgres.ClaimedJob{ + TenantID: "ten_test", + Kind: "parse_sbom", + SubjectID: "sbom_test", + Payload: map[string]any{"payload_ref": "tenants/ten_test/payloads/raw-secret-name", "payload_hash": hash}, + } + state := app.PersistedState{SBOMs: map[string]domain.SBOM{ + "sbom_test": {ID: "sbom_test", TenantID: "ten_test", ComponentCount: 1}, + }} + object := app.Object{Key: "tenants/ten_test/payloads/raw-secret-name", TenantID: "ten_test", Digest: hash, Bytes: body} + err := processJobWithObjects(context.Background(), fakeStateLoader{state: state, ok: true}, fakeObjectGetter{object: object}, job) + if err == nil || !strings.Contains(err.Error(), "replayed sbom payload does not match durable state") { + t.Fatalf("err=%v", err) + } + if strings.Contains(err.Error(), "raw-secret-name") || strings.Contains(err.Error(), string(body)) { + t.Fatalf("error leaked payload details: %v", err) + } +} + +func TestProcessJobWithObjectsFailsSafelyForPayloadProblems(t *testing.T) { + state := app.PersistedState{SBOMs: map[string]domain.SBOM{ + "sbom_test": {ID: "sbom_test", TenantID: "ten_test"}, + }} + base := postgres.ClaimedJob{ + ID: "job_test", + TenantID: "ten_test", + Kind: "parse_sbom", + SubjectID: "sbom_test", + Payload: map[string]any{"payload_ref": "tenants/ten_test/payloads/raw-secret-name", "payload_hash": digestBytes([]byte("ok"))}, + } + tests := []struct { + name string + job postgres.ClaimedJob + objects jobObjectGetter + want string + }{ + { + name: "wrong tenant prefix", + job: postgres.ClaimedJob{TenantID: "ten_test", Kind: "parse_sbom", SubjectID: "sbom_test", Payload: map[string]any{"payload_ref": "tenants/other/payloads/raw-secret-name"}}, + objects: fakeObjectGetter{}, + want: "tenant-prefixed", + }, + { + name: "missing object store", + job: base, + want: "object store is not configured", + }, + { + name: "object read failure", + job: base, + objects: fakeObjectGetter{err: errors.New("backend leaked secret")}, + want: "read outbox payload object", + }, + { + name: "object tenant mismatch", + job: base, + objects: fakeObjectGetter{object: app.Object{TenantID: "other", Bytes: []byte("ok"), Digest: digestBytes([]byte("ok"))}}, + want: "tenant mismatch", + }, + { + name: "metadata digest mismatch", + job: base, + objects: fakeObjectGetter{object: app.Object{TenantID: "ten_test", Bytes: []byte("ok"), Digest: digestBytes([]byte("other"))}}, + want: "metadata digest mismatch", + }, + { + name: "byte digest mismatch", + job: base, + objects: fakeObjectGetter{object: app.Object{TenantID: "ten_test", Bytes: []byte("other")}}, + want: "digest mismatch", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := processJobWithObjects(context.Background(), fakeStateLoader{state: state, ok: true}, tt.objects, tt.job) + if err == nil || !strings.Contains(err.Error(), tt.want) { + t.Fatalf("err=%v want %q", err, tt.want) + } + if strings.Contains(err.Error(), "raw-secret-name") || strings.Contains(err.Error(), "backend leaked secret") { + t.Fatalf("error leaked payload details: %v", err) + } + }) + } +} + +func TestProcessJobWithObjectsRejectsOversizedPayload(t *testing.T) { + t.Setenv("EVYDENCE_WORKER_MAX_PAYLOAD_BYTES", "2") + body := []byte("large") + hash := digestBytes(body) + state := app.PersistedState{SBOMs: map[string]domain.SBOM{ + "sbom_test": {ID: "sbom_test", TenantID: "ten_test"}, + }} + job := postgres.ClaimedJob{ + TenantID: "ten_test", + Kind: "parse_sbom", + SubjectID: "sbom_test", + Payload: map[string]any{"payload_ref": "tenants/ten_test/payloads/sbom.json", "payload_hash": hash}, + } + object := app.Object{TenantID: "ten_test", Digest: hash, Bytes: body} + err := processJobWithObjects(context.Background(), fakeStateLoader{state: state, ok: true}, fakeObjectGetter{object: object}, job) + if err == nil || !strings.Contains(err.Error(), "size limit") { + t.Fatalf("err=%v", err) + } +} + +func TestProcessJobFailsSafelyWhenDurableStateIsMissing(t *testing.T) { + job := postgres.ClaimedJob{ + ID: "job_test", + TenantID: "ten_test", + Kind: "parse_sbom", + SubjectType: "sbom", + SubjectID: "sbom_test", + Payload: map[string]any{"payload_ref": "object://tenants/ten_test/payloads/sbom/raw-secret-name"}, + } + err := processJob(context.Background(), fakeStateLoader{state: app.PersistedState{}, ok: true}, job) + if err == nil { + t.Fatal("expected recognized unhandled job to fail closed") + } + if !strings.Contains(err.Error(), "parsed sbom is not available") { + t.Fatalf("unexpected error: %v", err) + } + if strings.Contains(err.Error(), "raw-secret-name") || strings.Contains(err.Error(), job.SubjectID) { + t.Fatalf("job error leaked payload or subject: %v", err) + } +} + +func TestProcessJobRejectsUnsupportedKinds(t *testing.T) { + err := processJob(context.Background(), fakeStateLoader{state: app.PersistedState{}, ok: true}, postgres.ClaimedJob{Kind: "unknown", Payload: map[string]any{"token": "secret"}}) + if err == nil { + t.Fatal("expected unsupported job kind to fail") + } + if !strings.Contains(err.Error(), "unsupported outbox job kind") || strings.Contains(err.Error(), "secret") { + t.Fatalf("unexpected unsupported job error: %v", err) + } +} + +func TestProcessJobRecognizedKindsUseDurableTenantScopedState(t *testing.T) { + now := time.Now().UTC() + state := app.PersistedState{ + SBOMs: map[string]domain.SBOM{ + "sbom_1": {ID: "sbom_1", TenantID: "ten_1"}, + }, + Scans: map[string]domain.VulnerabilityScan{ + "scan_1": {ID: "scan_1", TenantID: "ten_1"}, + }, + Contracts: map[string]domain.OpenAPIContract{ + "contract_1": {ID: "contract_1", TenantID: "ten_1", Hash: "sha256:contract"}, + }, + VEXDocuments: map[string]domain.VEXDocument{ + "vex_1": {ID: "vex_1", TenantID: "ten_1"}, + }, + Bundles: map[string]domain.ReleaseBundle{ + "bundle_1": {ID: "bundle_1", TenantID: "ten_1", ManifestHash: "sha256:bundle", SignatureRefs: []string{"sig_1"}}, + }, + BuildAttestations: map[string]domain.BuildAttestation{ + "att_1": {ID: "att_1", TenantID: "ten_1", PayloadHash: "sha256:att", VerificationStatus: "structurally_valid", CreatedAt: now}, + }, + } + tests := []postgres.ClaimedJob{ + {TenantID: "ten_1", Kind: "parse_sbom", SubjectID: "sbom_1"}, + {TenantID: "ten_1", Kind: "parse_vulnerability_scan", SubjectID: "scan_1"}, + {TenantID: "ten_1", Kind: "parse_openapi_contract", SubjectID: "contract_1", Payload: map[string]any{"payload_hash": "sha256:contract"}}, + {TenantID: "ten_1", Kind: "parse_vex", SubjectID: "vex_1"}, + {TenantID: "ten_1", Kind: "sign_bundle", SubjectID: "bundle_1", Payload: map[string]any{"payload_hash": "sha256:bundle"}}, + {TenantID: "ten_1", Kind: "verify_attestation", SubjectID: "att_1", Payload: map[string]any{"payload_hash": "sha256:att"}}, + } + for _, job := range tests { + t.Run(job.Kind, func(t *testing.T) { + if err := processJob(context.Background(), fakeStateLoader{state: state, ok: true}, job); err != nil { + t.Fatalf("process %s: %v", job.Kind, err) + } + }) + } +} + +func TestProcessJobFailsClosedForStateLoadAndTenantMismatches(t *testing.T) { + if err := processJob(context.Background(), nil, postgres.ClaimedJob{}); err == nil || !strings.Contains(err.Error(), "requires durable state") { + t.Fatalf("nil state err=%v", err) + } + if err := processJob(context.Background(), fakeStateLoader{err: errors.New("database secret")}, postgres.ClaimedJob{}); err == nil || err.Error() != "load durable state for outbox job" { + t.Fatalf("state load err=%v", err) + } + if err := processJob(context.Background(), fakeStateLoader{ok: false}, postgres.ClaimedJob{}); err == nil || !strings.Contains(err.Error(), "not initialized") { + t.Fatalf("missing state err=%v", err) + } + state := app.PersistedState{Contracts: map[string]domain.OpenAPIContract{ + "contract_1": {ID: "contract_1", TenantID: "other", Hash: "sha256:contract"}, + }} + err := processJob(context.Background(), fakeStateLoader{state: state, ok: true}, postgres.ClaimedJob{TenantID: "ten_1", Kind: "parse_openapi_contract", SubjectID: "contract_1"}) + if err == nil || !strings.Contains(err.Error(), "parsed openapi contract is not available") || strings.Contains(err.Error(), "contract_1") { + t.Fatalf("tenant mismatch err=%v", err) + } +} + +func TestWorkerHelpersValidatePayloadHashAndEnv(t *testing.T) { + job := postgres.ClaimedJob{Payload: map[string]any{"payload_hash": " sha256:abc ", "ignored": 12}} + if got := payloadString(job, "payload_hash"); got != "sha256:abc" { + t.Fatalf("payloadString = %q", got) + } + if err := requirePayloadHash(job, "sha256:abc"); err != nil { + t.Fatalf("matching payload hash: %v", err) + } + if err := requirePayloadHash(job, "sha256:def"); err == nil || !strings.Contains(err.Error(), "payload hash") { + t.Fatalf("mismatch err=%v", err) + } + if err := requirePayloadHash(postgres.ClaimedJob{}, "sha256:def"); err != nil { + t.Fatalf("missing wanted hash should be ignored: %v", err) + } + + t.Setenv("EVYDENCE_WORKER_TEST_ENV", " value ") + if got := envDefault("EVYDENCE_WORKER_TEST_ENV", "fallback"); got != "value" { + t.Fatalf("envDefault configured = %q", got) + } + if got := envDefault("EVYDENCE_WORKER_MISSING_ENV", "fallback"); got != "fallback" { + t.Fatalf("envDefault fallback = %q", got) + } + t.Setenv("EVYDENCE_WORKER_TEST_DURATION", "250ms") + if got := durationEnv("EVYDENCE_WORKER_TEST_DURATION", time.Second); got != 250*time.Millisecond { + t.Fatalf("durationEnv configured = %s", got) + } + t.Setenv("EVYDENCE_WORKER_TEST_DURATION", "-1s") + if got := durationEnv("EVYDENCE_WORKER_TEST_DURATION", time.Second); got != time.Second { + t.Fatalf("durationEnv fallback = %s", got) + } + t.Setenv("EVYDENCE_WORKER_TEST_INT", "7") + if got := intEnv("EVYDENCE_WORKER_TEST_INT", 10); got != 7 { + t.Fatalf("intEnv configured = %d", got) + } + t.Setenv("EVYDENCE_WORKER_TEST_INT", "0") + if got := intEnv("EVYDENCE_WORKER_TEST_INT", 10); got != 10 { + t.Fatalf("intEnv fallback = %d", got) + } +} + +func TestReplayMergeHelpersCoverParserSideEffects(t *testing.T) { + sbom, changed := mergeReplayedSBOM(domain.SBOM{}, replayedSBOM{ + SpecVersion: "1.6", + ComponentCount: 1, + Components: []domain.SBOMComponent{{Name: "api"}}, + }) + if !changed || sbom.SpecVersion != "1.6" || sbom.ComponentCount != 1 || len(sbom.Components) != 1 { + t.Fatalf("merged sbom = %#v changed=%v", sbom, changed) + } + if _, changed := mergeReplayedSBOM(sbom, replayedSBOM{SpecVersion: "1.6"}); changed { + t.Fatal("complete sbom should not be changed") + } + + scan, changed := mergeReplayedVulnerabilityScan(domain.VulnerabilityScan{}, replayedVulnerabilityScan{ + Scanner: "grype", + TargetRef: "pkg:oci/api", + Summary: map[string]int{"high": 1}, + Findings: []domain.VulnerabilityFinding{{ID: "finding_1", Severity: "high"}}, + }) + if !changed || scan.Scanner != "grype" || scan.TargetRef == "" || scan.Summary["high"] != 1 || len(scan.Findings) != 1 { + t.Fatalf("merged scan = %#v changed=%v", scan, changed) + } + scan.Summary["high"] = 2 + if err := verifyReplayedVulnerabilityScan(replayedVulnerabilityScan{Scanner: "grype", TargetRef: "pkg:oci/api", Summary: map[string]int{"high": 1}}, scan); err == nil { + t.Fatal("expected vulnerability scan summary mismatch") + } + + vex, changed := mergeReplayedVEX(domain.VEXDocument{}, replayedVEX{Author: "security@example.test", StatementCount: 1, StatusSummary: map[string]int{"fixed": 1}}) + if !changed || vex.Author == "" || vex.StatementCount != 1 || vex.StatusSummary["fixed"] != 1 { + t.Fatalf("merged vex = %#v changed=%v", vex, changed) + } + if err := verifyReplayedVEX(replayedVEX{Author: "other", StatementCount: 1, StatusSummary: map[string]int{"fixed": 1}}, vex); err == nil { + t.Fatal("expected vex author mismatch") + } + + contract, changed := mergeReplayedOpenAPIContract(domain.OpenAPIContract{}, replayedOpenAPIContract{ + PathCount: 1, + Operations: []domain.OpenAPIOperation{{Path: "/v1/test", Method: "get"}}, + }) + if !changed || contract.PathCount != 1 || len(contract.Operations) != 1 { + t.Fatalf("merged contract = %#v changed=%v", contract, changed) + } + if err := verifyReplayedOpenAPIContract([]byte("raw"), replayedOpenAPIContract{PathCount: 2}, contract); err == nil { + t.Fatal("expected openapi path-count mismatch") + } + + raw := dsseEnvelopeForTest(t, "sha256:"+strings.Repeat("a", 64)) + attestation, changed := mergeReplayedAttestation(domain.BuildAttestation{}, replayedAttestation{ + PayloadType: "application/vnd.in-toto+json", + PredicateType: "https://slsa.dev/provenance/v1", + SubjectDigests: []string{"sha256:" + strings.Repeat("a", 64)}, + SignatureCount: 1, + BuilderID: "github-actions", + BuildType: "test", + MaterialsCount: 2, + }, raw) + if !changed || attestation.PayloadHash == "" || attestation.PayloadSize == 0 || attestation.PredicateType == "" || len(attestation.SubjectDigests) != 1 || attestation.SignatureCount != 1 || attestation.BuilderID == "" || attestation.BuildType == "" || attestation.MaterialsCount != 2 || attestation.VerificationStatus != "structurally_valid" { + t.Fatalf("merged attestation = %#v changed=%v", attestation, changed) + } + accepted, changed := mergeReplayedAttestation(domain.BuildAttestation{VerificationStatus: "accepted"}, replayedAttestation{}, raw) + if !changed || accepted.VerificationStatus != "structurally_valid" { + t.Fatalf("accepted attestation status was not completed: %#v changed=%v", accepted, changed) + } + if err := verifyReplayedAttestation(raw, replayedAttestation{PredicateType: "other", SubjectDigests: attestation.SubjectDigests}, attestation); err == nil { + t.Fatal("expected attestation predicate mismatch") + } + + if operationForMethod(&openapi3.PathItem{Get: &openapi3.Operation{}}, "get") == nil || operationForMethod(&openapi3.PathItem{Trace: &openapi3.Operation{}}, "trace") == nil || operationForMethod(&openapi3.PathItem{}, "unknown") != nil { + t.Fatal("operationForMethod did not route methods as expected") + } + if cloned := cloneIntMap(map[string]int{"high": 1}); cloned["high"] != 1 { + t.Fatalf("cloneIntMap = %#v", cloned) + } + if cloneIntMap(nil) != nil { + t.Fatal("nil cloneIntMap should stay nil") + } + if got, ok := nestedString(map[string]any{"outer": map[string]any{"inner": "value"}}, "outer", "inner"); !ok || got != "value" { + t.Fatalf("nestedString = %q %v", got, ok) + } + if equalStringSets([]string{"b", "a"}, []string{"a", "b"}) != true || equalStringSets([]string{"a"}, []string{"b"}) != false { + t.Fatal("equalStringSets mismatch") + } +} + +func TestRunRequiresDatabaseURLAndWrapsOpenFailure(t *testing.T) { + if err := runWithArgs([]string{"healthcheck"}); err != nil { + t.Fatalf("healthcheck: %v", err) + } + if err := runWithArgs([]string{"--healthcheck"}); err != nil { + t.Fatalf("--healthcheck: %v", err) + } + if err := runWithArgs([]string{"unexpected"}); err == nil || !strings.Contains(err.Error(), "unsupported worker command") { + t.Fatalf("unexpected command err=%v", err) + } + t.Setenv("EVYDENCE_DATABASE_URL", "") + err := run() + if err == nil || !strings.Contains(err.Error(), "EVYDENCE_DATABASE_URL") { + t.Fatalf("missing database err=%v", err) + } + t.Setenv("EVYDENCE_DATABASE_URL", "postgres://invalid-host.invalid/evydence") + t.Setenv("EVYDENCE_SKIP_MIGRATIONS", "true") + if err := run(); err == nil { + t.Fatal("expected postgres open failure") + } +} + +func TestOpenObjectStoreSelectsFilesystemAndRejectsUnsupportedBackend(t *testing.T) { + root := t.TempDir() + t.Setenv("EVYDENCE_OBJECT_STORE", "filesystem") + t.Setenv("EVYDENCE_OBJECT_DIR", filepath.Join(root, "objects")) + store, description, err := openObjectStore(context.Background()) + if err != nil { + t.Fatalf("open filesystem object store: %v", err) + } + if store == nil || !strings.Contains(description, "filesystem root") || !strings.Contains(description, "objects") { + t.Fatalf("filesystem object store description=%q store=%T", description, store) + } + + t.Setenv("EVYDENCE_OBJECT_STORE", "memory") + if store, description, err := openObjectStore(context.Background()); err == nil || store != nil || description != "" || !strings.Contains(err.Error(), "unsupported") { + t.Fatalf("unsupported backend store=%T description=%q err=%v", store, description, err) + } +} + +func TestOpenObjectStoreRejectsIncompleteS3ConfigurationWithoutSecretsInError(t *testing.T) { + t.Setenv("EVYDENCE_OBJECT_STORE", "s3") + t.Setenv("EVYDENCE_S3_ENDPOINT", "") + t.Setenv("EVYDENCE_S3_ACCESS_KEY_ID", "access-key") + t.Setenv("EVYDENCE_S3_SECRET_ACCESS_KEY", "super-secret") + t.Setenv("EVYDENCE_S3_BUCKET", "") + _, _, err := openObjectStore(context.Background()) + if err == nil { + t.Fatal("expected incomplete S3 configuration to fail") + } + if strings.Contains(err.Error(), "super-secret") || strings.Contains(err.Error(), os.Getenv("EVYDENCE_S3_ACCESS_KEY_ID")) { + t.Fatalf("S3 configuration error leaked credential material: %v", err) + } +} + +func dsseEnvelopeForTest(t *testing.T, digest string) []byte { + t.Helper() + statement, err := json.Marshal(map[string]any{ + "_type": "https://in-toto.io/Statement/v1", + "predicateType": "https://slsa.dev/provenance/v1", + "subject": []map[string]any{{ + "name": "api", + "digest": map[string]string{"sha256": strings.TrimPrefix(digest, "sha256:")}, + }}, + "predicate": map[string]any{"builder": map[string]string{"id": "github-actions"}, "buildType": "test", "materials": []any{}}, + }) + if err != nil { + t.Fatal(err) + } + envelope, err := json.Marshal(map[string]any{ + "payloadType": "application/vnd.in-toto+json", + "payload": base64.StdEncoding.EncodeToString(statement), + "signatures": []map[string]string{{"sig": "abc"}}, + }) + if err != nil { + t.Fatal(err) + } + return envelope +} diff --git a/cmd/evydence/main.go b/cmd/evydence/main.go index c728934..de5b4dc 100644 --- a/cmd/evydence/main.go +++ b/cmd/evydence/main.go @@ -1,6 +1,7 @@ package main import ( + "archive/zip" "bytes" "context" "crypto/ed25519" @@ -24,6 +25,10 @@ import ( func main() { if err := run(os.Args[1:]); err != nil { fmt.Fprintln(os.Stderr, err) + var exitErr interface{ ExitCode() int } + if errors.As(err, &exitErr) { + os.Exit(exitErr.ExitCode()) + } os.Exit(1) } } @@ -53,26 +58,50 @@ func run(args []string) error { return usage() } return verifyEvidenceBundle(args[1]) + case "verify-audit-chain": + if len(args) != 2 { + return usage() + } + return verifyAuditChain(args[1]) + case "package": + if len(args) < 2 || args[1] != "verify" { + return usage() + } + return verifyCustomerPackage(args[2:]) case "github-actions": if len(args) < 2 || args[1] != "upload-build" { return usage() } return uploadGitHubActionsBuild(context.Background(), http.DefaultClient, args[2:]) + case "ci": + if len(args) < 2 || args[1] != "preflight" { + return usage() + } + return ciPreflight(context.Background(), http.DefaultClient, args[2:]) case "import-bundle": if len(args) < 2 || args[1] != "upload" { return usage() } return uploadEvidenceBundleImport(context.Background(), http.DefaultClient, args[2:]) case "upload": - if len(args) < 2 || args[1] != "manifest" { + if len(args) < 2 { + return usage() + } + switch args[1] { + case "manifest": + return uploadManifestRequests(context.Background(), http.DefaultClient, args[2:]) + case "validate-manifest": + return validateUploadManifestCommand(args[2:]) + default: return usage() } - return uploadManifestRequests(context.Background(), http.DefaultClient, args[2:]) case "release": if len(args) < 2 { return usage() } switch args[1] { + case "upload-evidence": + return uploadReleaseEvidence(context.Background(), http.DefaultClient, args[2:]) case "manifest": return createReleaseArtifactManifest(args[2:]) case "sign": @@ -90,7 +119,7 @@ func run(args []string) error { } func usage() error { - return errors.New("usage: evydence hash | evydence verify-manifest --hash sha256: | evydence verify-evidence-bundle | evydence github-actions upload-build ... | evydence import-bundle upload ... | evydence upload manifest ... | evydence release manifest|sign|verify|keygen") + return errors.New("usage: evydence hash | evydence verify-manifest --hash sha256: | evydence verify-evidence-bundle | evydence verify-audit-chain | evydence package verify ... | evydence github-actions upload-build ... | evydence ci preflight ... | evydence import-bundle upload ... | evydence upload manifest|validate-manifest ... | evydence release upload-evidence|manifest|sign|verify|keygen") } func hashFile(path string) (string, error) { @@ -145,267 +174,1637 @@ func verifyManifest(path, expected string) error { } func verifyEvidenceBundle(path string) error { - cleaned, err := cleanOperatorPath(path) + bundle, err := readEvidenceBundle(path) if err != nil { return err } - // #nosec G304,G703 -- this CLI command intentionally reads a local operator-specified bundle file. - body, err := os.ReadFile(cleaned) + verified, err := verifyEvidenceBundleStruct(bundle) + if err != nil { + return err + } + if verified.Signed { + fmt.Println("evidence bundle manifest and signature verified") + return nil + } + fmt.Println("evidence bundle manifest verified") + return nil +} + +type offlineSignature struct { + ID string `json:"id"` + KeyID string `json:"key_id"` + Algorithm string `json:"algorithm"` + Value string `json:"value"` + CreatedAt time.Time `json:"created_at,omitempty"` +} + +type offlineEvidenceBundle struct { + Manifest map[string]any `json:"manifest"` + ManifestHash string `json:"manifest_hash"` + SignatureRefs []string `json:"signature_refs"` + Signatures []offlineSignature `json:"signatures"` + SigningKeys []offlineSigningKey `json:"signing_keys"` +} + +type offlineEvidenceBundleVerification struct { + ManifestHash string + Signed bool + SigningKeyIDs []string +} + +type offlineSigningKey struct { + ID string `json:"id"` + Algorithm string `json:"algorithm"` + Status string `json:"status"` + PublicKey string `json:"public_key"` + RevokedAt *time.Time `json:"revoked_at,omitempty"` +} + +type offlineAuditChainEntry struct { + ID string `json:"id"` + TenantID string `json:"tenant_id"` + Sequence int64 `json:"sequence"` + EntryType string `json:"entry_type"` + SubjectType string `json:"subject_type"` + SubjectID string `json:"subject_id"` + ActorType string `json:"actor_type"` + ActorID string `json:"actor_id"` + OccurredAt time.Time `json:"occurred_at"` + PayloadHash string `json:"payload_hash"` + PreviousEntryHash string `json:"previous_entry_hash"` + SignatureRef string `json:"signature_ref"` + SchemaVersion string `json:"schema_version"` + CanonicalEntryHash string `json:"canonical_entry_hash"` + EntryHash string `json:"entry_hash"` +} + +func verifyAuditChain(path string) error { + body, err := readFileStrict(path) if err != nil { return err } - var bundle struct { - Manifest map[string]any `json:"manifest"` - ManifestHash string `json:"manifest_hash"` + entries, err := decodeAuditChain(body) + if err != nil { + return err + } + if len(entries) == 0 { + return errors.New("audit chain contains no entries") + } + previous := "" + for i, entry := range entries { + if entry.Sequence != int64(i+1) { + return fmt.Errorf("audit chain sequence mismatch at entry %d", i+1) + } + if entry.PreviousEntryHash != previous { + return fmt.Errorf("audit chain previous hash mismatch at entry %d", i+1) + } + canonical, err := auditEntryCanonicalHash(entry) + if err != nil { + return err + } + if entry.CanonicalEntryHash != canonical { + return fmt.Errorf("audit chain canonical hash mismatch at entry %d", i+1) + } + if hashString(previous+"\n"+entry.CanonicalEntryHash) != entry.EntryHash { + return fmt.Errorf("audit chain entry hash mismatch at entry %d", i+1) + } + previous = entry.EntryHash + } + fmt.Println("audit chain verified") + return nil +} + +func decodeAuditChain(body []byte) ([]offlineAuditChainEntry, error) { + var envelope struct { + Entries []offlineAuditChainEntry `json:"entries"` + Chain []offlineAuditChainEntry `json:"chain"` + } + if err := json.Unmarshal(body, &envelope); err == nil { + if len(envelope.Entries) > 0 { + return envelope.Entries, nil + } + if len(envelope.Chain) > 0 { + return envelope.Chain, nil + } + } + var entries []offlineAuditChainEntry + if err := json.Unmarshal(body, &entries); err != nil { + return nil, errors.New("audit chain is not JSON array or entries envelope") + } + return entries, nil +} + +func auditEntryCanonicalHash(entry offlineAuditChainEntry) (string, error) { + if strings.TrimSpace(entry.TenantID) == "" || entry.Sequence <= 0 || strings.TrimSpace(entry.EntryType) == "" || strings.TrimSpace(entry.SubjectType) == "" || strings.TrimSpace(entry.SubjectID) == "" || strings.TrimSpace(entry.ActorType) == "" || strings.TrimSpace(entry.SchemaVersion) == "" || entry.OccurredAt.IsZero() { + return "", errors.New("audit chain entry missing required fields") + } + return canonicalJSONHash(map[string]any{ + "tenant_id": entry.TenantID, + "sequence": entry.Sequence, + "entry_type": entry.EntryType, + "subject_type": entry.SubjectType, + "subject_id": entry.SubjectID, + "actor_type": entry.ActorType, + "actor_id": entry.ActorID, + "occurred_at": entry.OccurredAt.UTC().Format(time.RFC3339Nano), + "payload_hash": entry.PayloadHash, + "previous_entry_hash": entry.PreviousEntryHash, + "signature_ref": entry.SignatureRef, + "schema_version": entry.SchemaVersion, + }) +} + +func verifyOfflineSignatures(payloadHash string, refs []string, signatures []offlineSignature, keys []offlineSigningKey) error { + if len(signatures) == 0 && len(keys) == 0 { + return nil + } + if strings.TrimSpace(payloadHash) == "" || len(refs) == 0 || len(signatures) == 0 || len(keys) == 0 { + return errors.New("offline signature material is incomplete") + } + keyByID := map[string]offlineSigningKey{} + for _, key := range keys { + if key.ID != "" { + keyByID[key.ID] = key + } + } + signatureByID := map[string]offlineSignature{} + for _, signature := range signatures { + if signature.ID != "" { + signatureByID[signature.ID] = signature + } + } + for _, ref := range refs { + signature, ok := signatureByID[strings.TrimSpace(ref)] + if !ok || signature.Algorithm != "Ed25519" { + continue + } + key, ok := keyByID[signature.KeyID] + if !ok || key.Algorithm != "Ed25519" { + continue + } + if key.Status == "revoked" && (key.RevokedAt == nil || signature.CreatedAt.IsZero() || signature.CreatedAt.After(*key.RevokedAt)) { + continue + } + pub, err := decodeBase64Flexible(key.PublicKey) + if err != nil || len(pub) != ed25519.PublicKeySize { + continue + } + value, err := decodeBase64Flexible(signature.Value) + if err != nil || len(value) != ed25519.SignatureSize { + continue + } + if ed25519.Verify(ed25519.PublicKey(pub), []byte(payloadHash), value) { + return nil + } } + return errors.New("offline signature verification failed") +} + +func readEvidenceBundle(path string) (offlineEvidenceBundle, error) { + body, err := readFileStrict(path) + if err != nil { + return offlineEvidenceBundle{}, err + } + var bundle offlineEvidenceBundle if err := json.Unmarshal(body, &bundle); err != nil { - return errors.New("evidence bundle is not JSON") + return offlineEvidenceBundle{}, errors.New("evidence bundle is not JSON") } + return bundle, nil +} + +func verifyEvidenceBundleStruct(bundle offlineEvidenceBundle) (offlineEvidenceBundleVerification, error) { if len(bundle.Manifest) == 0 || strings.TrimSpace(bundle.ManifestHash) == "" { - return errors.New("evidence bundle missing manifest or manifest_hash") + return offlineEvidenceBundleVerification{}, errors.New("evidence bundle missing manifest or manifest_hash") } canonical, err := json.Marshal(bundle.Manifest) if err != nil { - return err + return offlineEvidenceBundleVerification{}, err } var normalized any if err := json.Unmarshal(canonical, &normalized); err != nil { - return err + return offlineEvidenceBundleVerification{}, err } canonical, err = json.Marshal(normalized) if err != nil { - return err + return offlineEvidenceBundleVerification{}, err } sum := sha256.Sum256(canonical) got := "sha256:" + hex.EncodeToString(sum[:]) if got != bundle.ManifestHash { - return fmt.Errorf("evidence bundle hash mismatch: got %s want %s", got, bundle.ManifestHash) + return offlineEvidenceBundleVerification{}, fmt.Errorf("evidence bundle hash mismatch: got %s want %s", got, bundle.ManifestHash) } - fmt.Println("evidence bundle manifest verified") - return nil + keyIDs := evidenceBundleSigningKeyIDs(bundle) + if err := verifyOfflineSignatures(bundle.ManifestHash, bundle.SignatureRefs, bundle.Signatures, bundle.SigningKeys); err != nil { + return offlineEvidenceBundleVerification{}, err + } + return offlineEvidenceBundleVerification{ManifestHash: bundle.ManifestHash, Signed: len(bundle.Signatures) > 0 || len(bundle.SigningKeys) > 0, SigningKeyIDs: keyIDs}, nil } -func cleanOperatorPath(path string) (string, error) { - path = strings.TrimSpace(path) - if path == "" { - return "", errors.New("file path is required") +func evidenceBundleSigningKeyIDs(bundle offlineEvidenceBundle) []string { + byID := map[string]offlineSignature{} + for _, signature := range bundle.Signatures { + if strings.TrimSpace(signature.ID) != "" { + byID[signature.ID] = signature + } } - if strings.Contains(path, "\x00") { - return "", errors.New("file path contains a NUL byte") + seen := map[string]bool{} + keyIDs := []string{} + for _, ref := range bundle.SignatureRefs { + signature, ok := byID[strings.TrimSpace(ref)] + if !ok || strings.TrimSpace(signature.KeyID) == "" || seen[signature.KeyID] { + continue + } + seen[signature.KeyID] = true + keyIDs = append(keyIDs, signature.KeyID) } - return filepath.Clean(path), nil + return keyIDs } -func uploadGitHubActionsBuild(ctx context.Context, client *http.Client, args []string) error { - fs := flag.NewFlagSet("github-actions upload-build", flag.ContinueOnError) +const ( + customerPackageSchemaVersion = "customer-security-package.v2.0.0" + maxCustomerPackageFileBytes = int64(10 << 20) +) + +type customerPackageVerifyResult struct { + ManifestHash string + PackageID string + TenantID string + ProductID string + ReleaseID string + EvidenceIDs []string + ReleaseBundleHashes []string +} + +type customerPackageArchiveFiles struct { + Manifest []byte + DecisionExport []byte + Metadata map[string]any + Verification map[string]any +} + +const customerDecisionExportSchemaVersion = "customer-vulnerability-decisions.v1.0.0" + +func verifyCustomerPackage(args []string) error { + fs := flag.NewFlagSet("package verify", flag.ContinueOnError) fs.SetOutput(io.Discard) - var ( - apiURL = fs.String("url", strings.TrimSpace(os.Getenv("EVYDENCE_API_URL")), "Evydence API URL") - apiKey = fs.String("api-key", strings.TrimSpace(os.Getenv("EVYDENCE_API_KEY")), "Evydence API key") - projectID = fs.String("project-id", "", "Evydence project ID") - releaseID = fs.String("release-id", "", "Evydence release ID") - artifactID = fs.String("artifact-id", "", "Evydence artifact ID") - artifactDigest = fs.String("artifact-digest", "", "artifact digest") - attestationPath = fs.String("attestation-path", "", "DSSE attestation JSON path") - status = fs.String("status", envDefault("EVYDENCE_BUILD_STATUS", "passed"), "build status") - startedAt = fs.String("started-at", envDefault("EVYDENCE_BUILD_STARTED_AT", time.Now().UTC().Format(time.RFC3339)), "build start time") - finishedAt = fs.String("finished-at", strings.TrimSpace(os.Getenv("EVYDENCE_BUILD_FINISHED_AT")), "build finish time") - parametersHash = fs.String("parameters-hash", "", "build parameters hash") - environmentHash = fs.String("environment-hash", "", "build environment hash") - oidcSubject = fs.String("oidc-subject", strings.TrimSpace(os.Getenv("EVYDENCE_GITHUB_OIDC_SUBJECT")), "captured GitHub OIDC subject") - ) + manifestPath := fs.String("manifest", "", "customer package manifest JSON path") + archivePath := fs.String("archive", "", "customer package ZIP path") + bundlePath := fs.String("bundle", "", "evidence bundle JSON path") + expectedHash := fs.String("hash", "", "expected canonical manifest hash sha256:") + expectedTenantID := fs.String("expected-tenant-id", "", "expected tenant id") + expectedProductID := fs.String("expected-product-id", "", "expected product id") + expectedReleaseID := fs.String("expected-release-id", "", "expected release id") + expectedPackageID := fs.String("expected-package-id", "", "expected package id") + expectedSigningKeyID := fs.String("expected-signing-key-id", "", "expected evidence-bundle signing key id") if err := fs.Parse(args); err != nil { return usage() } - if strings.TrimSpace(*apiURL) == "" || strings.TrimSpace(*apiKey) == "" || strings.TrimSpace(*projectID) == "" || strings.TrimSpace(*releaseID) == "" { + if fs.NArg() != 0 || (strings.TrimSpace(*manifestPath) == "" && strings.TrimSpace(*archivePath) == "") { return usage() } - if (*artifactID == "") != (*artifactDigest == "") { - return errors.New("--artifact-id and --artifact-digest must be provided together") + + var archive customerPackageArchiveFiles + var manifestBody []byte + if strings.TrimSpace(*archivePath) != "" { + var err error + archive, err = readCustomerPackageArchive(*archivePath) + if err != nil { + return err + } + manifestBody = archive.Manifest } - started, err := time.Parse(time.RFC3339, strings.TrimSpace(*startedAt)) + if strings.TrimSpace(*manifestPath) != "" { + body, err := readFileStrict(*manifestPath) + if err != nil { + return err + } + if len(manifestBody) > 0 { + archiveHash, err := canonicalJSONBytesHash(manifestBody) + if err != nil { + return err + } + fileHash, err := canonicalJSONBytesHash(body) + if err != nil { + return err + } + if archiveHash != fileHash { + return errors.New("manifest file and archive manifest do not match") + } + } + manifestBody = body + } + + result, err := verifyCustomerPackageManifestBytes(manifestBody) if err != nil { - return errors.New("--started-at must use RFC3339") + return err + } + if expected := strings.TrimSpace(*expectedHash); expected != "" && result.ManifestHash != expected { + return fmt.Errorf("customer package manifest hash mismatch: got %s want %s", result.ManifestHash, expected) + } + if err := verifyExpectedValue("tenant id", result.TenantID, *expectedTenantID); err != nil { + return err + } + if err := verifyExpectedValue("product id", result.ProductID, *expectedProductID); err != nil { + return err + } + if err := verifyExpectedValue("release id", result.ReleaseID, *expectedReleaseID); err != nil { + return err + } + if err := verifyExpectedValue("package id", result.PackageID, *expectedPackageID); err != nil { + return err + } + if len(archive.Manifest) > 0 { + if err := verifyCustomerPackageArchiveMetadata(archive, result); err != nil { + return err + } + } + bundleStatus := "not supplied" + if strings.TrimSpace(*bundlePath) != "" { + bundle, err := readEvidenceBundle(*bundlePath) + if err != nil { + return err + } + verified, err := verifyEvidenceBundleStruct(bundle) + if err != nil { + return err + } + if expected := strings.TrimSpace(*expectedSigningKeyID); expected != "" && !stringInSlice(verified.SigningKeyIDs, expected) { + return fmt.Errorf("expected signing key id %s was not used by evidence bundle signatures", expected) + } + if err := verifyCustomerPackageEvidenceCoverage(result.EvidenceIDs, bundle.Manifest); err != nil { + return err + } + bundleStatus = "verified" + if verified.Signed { + bundleStatus = "verified with signature" + } + } + fmt.Printf("customer package verified\npackage_id: %s\nproduct_id: %s\nrelease_id: %s\nmanifest_hash: %s\nevidence_bundle: %s\n", result.PackageID, result.ProductID, result.ReleaseID, result.ManifestHash, bundleStatus) + return nil +} + +func verifyCustomerPackageManifestBytes(body []byte) (customerPackageVerifyResult, error) { + if len(bytes.TrimSpace(body)) == 0 { + return customerPackageVerifyResult{}, errors.New("customer package manifest is empty") + } + var raw any + if err := json.Unmarshal(body, &raw); err != nil { + return customerPackageVerifyResult{}, errors.New("customer package manifest is not valid JSON") + } + if err := rejectCustomerPackageSensitiveKeys(raw); err != nil { + return customerPackageVerifyResult{}, err + } + hash, err := canonicalJSONBytesHash(body) + if err != nil { + return customerPackageVerifyResult{}, err + } + var manifest struct { + SchemaVersion string `json:"schema_version"` + PackageVersion string `json:"package_version"` + PackageID string `json:"package_id"` + ID string `json:"id"` + Title string `json:"title"` + GeneratedAt string `json:"generated_at"` + Tenant struct{ ID string } `json:"tenant"` + ProductID string `json:"product_id"` + Product struct{ ID string } `json:"product"` + ReleaseID string `json:"release_id"` + Release struct{ ID string } `json:"release"` + RedactionProfile struct { + ID string `json:"id"` + Name string `json:"name"` + AllowedTypes []string `json:"allowed_types"` + SchemaVersion string `json:"schema_version"` + } `json:"redaction_profile"` + EvidenceIDs []string `json:"evidence_ids"` + ArtifactDigests []struct { + ID string `json:"id"` + Digest string `json:"digest"` + } `json:"artifact_digests"` + ReadinessSummary map[string]any `json:"readiness_summary"` + VerificationMaterial struct { + HashAlgorithm string `json:"hash_algorithm"` + ReleaseBundles []struct { + ManifestHash string `json:"manifest_hash"` + } `json:"release_bundles"` + } `json:"verification_material"` + Limitations []string `json:"limitations"` + NonClaims []string `json:"non_claims"` + } + if err := json.Unmarshal(body, &manifest); err != nil { + return customerPackageVerifyResult{}, errors.New("customer package manifest structure is invalid") + } + if strings.TrimSpace(manifest.SchemaVersion) != customerPackageSchemaVersion || strings.TrimSpace(manifest.PackageVersion) != customerPackageSchemaVersion { + return customerPackageVerifyResult{}, errors.New("customer package manifest schema_version is unsupported") + } + packageID := strings.TrimSpace(manifest.PackageID) + if packageID == "" { + packageID = strings.TrimSpace(manifest.ID) + } + if packageID == "" || strings.TrimSpace(manifest.ID) == "" || packageID != strings.TrimSpace(manifest.ID) { + return customerPackageVerifyResult{}, errors.New("customer package manifest id/package_id mismatch") + } + if strings.TrimSpace(manifest.Title) == "" || strings.TrimSpace(manifest.Tenant.ID) == "" || strings.TrimSpace(manifest.ProductID) == "" || strings.TrimSpace(manifest.Product.ID) == "" { + return customerPackageVerifyResult{}, errors.New("customer package manifest missing required identity fields") + } + if strings.TrimSpace(manifest.ProductID) != strings.TrimSpace(manifest.Product.ID) { + return customerPackageVerifyResult{}, errors.New("customer package manifest product id mismatch") + } + if strings.TrimSpace(manifest.ReleaseID) != "" && strings.TrimSpace(manifest.Release.ID) != "" && strings.TrimSpace(manifest.ReleaseID) != strings.TrimSpace(manifest.Release.ID) { + return customerPackageVerifyResult{}, errors.New("customer package manifest release id mismatch") + } + if _, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(manifest.GeneratedAt)); err != nil { + return customerPackageVerifyResult{}, errors.New("customer package manifest generated_at must be RFC3339") + } + if strings.TrimSpace(manifest.RedactionProfile.ID) == "" || strings.TrimSpace(manifest.RedactionProfile.Name) == "" || len(manifest.RedactionProfile.AllowedTypes) == 0 || strings.TrimSpace(manifest.RedactionProfile.SchemaVersion) == "" { + return customerPackageVerifyResult{}, errors.New("customer package manifest missing redaction profile details") + } + if len(manifest.ReadinessSummary) == 0 || len(manifest.Limitations) == 0 || len(manifest.NonClaims) == 0 { + return customerPackageVerifyResult{}, errors.New("customer package manifest missing readiness, limitations, or non-claims") + } + if algorithm := strings.TrimSpace(manifest.VerificationMaterial.HashAlgorithm); algorithm != "" && algorithm != "sha256" { + return customerPackageVerifyResult{}, errors.New("customer package manifest uses unsupported hash algorithm") + } + for _, artifact := range manifest.ArtifactDigests { + if strings.TrimSpace(artifact.ID) == "" || !validSHA256Digest(artifact.Digest) { + return customerPackageVerifyResult{}, errors.New("customer package manifest has invalid artifact digest") + } + } + releaseBundleHashes := []string{} + for _, bundle := range manifest.VerificationMaterial.ReleaseBundles { + if strings.TrimSpace(bundle.ManifestHash) != "" { + releaseBundleHashes = append(releaseBundleHashes, strings.TrimSpace(bundle.ManifestHash)) + } + } + return customerPackageVerifyResult{ + ManifestHash: hash, + PackageID: packageID, + TenantID: strings.TrimSpace(manifest.Tenant.ID), + ProductID: strings.TrimSpace(manifest.ProductID), + ReleaseID: strings.TrimSpace(manifest.ReleaseID), + EvidenceIDs: trimStringSlice(manifest.EvidenceIDs), + ReleaseBundleHashes: releaseBundleHashes, + }, nil +} + +func readCustomerPackageArchive(path string) (customerPackageArchiveFiles, error) { + cleaned, err := cleanOperatorPath(path) + if err != nil { + return customerPackageArchiveFiles{}, err + } + // #nosec G304 -- this CLI intentionally reads a local operator-specified ZIP archive and never extracts entries to disk. + reader, err := zip.OpenReader(cleaned) + if err != nil { + return customerPackageArchiveFiles{}, err + } + defer func() { + _ = reader.Close() + }() + files := customerPackageArchiveFiles{} + for _, file := range reader.File { + switch file.Name { + case "manifest.json": + files.Manifest, err = readZIPFileLimited(file) + case "package.json": + var body []byte + body, err = readZIPFileLimited(file) + if err == nil { + err = json.Unmarshal(body, &files.Metadata) + } + case "verification.json": + var body []byte + body, err = readZIPFileLimited(file) + if err == nil { + err = json.Unmarshal(body, &files.Verification) + } + case "vulnerability-decisions.json": + files.DecisionExport, err = readZIPFileLimited(file) + } + if err != nil { + return customerPackageArchiveFiles{}, err + } + } + if len(files.Manifest) == 0 || len(files.Metadata) == 0 || len(files.Verification) == 0 { + return customerPackageArchiveFiles{}, errors.New("customer package archive missing manifest.json, package.json, or verification.json") + } + return files, nil +} + +func readZIPFileLimited(file *zip.File) ([]byte, error) { + if file.UncompressedSize64 > uint64(maxCustomerPackageFileBytes) { + return nil, fmt.Errorf("customer package archive entry %s is too large", file.Name) + } + rc, err := file.Open() + if err != nil { + return nil, err + } + defer func() { + _ = rc.Close() + }() + body, err := io.ReadAll(io.LimitReader(rc, maxCustomerPackageFileBytes+1)) + if err != nil { + return nil, err + } + if int64(len(body)) > maxCustomerPackageFileBytes { + return nil, fmt.Errorf("customer package archive entry %s is too large", file.Name) + } + return body, nil +} + +func verifyCustomerPackageArchiveMetadata(archive customerPackageArchiveFiles, result customerPackageVerifyResult) error { + for name, document := range map[string]map[string]any{"package.json": archive.Metadata, "verification.json": archive.Verification} { + if got := stringMapField(document, "manifest_hash"); got != result.ManifestHash { + return fmt.Errorf("%s manifest_hash mismatch", name) + } + if got := stringMapField(document, "package_id"); got != "" && got != result.PackageID { + return fmt.Errorf("%s package_id mismatch", name) + } + if name == "package.json" { + if got := stringMapField(document, "id"); got != "" && got != result.PackageID { + return fmt.Errorf("%s id mismatch", name) + } + } + } + if exportFile := stringMapField(archive.Verification, "decision_export_file"); strings.TrimSpace(exportFile) != "" { + if strings.TrimSpace(exportFile) != "vulnerability-decisions.json" { + return errors.New("verification.json decision_export_file is unsupported") + } + if len(archive.DecisionExport) == 0 { + return errors.New("customer package archive missing declared vulnerability decision export") + } + if err := verifyCustomerDecisionExportBytes(archive.DecisionExport, result); err != nil { + return err + } + } + return nil +} + +func verifyCustomerDecisionExportBytes(body []byte, result customerPackageVerifyResult) error { + var raw any + if err := json.Unmarshal(body, &raw); err != nil { + return errors.New("customer package decision export is not valid JSON") + } + if err := rejectCustomerPackageSensitiveKeys(raw); err != nil { + return err + } + var export struct { + SchemaVersion string `json:"schema_version"` + PackageID string `json:"package_id"` + ProductID string `json:"product_id"` + ReleaseID string `json:"release_id"` + SourceManifestHash string `json:"source_manifest_hash"` + Decisions []map[string]any `json:"decisions"` + Assumptions []string `json:"assumptions"` + Limitations []string `json:"limitations"` + GeneratedAt string `json:"generated_at"` + } + if err := json.Unmarshal(body, &export); err != nil { + return errors.New("customer package decision export structure is invalid") + } + if strings.TrimSpace(export.SchemaVersion) != customerDecisionExportSchemaVersion { + return errors.New("customer package decision export schema_version is unsupported") + } + if strings.TrimSpace(export.PackageID) != result.PackageID || strings.TrimSpace(export.ProductID) != result.ProductID || strings.TrimSpace(export.ReleaseID) != result.ReleaseID { + return errors.New("customer package decision export scope mismatch") + } + if strings.TrimSpace(export.SourceManifestHash) != result.ManifestHash { + return errors.New("customer package decision export manifest_hash mismatch") + } + if len(export.Decisions) == 0 || len(export.Assumptions) == 0 || len(export.Limitations) == 0 { + return errors.New("customer package decision export missing decisions, assumptions, or limitations") + } + if _, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(export.GeneratedAt)); err != nil { + return errors.New("customer package decision export generated_at must be RFC3339") + } + for _, decision := range export.Decisions { + for _, key := range []string{"id", "vulnerability", "status", "impact_statement"} { + if strings.TrimSpace(stringMapField(decision, key)) == "" { + return fmt.Errorf("customer package decision export missing decision field %s", key) + } + } + } + return nil +} + +func verifyCustomerPackageEvidenceCoverage(packageEvidenceIDs []string, bundleManifest map[string]any) error { + if len(packageEvidenceIDs) == 0 { + return nil + } + bundleIDs := stringSliceFromAny(bundleManifest["evidence_ids"]) + if len(bundleIDs) == 0 { + return nil + } + for _, id := range packageEvidenceIDs { + if !stringInSlice(bundleIDs, id) { + return fmt.Errorf("evidence bundle does not include package evidence id %s", id) + } + } + return nil +} + +func canonicalJSONBytesHash(body []byte) (string, error) { + var normalized any + if err := json.Unmarshal(body, &normalized); err != nil { + return "", errors.New("manifest is not valid JSON") + } + canonical, err := json.Marshal(normalized) + if err != nil { + return "", err + } + return hashBytes(canonical), nil +} + +func validSHA256Digest(value string) bool { + value = strings.TrimSpace(value) + if !strings.HasPrefix(value, "sha256:") || len(value) != len("sha256:")+64 { + return false + } + _, err := hex.DecodeString(strings.TrimPrefix(value, "sha256:")) + return err == nil +} + +func verifyExpectedValue(label, got, expected string) error { + expected = strings.TrimSpace(expected) + if expected == "" { + return nil + } + if strings.TrimSpace(got) != expected { + return fmt.Errorf("customer package %s mismatch: got %s want %s", label, got, expected) + } + return nil +} + +func rejectCustomerPackageSensitiveKeys(value any) error { + return rejectCustomerPackageSensitiveKeysAt(value, "") +} + +func rejectCustomerPackageSensitiveKeysAt(value any, path string) error { + switch typed := value.(type) { + case map[string]any: + for key, child := range typed { + lower := strings.ToLower(strings.TrimSpace(key)) + if prohibitedCustomerPackageManifestKey(lower) { + if path == "" { + return fmt.Errorf("customer package manifest contains prohibited field %s", key) + } + return fmt.Errorf("customer package manifest contains prohibited field %s.%s", path, key) + } + childPath := key + if path != "" { + childPath = path + "." + key + } + if err := rejectCustomerPackageSensitiveKeysAt(child, childPath); err != nil { + return err + } + } + case []any: + for _, child := range typed { + if err := rejectCustomerPackageSensitiveKeysAt(child, path); err != nil { + return err + } + } + } + return nil +} + +func prohibitedCustomerPackageManifestKey(key string) bool { + switch key { + case "payload", "payload_bytes", "payload_ref", "object_key", "private_key", "token", "secret", "api_key_hash", "session_token_hash", "internal_notes": + return true + default: + return false + } +} + +func stringMapField(values map[string]any, key string) string { + if values == nil { + return "" + } + value, _ := values[key].(string) + return strings.TrimSpace(value) +} + +func stringSliceFromAny(value any) []string { + raw, ok := value.([]any) + if !ok { + return nil + } + out := make([]string, 0, len(raw)) + for _, item := range raw { + text, ok := item.(string) + if ok && strings.TrimSpace(text) != "" { + out = append(out, strings.TrimSpace(text)) + } + } + return out +} + +func trimStringSlice(values []string) []string { + out := make([]string, 0, len(values)) + for _, value := range values { + value = strings.TrimSpace(value) + if value != "" { + out = append(out, value) + } + } + return out +} + +func stringInSlice(values []string, expected string) bool { + for _, value := range values { + if value == expected { + return true + } + } + return false +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if trimmed := strings.TrimSpace(value); trimmed != "" { + return trimmed + } + } + return "" +} + +func cleanOperatorPath(path string) (string, error) { + path = strings.TrimSpace(path) + if path == "" { + return "", errors.New("file path is required") + } + if strings.Contains(path, "\x00") { + return "", errors.New("file path contains a NUL byte") + } + return filepath.Clean(path), nil +} + +func uploadGitHubActionsBuild(ctx context.Context, client *http.Client, args []string) error { + fs := flag.NewFlagSet("github-actions upload-build", flag.ContinueOnError) + fs.SetOutput(io.Discard) + var ( + apiURL = fs.String("url", strings.TrimSpace(os.Getenv("EVYDENCE_API_URL")), "Evydence API URL") + apiKey = fs.String("api-key", strings.TrimSpace(os.Getenv("EVYDENCE_API_KEY")), "Evydence API key") + projectID = fs.String("project-id", "", "Evydence project ID") + releaseID = fs.String("release-id", "", "Evydence release ID") + artifactID = fs.String("artifact-id", "", "Evydence artifact ID") + artifactDigest = fs.String("artifact-digest", "", "artifact digest") + attestationPath = fs.String("attestation-path", "", "DSSE attestation JSON path") + status = fs.String("status", envDefault("EVYDENCE_BUILD_STATUS", "passed"), "build status") + startedAt = fs.String("started-at", envDefault("EVYDENCE_BUILD_STARTED_AT", time.Now().UTC().Format(time.RFC3339)), "build start time") + finishedAt = fs.String("finished-at", strings.TrimSpace(os.Getenv("EVYDENCE_BUILD_FINISHED_AT")), "build finish time") + parametersHash = fs.String("parameters-hash", "", "build parameters hash") + environmentHash = fs.String("environment-hash", "", "build environment hash") + oidcSubject = fs.String("oidc-subject", strings.TrimSpace(os.Getenv("EVYDENCE_GITHUB_OIDC_SUBJECT")), "captured GitHub OIDC subject") + ) + if err := fs.Parse(args); err != nil { + return usage() + } + if strings.TrimSpace(*apiURL) == "" || strings.TrimSpace(*apiKey) == "" || strings.TrimSpace(*projectID) == "" || strings.TrimSpace(*releaseID) == "" { + return usage() + } + if (*artifactID == "") != (*artifactDigest == "") { + return errors.New("--artifact-id and --artifact-digest must be provided together") + } + started, err := time.Parse(time.RFC3339, strings.TrimSpace(*startedAt)) + if err != nil { + return errors.New("--started-at must use RFC3339") + } + var finished *time.Time + if strings.TrimSpace(*finishedAt) != "" { + parsed, err := time.Parse(time.RFC3339, strings.TrimSpace(*finishedAt)) + if err != nil { + return errors.New("--finished-at must use RFC3339") + } + finished = &parsed + } + outputs := []map[string]string{} + if strings.TrimSpace(*artifactID) != "" { + outputs = append(outputs, map[string]string{"artifact_id": strings.TrimSpace(*artifactID), "digest": strings.TrimSpace(*artifactDigest)}) + } + runID := envRequired("GITHUB_RUN_ID") + runAttempt := envDefault("GITHUB_RUN_ATTEMPT", "1") + commitSHA := envRequired("GITHUB_SHA") + repository := envRequired("GITHUB_REPOSITORY") + workflowRef := envRequired("GITHUB_WORKFLOW_REF") + if runID == "" || commitSHA == "" || repository == "" || workflowRef == "" { + return errors.New("GITHUB_RUN_ID, GITHUB_SHA, GITHUB_REPOSITORY, and GITHUB_WORKFLOW_REF are required") + } + payload := map[string]any{ + "project_id": strings.TrimSpace(*projectID), + "release_id": strings.TrimSpace(*releaseID), + "provider": "github_actions", + "commit_sha": commitSHA, + "repository": repository, + "workflow_ref": workflowRef, + "run_id": runID, + "run_attempt": atoiDefault(runAttempt, 1), + "job_id": strings.TrimSpace(os.Getenv("GITHUB_JOB")), + "actor": strings.TrimSpace(os.Getenv("GITHUB_ACTOR")), + "ref": strings.TrimSpace(os.Getenv("GITHUB_REF")), + "oidc_subject": strings.TrimSpace(*oidcSubject), + "status": strings.TrimSpace(*status), + "started_at": started.UTC().Format(time.RFC3339), + "finished_at": finished, + "parameters_hash": strings.TrimSpace(*parametersHash), + "environment_hash": strings.TrimSpace(*environmentHash), + "outputs": outputs, + } + body, err := postEvydence(ctx, client, *apiURL, *apiKey, "/v1/builds", "github-actions-build-"+runID+"-"+runAttempt, payload) + if err != nil { + return err + } + buildID, err := responseDataID(body) + if err != nil { + return err + } + fmt.Println("build uploaded: " + buildID) + if strings.TrimSpace(*attestationPath) == "" { + return nil + } + cleaned, err := cleanOperatorPath(*attestationPath) + if err != nil { + return err + } + // #nosec G304,G703 -- this CLI command intentionally reads a local operator-specified attestation file. + attestation, err := os.ReadFile(cleaned) + if err != nil { + return err + } + body, err = postRawEvydence(ctx, client, *apiURL, *apiKey, "/v1/builds/"+buildID+"/attestations", "github-actions-attestation-"+buildID, attestation) + if err != nil { + return err + } + attestationID, err := responseDataID(body) + if err != nil { + return err + } + fmt.Println("attestation uploaded: " + attestationID) + return nil +} + +func uploadEvidenceBundleImport(ctx context.Context, client *http.Client, args []string) error { + fs := flag.NewFlagSet("import-bundle upload", flag.ContinueOnError) + fs.SetOutput(io.Discard) + apiURL := fs.String("url", strings.TrimSpace(os.Getenv("EVYDENCE_API_URL")), "Evydence API URL") + apiKey := fs.String("api-key", strings.TrimSpace(os.Getenv("EVYDENCE_API_KEY")), "Evydence API key") + path := fs.String("path", "", "evidence bundle JSON path") + idem := fs.String("idempotency-key", "", "idempotency key") + if err := fs.Parse(args); err != nil { + return usage() + } + if strings.TrimSpace(*apiURL) == "" || strings.TrimSpace(*apiKey) == "" || strings.TrimSpace(*path) == "" { + return usage() + } + cleaned, err := cleanOperatorPath(*path) + if err != nil { + return err + } + // #nosec G304,G703 -- this CLI command intentionally reads a local operator-specified import bundle. + body, err := os.ReadFile(cleaned) + if err != nil { + return err + } + if strings.TrimSpace(*idem) == "" { + digest := sha256.Sum256(body) + *idem = "import-bundle-" + hex.EncodeToString(digest[:8]) + } + response, err := postRawEvydence(ctx, client, *apiURL, *apiKey, "/v1/evidence-bundles/import", *idem, body) + if err != nil { + return err + } + id, err := responseDataID(response) + if err != nil { + return err + } + fmt.Println("evidence bundle import recorded: " + id) + return nil +} + +func uploadManifestRequests(ctx context.Context, client *http.Client, args []string) error { + fs := flag.NewFlagSet("upload manifest", flag.ContinueOnError) + fs.SetOutput(io.Discard) + apiURL := fs.String("url", strings.TrimSpace(os.Getenv("EVYDENCE_API_URL")), "Evydence API URL") + apiKey := fs.String("api-key", strings.TrimSpace(os.Getenv("EVYDENCE_API_KEY")), "Evydence API key") + manifestPath := fs.String("manifest", "", "upload manifest JSON path") + if err := fs.Parse(args); err != nil { + return usage() + } + if strings.TrimSpace(*apiURL) == "" || strings.TrimSpace(*apiKey) == "" || strings.TrimSpace(*manifestPath) == "" { + return usage() + } + manifest, err := readAndValidateUploadManifest(*manifestPath) + if err != nil { + return err + } + for _, req := range manifest.Requests { + payload, err := req.PayloadBytes() + if err != nil { + return err + } + response, err := postRawEvydence(ctx, client, *apiURL, *apiKey, req.Path, req.IdempotencyKey, payload) + if err != nil { + return err + } + id, err := responseDataID(response) + if err != nil { + return err + } + fmt.Println("uploaded " + req.Path + ": " + id) + } + return nil +} + +func validateUploadManifestCommand(args []string) error { + fs := flag.NewFlagSet("upload validate-manifest", flag.ContinueOnError) + fs.SetOutput(io.Discard) + manifestPath := fs.String("manifest", "", "upload manifest JSON path") + if err := fs.Parse(args); err != nil { + return usage() + } + if strings.TrimSpace(*manifestPath) == "" || fs.NArg() != 0 { + return usage() + } + manifest, err := readAndValidateUploadManifest(*manifestPath) + if err != nil { + return err + } + fmt.Printf("upload manifest valid: %d requests\n", len(manifest.Requests)) + return nil +} + +const ( + exitCIPreflightMissingConfig = 2 + exitCIPreflightAuthFailure = 3 + exitCIPreflightWrongScope = 4 + exitCIPreflightWrongTenant = 5 + exitCIPreflightInvalidManifest = 6 +) + +type cliExitError struct { + code int + message string +} + +func (e cliExitError) Error() string { + return e.message +} + +func (e cliExitError) ExitCode() int { + return e.code +} + +func ciPreflight(ctx context.Context, client *http.Client, args []string) error { + fs := flag.NewFlagSet("ci preflight", flag.ContinueOnError) + fs.SetOutput(io.Discard) + apiURL := fs.String("url", strings.TrimSpace(os.Getenv("EVYDENCE_API_URL")), "Evydence API URL") + apiKey := fs.String("api-key", strings.TrimSpace(os.Getenv("EVYDENCE_API_KEY")), "Evydence API key") + productID := fs.String("product-id", strings.TrimSpace(os.Getenv("EVYDENCE_PRODUCT_ID")), "Evydence product ID") + projectID := fs.String("project-id", strings.TrimSpace(os.Getenv("EVYDENCE_PROJECT_ID")), "Evydence project ID") + releaseID := fs.String("release-id", strings.TrimSpace(os.Getenv("EVYDENCE_RELEASE_ID")), "Evydence release ID") + artifactID := fs.String("artifact-id", strings.TrimSpace(os.Getenv("EVYDENCE_ARTIFACT_ID")), "Evydence artifact ID") + manifestPath := fs.String("manifest", "", "upload manifest JSON path") + if err := fs.Parse(args); err != nil { + return ciExit(exitCIPreflightMissingConfig, "ci preflight configuration is invalid") + } + if fs.NArg() != 0 || strings.TrimSpace(*apiURL) == "" || strings.TrimSpace(*apiKey) == "" || + strings.TrimSpace(*productID) == "" || strings.TrimSpace(*projectID) == "" || + strings.TrimSpace(*releaseID) == "" || strings.TrimSpace(*artifactID) == "" || + strings.TrimSpace(*manifestPath) == "" { + return ciExit(exitCIPreflightMissingConfig, "ci preflight requires url, api key, product id, project id, release id, artifact id, and manifest path") + } + manifest, err := readAndValidateUploadManifest(*manifestPath) + if err != nil { + return ciExit(exitCIPreflightInvalidManifest, "ci preflight manifest is invalid: "+err.Error()) + } + if err := validatePreflightManifestIDs(manifest, *productID, *projectID, *releaseID, *artifactID); err != nil { + return ciExit(exitCIPreflightInvalidManifest, err.Error()) + } + productBody, err := getEvydence(ctx, client, *apiURL, *apiKey, "/v1/products/"+url.PathEscape(strings.TrimSpace(*productID))) + if err != nil { + return mapCIPreflightAPIError(err) + } + if err := ensureResponseDataID(productBody, *productID, "product"); err != nil { + return ciExit(exitCIPreflightWrongTenant, err.Error()) + } + projectBody, err := getEvydence(ctx, client, *apiURL, *apiKey, "/v1/projects/"+url.PathEscape(strings.TrimSpace(*projectID))) + if err != nil { + return mapCIPreflightAPIError(err) + } + if err := ensureResponseDataID(projectBody, *projectID, "project"); err != nil { + return ciExit(exitCIPreflightWrongTenant, err.Error()) + } + if err := ensureResponseDataField(projectBody, "product_id", *productID, "project product"); err != nil { + return ciExit(exitCIPreflightWrongTenant, err.Error()) + } + releaseBody, err := getEvydence(ctx, client, *apiURL, *apiKey, "/v1/releases/"+url.PathEscape(strings.TrimSpace(*releaseID))) + if err != nil { + return mapCIPreflightAPIError(err) + } + if err := ensureResponseDataID(releaseBody, *releaseID, "release"); err != nil { + return ciExit(exitCIPreflightWrongTenant, err.Error()) + } + if err := ensureResponseDataField(releaseBody, "product_id", *productID, "release product"); err != nil { + return ciExit(exitCIPreflightWrongTenant, err.Error()) + } + artifactBody, err := getEvydence(ctx, client, *apiURL, *apiKey, "/v1/artifacts/"+url.PathEscape(strings.TrimSpace(*artifactID))) + if err != nil { + return mapCIPreflightAPIError(err) + } + if err := ensureResponseDataID(artifactBody, *artifactID, "artifact"); err != nil { + return ciExit(exitCIPreflightWrongTenant, err.Error()) + } + fmt.Printf("ci preflight ok: product=%s project=%s release=%s artifact=%s manifest_requests=%d\n", strings.TrimSpace(*productID), strings.TrimSpace(*projectID), strings.TrimSpace(*releaseID), strings.TrimSpace(*artifactID), len(manifest.Requests)) + return nil +} + +func ciExit(code int, message string) error { + return cliExitError{code: code, message: message} +} + +func mapCIPreflightAPIError(err error) error { + var apiErr apiRequestError + if errors.As(err, &apiErr) { + switch apiErr.status { + case http.StatusUnauthorized: + return ciExit(exitCIPreflightAuthFailure, "ci preflight authentication failed") + case http.StatusForbidden: + return ciExit(exitCIPreflightWrongScope, "ci preflight API key lacks required scope") + case http.StatusNotFound: + return ciExit(exitCIPreflightWrongTenant, "ci preflight resource was not found for this tenant") + } + } + return err +} + +func ensureResponseDataID(body []byte, expected, name string) error { + return ensureResponseDataField(body, "id", expected, name) +} + +func ensureResponseDataField(body []byte, field, expected, name string) error { + var decoded struct { + Data map[string]any `json:"data"` + } + if err := json.Unmarshal(body, &decoded); err != nil { + return errors.New("ci preflight response is not valid JSON") + } + got, _ := decoded.Data[field].(string) + if strings.TrimSpace(got) != strings.TrimSpace(expected) { + return fmt.Errorf("ci preflight %s mismatch", name) + } + return nil +} + +func validatePreflightManifestIDs(manifest uploadManifestFile, productID, projectID, releaseID, artifactID string) error { + for index, req := range manifest.Requests { + payload, err := req.PayloadBytes() + if err != nil { + return fmt.Errorf("ci preflight manifest request %d payload cannot be read", index) + } + var value any + if err := json.Unmarshal(payload, &value); err != nil { + return fmt.Errorf("ci preflight manifest request %d payload is not valid JSON", index) + } + if err := checkManifestIDField(value, "product_id", productID, index); err != nil { + return err + } + if err := checkManifestIDField(value, "project_id", projectID, index); err != nil { + return err + } + if err := checkManifestIDField(value, "release_id", releaseID, index); err != nil { + return err + } + if err := checkManifestIDField(value, "artifact_id", artifactID, index); err != nil { + return err + } + } + return nil +} + +func checkManifestIDField(value any, field, expected string, index int) error { + found := []string{} + var walk func(any) + walk = func(node any) { + switch typed := node.(type) { + case map[string]any: + for key, child := range typed { + if key == field { + if got, ok := child.(string); ok && strings.TrimSpace(got) != "" { + found = append(found, strings.TrimSpace(got)) + } + } + walk(child) + } + case []any: + for _, child := range typed { + walk(child) + } + } + } + walk(value) + for _, got := range found { + if got != strings.TrimSpace(expected) { + return fmt.Errorf("ci preflight manifest request %d %s does not match configured value", index, field) + } + } + return nil +} + +const uploadManifestSchemaVersion = "evydence-upload-manifest.v1.0.0" + +type uploadManifestFile struct { + SchemaVersion string `json:"schema_version"` + Requests []uploadManifestRequest `json:"requests"` +} + +type uploadManifestRequest struct { + Kind string `json:"kind"` + Path string `json:"path"` + IdempotencyKey string `json:"idempotency_key"` + Payload json.RawMessage `json:"payload"` + PayloadFile string `json:"payload_file"` + payloadPath string +} + +func readAndValidateUploadManifest(path string) (uploadManifestFile, error) { + cleaned, err := cleanOperatorPath(path) + if err != nil { + return uploadManifestFile{}, err + } + // #nosec G304,G703 -- this CLI command intentionally reads a local operator-specified upload manifest. + body, err := os.ReadFile(cleaned) + if err != nil { + return uploadManifestFile{}, err + } + var manifest uploadManifestFile + dec := json.NewDecoder(bytes.NewReader(body)) + dec.DisallowUnknownFields() + if err := dec.Decode(&manifest); err != nil { + return uploadManifestFile{}, errors.New("upload manifest is not valid JSON or contains unknown fields") + } + if err := dec.Decode(&struct{}{}); err != io.EOF { + return uploadManifestFile{}, errors.New("upload manifest must contain one JSON document") + } + if manifest.SchemaVersion != "" && manifest.SchemaVersion != uploadManifestSchemaVersion { + return uploadManifestFile{}, fmt.Errorf("upload manifest schema_version must be %s", uploadManifestSchemaVersion) + } + if len(manifest.Requests) == 0 || len(manifest.Requests) > 100 { + return uploadManifestFile{}, errors.New("upload manifest must contain 1-100 requests") + } + baseDir := filepath.Dir(cleaned) + for i := range manifest.Requests { + if err := manifest.Requests[i].Validate(i, baseDir); err != nil { + return uploadManifestFile{}, err + } } - var finished *time.Time - if strings.TrimSpace(*finishedAt) != "" { - parsed, err := time.Parse(time.RFC3339, strings.TrimSpace(*finishedAt)) + return manifest, nil +} + +func (r *uploadManifestRequest) Validate(index int, baseDir string) error { + r.Kind = strings.TrimSpace(r.Kind) + r.Path = strings.TrimSpace(r.Path) + r.IdempotencyKey = strings.TrimSpace(r.IdempotencyKey) + r.PayloadFile = strings.TrimSpace(r.PayloadFile) + if !strings.HasPrefix(r.Path, "/v1/") || r.IdempotencyKey == "" { + return fmt.Errorf("upload request %d missing /v1 path or idempotency key", index) + } + if r.Kind != "" && !uploadManifestKindAllowsPath(r.Kind, r.Path) { + return fmt.Errorf("upload request %d kind %q does not allow path %q", index, r.Kind, r.Path) + } + hasInlinePayload := len(bytes.TrimSpace(r.Payload)) > 0 + hasPayloadFile := r.PayloadFile != "" + if hasInlinePayload == hasPayloadFile { + return fmt.Errorf("upload request %d must set exactly one of payload or payload_file", index) + } + if hasInlinePayload && !json.Valid(r.Payload) { + return fmt.Errorf("upload request %d payload is not valid JSON", index) + } + if hasPayloadFile { + payloadPath, err := cleanManifestPayloadPath(baseDir, r.PayloadFile) if err != nil { - return errors.New("--finished-at must use RFC3339") + return fmt.Errorf("upload request %d: %w", index, err) + } + r.payloadPath = payloadPath + // #nosec G304,G703 -- payload files are local operator-selected files constrained to the manifest directory. + payload, err := os.ReadFile(payloadPath) + if err != nil { + return fmt.Errorf("upload request %d payload_file cannot be read", index) + } + if len(bytes.TrimSpace(payload)) == 0 || !json.Valid(payload) { + return fmt.Errorf("upload request %d payload_file is not valid JSON", index) } - finished = &parsed } - outputs := []map[string]string{} - if strings.TrimSpace(*artifactID) != "" { - outputs = append(outputs, map[string]string{"artifact_id": strings.TrimSpace(*artifactID), "digest": strings.TrimSpace(*artifactDigest)}) + return nil +} + +func (r uploadManifestRequest) PayloadBytes() ([]byte, error) { + if r.payloadPath == "" { + return r.Payload, nil } - runID := envRequired("GITHUB_RUN_ID") - runAttempt := envDefault("GITHUB_RUN_ATTEMPT", "1") - commitSHA := envRequired("GITHUB_SHA") - repository := envRequired("GITHUB_REPOSITORY") - workflowRef := envRequired("GITHUB_WORKFLOW_REF") - if runID == "" || commitSHA == "" || repository == "" || workflowRef == "" { - return errors.New("GITHUB_RUN_ID, GITHUB_SHA, GITHUB_REPOSITORY, and GITHUB_WORKFLOW_REF are required") + // #nosec G304,G703 -- payload files were already validated and constrained to the manifest directory. + return os.ReadFile(r.payloadPath) +} + +func cleanManifestPayloadPath(baseDir, rel string) (string, error) { + rel = strings.TrimSpace(rel) + if rel == "" { + return "", errors.New("payload_file is required") } - payload := map[string]any{ - "project_id": strings.TrimSpace(*projectID), - "release_id": strings.TrimSpace(*releaseID), - "provider": "github_actions", - "commit_sha": commitSHA, - "repository": repository, - "workflow_ref": workflowRef, - "run_id": runID, - "run_attempt": atoiDefault(runAttempt, 1), - "job_id": strings.TrimSpace(os.Getenv("GITHUB_JOB")), - "actor": strings.TrimSpace(os.Getenv("GITHUB_ACTOR")), - "ref": strings.TrimSpace(os.Getenv("GITHUB_REF")), - "oidc_subject": strings.TrimSpace(*oidcSubject), - "status": strings.TrimSpace(*status), - "started_at": started.UTC().Format(time.RFC3339), - "finished_at": finished, - "parameters_hash": strings.TrimSpace(*parametersHash), - "environment_hash": strings.TrimSpace(*environmentHash), - "outputs": outputs, + if strings.Contains(rel, "\x00") { + return "", errors.New("payload_file contains a NUL byte") } - body, err := postEvydence(ctx, client, *apiURL, *apiKey, "/v1/builds", "github-actions-build-"+runID+"-"+runAttempt, payload) + if filepath.IsAbs(rel) { + return "", errors.New("payload_file must be relative to the manifest directory") + } + cleanRel := filepath.Clean(rel) + if cleanRel == "." || cleanRel == ".." || strings.HasPrefix(cleanRel, ".."+string(filepath.Separator)) { + return "", errors.New("payload_file must stay inside the manifest directory") + } + baseEval, err := filepath.EvalSymlinks(baseDir) if err != nil { - return err + return "", errors.New("manifest directory cannot be resolved") } - buildID, err := responseDataID(body) + full := filepath.Join(baseEval, cleanRel) + fullEval, err := filepath.EvalSymlinks(full) if err != nil { - return err + return "", errors.New("payload_file cannot be resolved") } - fmt.Println("build uploaded: " + buildID) - if strings.TrimSpace(*attestationPath) == "" { - return nil + relative, err := filepath.Rel(baseEval, fullEval) + if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) { + return "", errors.New("payload_file must stay inside the manifest directory") } - cleaned, err := cleanOperatorPath(*attestationPath) + return fullEval, nil +} + +func uploadManifestKindAllowsPath(kind, path string) bool { + switch strings.TrimSpace(kind) { + case "artifact": + return path == "/v1/artifacts" + case "sbom": + return path == "/v1/sboms" || path == "/v1/sboms/spdx" + case "scan", "vulnerability_scan": + return path == "/v1/vulnerability-scans" + case "vex": + return path == "/v1/vex" || path == "/v1/vex/cyclonedx" + case "provenance", "build", "build_attestation": + return path == "/v1/builds" || strings.HasPrefix(path, "/v1/builds/") + case "approval": + return path == "/v1/approvals" || strings.HasPrefix(path, "/v1/releases/") + case "package_export", "customer_package": + return path == "/v1/customer-packages" + case "release_bundle": + return path == "/v1/release-bundles" + case "evidence": + return path == "/v1/evidence" + default: + return false + } +} + +type releaseUploadConfig struct { + APIURL string + APIKey string + ProductID string + ProductName string + ProductSlug string + CreateProduct bool + ReleaseID string + ReleaseVersion string + CreateRelease bool + ArtifactID string + ArtifactPath string + ArtifactName string + ArtifactMediaType string + CreateArtifact bool + SBOMPath string + ScanPath string + ScanScanner string + TargetRef string + VEXPath string + CreateBundle bool + DryRun bool + IdempotencyPrefix string +} + +func uploadReleaseEvidence(ctx context.Context, client *http.Client, args []string) error { + cfg, err := parseReleaseUploadConfig(args) if err != nil { return err } - // #nosec G304,G703 -- this CLI command intentionally reads a local operator-specified attestation file. - attestation, err := os.ReadFile(cleaned) + artifact, err := releaseUploadArtifactMetadata(cfg.ArtifactPath, cfg.ArtifactName, cfg.ArtifactMediaType) if err != nil { return err } - body, err = postRawEvydence(ctx, client, *apiURL, *apiKey, "/v1/builds/"+buildID+"/attestations", "github-actions-attestation-"+buildID, attestation) + sbomPayload, err := readOptionalJSONPayload(cfg.SBOMPath, "SBOM") if err != nil { return err } - attestationID, err := responseDataID(body) + scanPayload, err := readOptionalJSONPayload(cfg.ScanPath, "vulnerability scan") if err != nil { return err } - fmt.Println("attestation uploaded: " + attestationID) + vexPayload, err := readOptionalJSONPayload(cfg.VEXPath, "VEX") + if err != nil { + return err + } + if len(sbomPayload) == 0 && len(scanPayload) == 0 && len(vexPayload) == 0 && !cfg.CreateBundle { + return errors.New("provide at least one evidence file or leave --create-bundle enabled") + } + if cfg.ArtifactPath != "" && cfg.ArtifactID == "" && !cfg.CreateArtifact { + return errors.New("--artifact-id is required when --artifact is supplied unless --create-artifact is set") + } + if cfg.ReleaseID == "" && !cfg.CreateRelease { + return errors.New("--release-id is required unless --create-release is set") + } + if cfg.ProductID == "" && (cfg.CreateRelease || cfg.CreateProduct) && !cfg.CreateProduct { + return errors.New("--product-id is required unless --create-product is set") + } + if cfg.CreateProduct && (cfg.ProductName == "" || cfg.ProductSlug == "") { + return errors.New("--product-name and --product-slug are required with --create-product") + } + if cfg.CreateRelease && cfg.ReleaseVersion == "" { + return errors.New("--release-version is required with --create-release") + } + if cfg.CreateArtifact && cfg.ArtifactPath == "" { + return errors.New("--artifact is required with --create-artifact") + } + if !cfg.DryRun && (cfg.APIURL == "" || cfg.APIKey == "") { + return usage() + } + if cfg.IdempotencyPrefix == "" { + cfg.IdempotencyPrefix = releaseUploadIdempotencyPrefix(cfg, artifact, sbomPayload, scanPayload, vexPayload) + } + productID := cfg.ProductID + if cfg.CreateProduct { + id, err := postReleaseUploadJSON(ctx, client, cfg, "/v1/products", cfg.IdempotencyPrefix+"-product", map[string]any{ + "name": cfg.ProductName, + "slug": cfg.ProductSlug, + }, "") + if err != nil { + return err + } + productID = id + } + releaseID := cfg.ReleaseID + if cfg.CreateRelease { + if productID == "" { + return errors.New("product id is required before creating a release") + } + id, err := postReleaseUploadJSON(ctx, client, cfg, "/v1/releases", cfg.IdempotencyPrefix+"-release", map[string]any{ + "product_id": productID, + "version": cfg.ReleaseVersion, + }, "") + if err != nil { + return err + } + releaseID = id + } + if releaseID == "" { + return errors.New("release id is required") + } + artifactID := cfg.ArtifactID + if cfg.CreateArtifact { + id, err := postReleaseUploadJSON(ctx, client, cfg, "/v1/artifacts", cfg.IdempotencyPrefix+"-artifact", map[string]any{ + "name": artifact.Name, + "media_type": artifact.MediaType, + "digest": artifact.Digest, + "size": artifact.Size, + }, "") + if err != nil { + return err + } + artifactID = id + } + if len(sbomPayload) > 0 { + if _, err := postReleaseUploadJSON(ctx, client, cfg, "/v1/sboms", cfg.IdempotencyPrefix+"-sbom", map[string]any{ + "release_id": releaseID, + "artifact_id": artifactID, + "payload": json.RawMessage(sbomPayload), + }, ""); err != nil { + return err + } + } + if len(scanPayload) > 0 { + payload, err := buildReleaseUploadScanPayload(scanPayload, releaseID, cfg.TargetRef, cfg.ScanScanner, artifact) + if err != nil { + return err + } + if _, err := postReleaseUploadRaw(ctx, client, cfg, "/v1/vulnerability-scans", cfg.IdempotencyPrefix+"-scan", payload, ""); err != nil { + return err + } + } + if len(vexPayload) > 0 { + if _, err := postReleaseUploadJSON(ctx, client, cfg, "/v1/vex", cfg.IdempotencyPrefix+"-vex", map[string]any{ + "release_id": releaseID, + "artifact_id": artifactID, + "payload": json.RawMessage(vexPayload), + }, ""); err != nil { + return err + } + } + if cfg.CreateBundle { + if _, err := postReleaseUploadJSON(ctx, client, cfg, "/v1/release-bundles", cfg.IdempotencyPrefix+"-release-bundle", map[string]any{ + "release_id": releaseID, + }, ""); err != nil { + return err + } + } + printReleaseUploadNextSteps(cfg.APIURL, releaseID) return nil } -func uploadEvidenceBundleImport(ctx context.Context, client *http.Client, args []string) error { - fs := flag.NewFlagSet("import-bundle upload", flag.ContinueOnError) +func parseReleaseUploadConfig(args []string) (releaseUploadConfig, error) { + fs := flag.NewFlagSet("release upload-evidence", flag.ContinueOnError) fs.SetOutput(io.Discard) - apiURL := fs.String("url", strings.TrimSpace(os.Getenv("EVYDENCE_API_URL")), "Evydence API URL") - apiKey := fs.String("api-key", strings.TrimSpace(os.Getenv("EVYDENCE_API_KEY")), "Evydence API key") - path := fs.String("path", "", "evidence bundle JSON path") - idem := fs.String("idempotency-key", "", "idempotency key") + cfg := releaseUploadConfig{CreateBundle: true, ArtifactMediaType: "application/octet-stream", ScanScanner: "generic"} + productAlias := "" + releaseAlias := "" + fs.StringVar(&cfg.APIURL, "url", strings.TrimSpace(os.Getenv("EVYDENCE_API_URL")), "Evydence API URL") + fs.StringVar(&cfg.APIKey, "api-key", strings.TrimSpace(os.Getenv("EVYDENCE_API_KEY")), "Evydence API key") + fs.StringVar(&cfg.ProductID, "product-id", "", "existing product id") + fs.StringVar(&productAlias, "product", "", "existing product id alias") + fs.StringVar(&cfg.ProductName, "product-name", "", "product name used with --create-product") + fs.StringVar(&cfg.ProductSlug, "product-slug", "", "product slug used with --create-product") + fs.BoolVar(&cfg.CreateProduct, "create-product", false, "create product before uploading evidence") + fs.StringVar(&cfg.ReleaseID, "release-id", "", "existing release id") + fs.StringVar(&releaseAlias, "release", "", "existing release id alias") + fs.StringVar(&cfg.ReleaseVersion, "release-version", "", "release version used with --create-release") + fs.BoolVar(&cfg.CreateRelease, "create-release", false, "create release before uploading evidence") + fs.StringVar(&cfg.ArtifactID, "artifact-id", "", "existing artifact id") + fs.StringVar(&cfg.ArtifactPath, "artifact", "", "artifact file path used for digest and optional registration") + fs.StringVar(&cfg.ArtifactName, "artifact-name", "", "artifact name; defaults to artifact file basename") + fs.StringVar(&cfg.ArtifactMediaType, "artifact-media-type", "application/octet-stream", "artifact media type used with --create-artifact") + fs.BoolVar(&cfg.CreateArtifact, "create-artifact", false, "register artifact before uploading evidence") + fs.StringVar(&cfg.SBOMPath, "sbom", "", "CycloneDX SBOM JSON path") + fs.StringVar(&cfg.ScanPath, "scan", "", "Evydence generic vulnerability scan JSON path") + fs.StringVar(&cfg.ScanScanner, "scan-scanner", "generic", "scanner name to use when the scan JSON omits scanner") + fs.StringVar(&cfg.TargetRef, "target-ref", "", "scan target reference; defaults to artifact digest when available") + fs.StringVar(&cfg.VEXPath, "vex", "", "OpenVEX JSON path") + fs.BoolVar(&cfg.CreateBundle, "create-bundle", true, "create a release bundle after uploads") + fs.BoolVar(&cfg.DryRun, "dry-run", false, "validate inputs and print planned requests without network calls") + fs.StringVar(&cfg.IdempotencyPrefix, "idempotency-prefix", "", "stable idempotency prefix") if err := fs.Parse(args); err != nil { - return usage() - } - if strings.TrimSpace(*apiURL) == "" || strings.TrimSpace(*apiKey) == "" || strings.TrimSpace(*path) == "" { - return usage() + return releaseUploadConfig{}, usage() + } + if fs.NArg() != 0 { + return releaseUploadConfig{}, usage() + } + cfg.APIURL = strings.TrimSpace(cfg.APIURL) + cfg.APIKey = strings.TrimSpace(cfg.APIKey) + cfg.ProductID = firstNonEmpty(cfg.ProductID, productAlias) + cfg.ReleaseID = firstNonEmpty(cfg.ReleaseID, releaseAlias) + cfg.ProductName = strings.TrimSpace(cfg.ProductName) + cfg.ProductSlug = strings.TrimSpace(cfg.ProductSlug) + cfg.ReleaseVersion = strings.TrimSpace(cfg.ReleaseVersion) + cfg.ArtifactID = strings.TrimSpace(cfg.ArtifactID) + cfg.ArtifactName = strings.TrimSpace(cfg.ArtifactName) + cfg.ArtifactMediaType = firstNonEmpty(cfg.ArtifactMediaType, "application/octet-stream") + cfg.ScanScanner = firstNonEmpty(cfg.ScanScanner, "generic") + cfg.TargetRef = strings.TrimSpace(cfg.TargetRef) + cfg.IdempotencyPrefix = strings.TrimSpace(cfg.IdempotencyPrefix) + return cfg, nil +} + +type releaseUploadArtifact struct { + Name string + MediaType string + Digest string + Size int64 +} + +func releaseUploadArtifactMetadata(path, name, mediaType string) (releaseUploadArtifact, error) { + if strings.TrimSpace(path) == "" { + return releaseUploadArtifact{Name: strings.TrimSpace(name), MediaType: firstNonEmpty(mediaType, "application/octet-stream")}, nil } - cleaned, err := cleanOperatorPath(*path) + cleaned, err := cleanOperatorPath(path) if err != nil { - return err + return releaseUploadArtifact{}, err } - // #nosec G304,G703 -- this CLI command intentionally reads a local operator-specified import bundle. - body, err := os.ReadFile(cleaned) + info, err := os.Stat(cleaned) if err != nil { - return err + return releaseUploadArtifact{}, err } - if strings.TrimSpace(*idem) == "" { - digest := sha256.Sum256(body) - *idem = "import-bundle-" + hex.EncodeToString(digest[:8]) + if info.IsDir() { + return releaseUploadArtifact{}, errors.New("--artifact must point to a file") } - response, err := postRawEvydence(ctx, client, *apiURL, *apiKey, "/v1/evidence-bundles/import", *idem, body) + digest, err := hashFile(cleaned) if err != nil { - return err + return releaseUploadArtifact{}, err } - id, err := responseDataID(response) - if err != nil { - return err + if strings.TrimSpace(name) == "" { + name = filepath.Base(cleaned) } - fmt.Println("evidence bundle import recorded: " + id) - return nil + return releaseUploadArtifact{Name: strings.TrimSpace(name), MediaType: firstNonEmpty(mediaType, "application/octet-stream"), Digest: digest, Size: info.Size()}, nil } -func uploadManifestRequests(ctx context.Context, client *http.Client, args []string) error { - fs := flag.NewFlagSet("upload manifest", flag.ContinueOnError) - fs.SetOutput(io.Discard) - apiURL := fs.String("url", strings.TrimSpace(os.Getenv("EVYDENCE_API_URL")), "Evydence API URL") - apiKey := fs.String("api-key", strings.TrimSpace(os.Getenv("EVYDENCE_API_KEY")), "Evydence API key") - manifestPath := fs.String("manifest", "", "upload manifest JSON path") - if err := fs.Parse(args); err != nil { - return usage() +func readOptionalJSONPayload(path, label string) (json.RawMessage, error) { + if strings.TrimSpace(path) == "" { + return nil, nil } - if strings.TrimSpace(*apiURL) == "" || strings.TrimSpace(*apiKey) == "" || strings.TrimSpace(*manifestPath) == "" { - return usage() + body, err := readFileStrict(path) + if err != nil { + return nil, err + } + if len(body) > 20<<20 { + return nil, fmt.Errorf("%s payload exceeds 20 MiB", label) } - cleaned, err := cleanOperatorPath(*manifestPath) + var normalized any + dec := json.NewDecoder(bytes.NewReader(body)) + dec.DisallowUnknownFields() + if err := dec.Decode(&normalized); err != nil { + return nil, fmt.Errorf("%s payload is not valid JSON", label) + } + if err := dec.Decode(&struct{}{}); err != io.EOF { + return nil, fmt.Errorf("%s payload must contain one JSON document", label) + } + canonical, err := json.Marshal(normalized) if err != nil { - return err + return nil, err } - // #nosec G304,G703 -- this CLI command intentionally reads a local operator-specified upload manifest. - body, err := os.ReadFile(cleaned) + return canonical, nil +} + +func buildReleaseUploadScanPayload(raw json.RawMessage, releaseID, targetRef, scanner string, artifact releaseUploadArtifact) ([]byte, error) { + var doc struct { + Scanner string `json:"scanner"` + TargetRef string `json:"target_ref"` + ReleaseID string `json:"release_id"` + Findings []struct { + Vulnerability string `json:"vulnerability"` + Component string `json:"component"` + Severity string `json:"severity"` + State string `json:"state"` + } `json:"findings"` + } + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&doc); err != nil { + return nil, errors.New("scan payload must use Evydence generic vulnerability scan JSON") + } + doc.Scanner = firstNonEmpty(doc.Scanner, scanner) + doc.TargetRef = firstNonEmpty(doc.TargetRef, targetRef) + if doc.TargetRef == "" && artifact.Digest != "" { + doc.TargetRef = "artifact-digest:" + artifact.Digest + } + doc.ReleaseID = releaseID + if strings.TrimSpace(doc.Scanner) == "" || strings.TrimSpace(doc.TargetRef) == "" { + return nil, errors.New("scan payload requires scanner and target_ref; use --scan-scanner and --target-ref when the file omits them") + } + if doc.Findings == nil { + return nil, errors.New("scan payload requires findings") + } + body, err := json.Marshal(doc) if err != nil { - return err + return nil, err } - var manifest struct { - Requests []struct { - Path string `json:"path"` - IdempotencyKey string `json:"idempotency_key"` - Payload json.RawMessage `json:"payload"` - PayloadFile string `json:"payload_file"` - } `json:"requests"` + return body, nil +} + +func releaseUploadIdempotencyPrefix(cfg releaseUploadConfig, artifact releaseUploadArtifact, sbom, scan, vex json.RawMessage) string { + parts := []string{ + cfg.ProductID, cfg.ProductName, cfg.ProductSlug, + cfg.ReleaseID, cfg.ReleaseVersion, + cfg.ArtifactID, artifact.Name, artifact.Digest, + hashBytes(sbom), hashBytes(scan), hashBytes(vex), + } + sum := sha256.Sum256([]byte(strings.Join(parts, "\x00"))) + return "release-evidence-" + hex.EncodeToString(sum[:8]) +} + +func postReleaseUploadJSON(ctx context.Context, client *http.Client, cfg releaseUploadConfig, path, idem string, payload any, placeholderID string) (string, error) { + body, err := json.Marshal(payload) + if err != nil { + return "", err } - if err := json.Unmarshal(body, &manifest); err != nil { - return errors.New("upload manifest is not valid JSON") + return postReleaseUploadRaw(ctx, client, cfg, path, idem, body, placeholderID) +} + +func postReleaseUploadRaw(ctx context.Context, client *http.Client, cfg releaseUploadConfig, path, idem string, body []byte, placeholderID string) (string, error) { + if cfg.DryRun { + fmt.Printf("dry-run: would POST %s idempotency=%s payload_hash=%s\n", path, idem, hashBytes(body)) + return placeholderID, nil } - if len(manifest.Requests) == 0 || len(manifest.Requests) > 100 { - return errors.New("upload manifest must contain 1-100 requests") + response, err := postRawEvydence(ctx, client, cfg.APIURL, cfg.APIKey, path, idem, body) + if err != nil { + return "", err } - baseDir := filepath.Dir(cleaned) - for i, req := range manifest.Requests { - path := strings.TrimSpace(req.Path) - idem := strings.TrimSpace(req.IdempotencyKey) - if !strings.HasPrefix(path, "/v1/") || idem == "" { - return fmt.Errorf("upload request %d missing /v1 path or idempotency key", i) - } - payload := req.Payload - if strings.TrimSpace(req.PayloadFile) != "" { - payloadPath, err := cleanOperatorPath(filepath.Join(baseDir, req.PayloadFile)) - if err != nil { - return err - } - // #nosec G304,G703 -- payload files are local operator-selected files referenced by the manifest. - payload, err = os.ReadFile(payloadPath) - if err != nil { - return err - } - } - if len(bytes.TrimSpace(payload)) == 0 { - return fmt.Errorf("upload request %d has empty payload", i) - } - response, err := postRawEvydence(ctx, client, *apiURL, *apiKey, path, idem, payload) - if err != nil { - return err - } - id, err := responseDataID(response) - if err != nil { - return err + id, err := responseDataID(response) + if err != nil { + return "", err + } + fmt.Println("uploaded " + path + ": " + id) + return id, nil +} + +func printReleaseUploadNextSteps(apiURL, releaseID string) { + reference := "/v1/reports/release-readiness?release_id=" + releaseID + if strings.TrimSpace(apiURL) != "" { + if base, err := cleanAPIURL(apiURL); err == nil { + reference = base + "/v1/reports/release-readiness?release_id=" + url.QueryEscape(releaseID) } - fmt.Println("uploaded " + path + ": " + id) } - return nil + fmt.Println("next: read release readiness at " + reference) + fmt.Println("next: verify release bundle with GET /v1/release-bundles/{id}/verify after bundle creation") } func createReleaseArtifactManifest(args []string) error { @@ -603,6 +2002,39 @@ func canonicalFileHash(path string) ([]byte, string, error) { return canonical, "sha256:" + hex.EncodeToString(sum[:]), nil } +func canonicalJSONHash(value any) (string, error) { + body, err := json.Marshal(value) + if err != nil { + return "", err + } + var normalized any + if err := json.Unmarshal(body, &normalized); err != nil { + return "", err + } + body, err = json.Marshal(normalized) + if err != nil { + return "", err + } + return hashBytes(body), nil +} + +func hashBytes(body []byte) string { + sum := sha256.Sum256(body) + return "sha256:" + hex.EncodeToString(sum[:]) +} + +func hashString(value string) string { + return hashBytes([]byte(value)) +} + +func decodeBase64Flexible(value string) ([]byte, error) { + value = strings.TrimSpace(value) + if decoded, err := base64.RawStdEncoding.DecodeString(value); err == nil { + return decoded, nil + } + return base64.StdEncoding.DecodeString(value) +} + func verifyReleaseArtifactFiles(manifestPath string, _ []byte) error { body, err := readFileStrict(manifestPath) if err != nil { @@ -676,6 +2108,38 @@ func postRawEvydence(ctx context.Context, client *http.Client, apiURL, apiKey, p return responseBody, nil } +func getEvydence(ctx context.Context, client *http.Client, apiURL, apiKey, path string) ([]byte, error) { + if client == nil { + client = http.DefaultClient + } + baseURL, err := cleanAPIURL(apiURL) + if err != nil { + return nil, err + } + // #nosec G704 -- this CLI intentionally sends requests to an operator-specified Evydence API URL after scheme and host validation. + req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+path, nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(apiKey)) + // #nosec G704 -- request target is the validated operator-specified Evydence API URL for this CLI command. + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer func() { + _ = resp.Body.Close() + }() + responseBody, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return nil, err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, safeAPIError(resp.StatusCode, responseBody) + } + return responseBody, nil +} + func cleanAPIURL(raw string) (string, error) { parsed, err := url.Parse(strings.TrimSpace(raw)) if err != nil { @@ -692,6 +2156,19 @@ func cleanAPIURL(raw string) (string, error) { return strings.TrimRight(parsed.String(), "/"), nil } +type apiRequestError struct { + status int + code string + detail string +} + +func (e apiRequestError) Error() string { + if e.code != "" { + return fmt.Sprintf("evydence API request failed: status=%d code=%s detail=%s", e.status, e.code, e.detail) + } + return fmt.Sprintf("evydence API request failed: status=%d detail=%s", e.status, e.detail) +} + func safeAPIError(status int, body []byte) error { var problem struct { Detail string `json:"detail"` @@ -709,10 +2186,7 @@ func safeAPIError(status int, body []byte) error { if detail == "" { detail = http.StatusText(status) } - if code != "" { - return fmt.Errorf("evydence API request failed: status=%d code=%s detail=%s", status, code, detail) - } - return fmt.Errorf("evydence API request failed: status=%d detail=%s", status, detail) + return apiRequestError{status: status, code: code, detail: detail} } func responseDataID(body []byte) (string, error) { diff --git a/cmd/evydence/main_test.go b/cmd/evydence/main_test.go index 54ddf9a..d2ef4b2 100644 --- a/cmd/evydence/main_test.go +++ b/cmd/evydence/main_test.go @@ -1,17 +1,21 @@ package main import ( + "archive/zip" "crypto/ed25519" "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/hex" "encoding/json" + "errors" + "io" "net/http" "net/http/httptest" "os" "strings" "testing" + "time" ) func TestCleanOperatorPathRejectsNUL(t *testing.T) { @@ -75,6 +79,183 @@ func TestUploadManifestPostsRequests(t *testing.T) { } } +func TestValidateUploadManifestCommand(t *testing.T) { + dir := t.TempDir() + payloadPath := writeTestFile(t, dir+"/artifact.json", []byte(`{"name":"api","digest":"sha256:ca978112ca1bbdcafac231b39a23dc4da786eff8147c4e72b9807785afee48bb","size":1}`)) + _ = payloadPath + manifestPath := dir + "/upload.json" + body, err := json.Marshal(map[string]any{ + "schema_version": uploadManifestSchemaVersion, + "requests": []map[string]any{{ + "kind": "artifact", + "path": "/v1/artifacts", + "idempotency_key": "artifact-1", + "payload_file": "artifact.json", + }}, + }) + if err != nil { + t.Fatalf("marshal manifest: %v", err) + } + if err := os.WriteFile(manifestPath, body, 0o600); err != nil { + t.Fatalf("write manifest: %v", err) + } + out, err := captureStdout(t, func() error { + return validateUploadManifestCommand([]string{"--manifest", manifestPath}) + }) + if err != nil { + t.Fatalf("validate manifest: %v", err) + } + if !strings.Contains(out, "upload manifest valid: 1 requests") { + t.Fatalf("unexpected validate output: %s", out) + } +} + +func TestCIPreflightVerifiesAPIAndManifestWithoutSecretLeakage(t *testing.T) { + dir := t.TempDir() + manifestPath := dir + "/upload.json" + body, err := json.Marshal(map[string]any{ + "schema_version": uploadManifestSchemaVersion, + "requests": []map[string]any{{ + "kind": "sbom", + "path": "/v1/sboms", + "idempotency_key": "sbom-1", + "payload": map[string]any{"release_id": "rel_1", "artifact_id": "art_1", "payload": map[string]any{"bomFormat": "CycloneDX"}}, + }, { + "kind": "release_bundle", + "path": "/v1/release-bundles", + "idempotency_key": "bundle-1", + "payload": map[string]any{"release_id": "rel_1"}, + }}, + }) + if err != nil { + t.Fatalf("marshal manifest: %v", err) + } + if err := os.WriteFile(manifestPath, body, 0o600); err != nil { + t.Fatalf("write manifest: %v", err) + } + seen := map[string]bool{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Authorization"); got != "Bearer evy_secret_should_not_print" { + t.Fatalf("authorization header=%q", got) + } + seen[r.URL.Path] = true + switch r.URL.Path { + case "/v1/products/prod_1": + _, _ = w.Write([]byte(`{"data":{"id":"prod_1","tenant_id":"ten_1","name":"Payments","slug":"payments"},"meta":{"api_version":"v1"}}`)) + case "/v1/projects/proj_1": + _, _ = w.Write([]byte(`{"data":{"id":"proj_1","tenant_id":"ten_1","product_id":"prod_1","name":"api"},"meta":{"api_version":"v1"}}`)) + case "/v1/releases/rel_1": + _, _ = w.Write([]byte(`{"data":{"id":"rel_1","tenant_id":"ten_1","product_id":"prod_1","version":"1.0.0","state":"open"},"meta":{"api_version":"v1"}}`)) + case "/v1/artifacts/art_1": + _, _ = w.Write([]byte(`{"data":{"id":"art_1","tenant_id":"ten_1","name":"api.tar.gz","digest":"sha256:abc"},"meta":{"api_version":"v1"}}`)) + default: + t.Fatalf("unexpected path %s", r.URL.Path) + } + })) + defer server.Close() + out, err := captureStdout(t, func() error { + return ciPreflight(t.Context(), server.Client(), []string{ + "--url", server.URL, + "--api-key", "evy_secret_should_not_print", + "--product-id", "prod_1", + "--project-id", "proj_1", + "--release-id", "rel_1", + "--artifact-id", "art_1", + "--manifest", manifestPath, + }) + }) + if err != nil { + t.Fatalf("ci preflight: %v", err) + } + for _, path := range []string{"/v1/products/prod_1", "/v1/projects/proj_1", "/v1/releases/rel_1", "/v1/artifacts/art_1"} { + if !seen[path] { + t.Fatalf("missing preflight request %s, saw %#v", path, seen) + } + } + if !strings.Contains(out, "ci preflight ok") || strings.Contains(out, "evy_secret_should_not_print") { + t.Fatalf("unsafe or incomplete output: %s", out) + } +} + +func TestCIPreflightMapsStableExitCodesAndRedactsSecrets(t *testing.T) { + dir := t.TempDir() + badManifest := dir + "/bad.json" + if err := os.WriteFile(badManifest, []byte(`{"requests":[]}`), 0o600); err != nil { + t.Fatalf("write manifest: %v", err) + } + err := ciPreflight(t.Context(), http.DefaultClient, []string{"--url", "https://example.test", "--api-key", "evy_secret", "--product-id", "prod_1", "--project-id", "proj_1", "--release-id", "rel_1", "--artifact-id", "art_1", "--manifest", badManifest}) + assertCLIExitCode(t, err, exitCIPreflightInvalidManifest) + + statusTests := []struct { + status int + code int + }{ + {status: http.StatusUnauthorized, code: exitCIPreflightAuthFailure}, + {status: http.StatusForbidden, code: exitCIPreflightWrongScope}, + {status: http.StatusNotFound, code: exitCIPreflightWrongTenant}, + } + for _, tt := range statusTests { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, `{"code":"TEST","detail":"safe failure"}`, tt.status) + })) + err := ciPreflight(t.Context(), server.Client(), []string{"--url", server.URL, "--api-key", "evy_secret_should_not_print", "--product-id", "prod_1", "--project-id", "proj_1", "--release-id", "rel_1", "--artifact-id", "art_1", "--manifest", validPreflightManifest(t, dir)}) + server.Close() + assertCLIExitCode(t, err, tt.code) + if strings.Contains(err.Error(), "evy_secret_should_not_print") { + t.Fatalf("preflight error leaked secret: %v", err) + } + } + assertCLIExitCode(t, ciPreflight(t.Context(), http.DefaultClient, []string{"--url", "https://example.test"}), exitCIPreflightMissingConfig) +} + +func TestUploadManifestValidationRejectsUnsafeInputs(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir+"/payload.json", []byte(`{"release_id":"rel_1"}`)) + tests := []struct { + name string + body string + want string + }{ + { + name: "unknown schema", + body: `{"schema_version":"other","requests":[{"path":"/v1/release-bundles","idempotency_key":"k","payload":{"release_id":"rel_1"}}]}`, + want: "schema_version", + }, + { + name: "unknown field", + body: `{"schema_version":"evydence-upload-manifest.v1.0.0","unexpected":true,"requests":[{"path":"/v1/release-bundles","idempotency_key":"k","payload":{"release_id":"rel_1"}}]}`, + want: "unknown fields", + }, + { + name: "both payload sources", + body: `{"requests":[{"path":"/v1/release-bundles","idempotency_key":"k","payload":{"release_id":"rel_1"},"payload_file":"payload.json"}]}`, + want: "exactly one", + }, + { + name: "payload traversal", + body: `{"requests":[{"path":"/v1/release-bundles","idempotency_key":"k","payload_file":"../payload.json"}]}`, + want: "inside the manifest directory", + }, + { + name: "kind mismatch", + body: `{"requests":[{"kind":"sbom","path":"/v1/vex","idempotency_key":"k","payload":{"release_id":"rel_1"}}]}`, + want: "does not allow path", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + manifestPath := dir + "/" + strings.ReplaceAll(tt.name, " ", "-") + ".json" + if err := os.WriteFile(manifestPath, []byte(tt.body), 0o600); err != nil { + t.Fatalf("write manifest: %v", err) + } + err := validateUploadManifestCommand([]string{"--manifest", manifestPath}) + if err == nil || !strings.Contains(err.Error(), tt.want) { + t.Fatalf("err=%v want %q", err, tt.want) + } + }) + } +} + func TestImportBundleUploadPostsImport(t *testing.T) { dir := t.TempDir() bundlePath := dir + "/bundle.json" @@ -118,9 +299,164 @@ func TestVerifyEvidenceBundle(t *testing.T) { } } +func TestVerifyEvidenceBundleChecksIncludedSignature(t *testing.T) { + manifest := map[string]any{"bundle_version": "evidence-bundle.v1.0.0", "evidence_ids": []any{"ev_1"}} + canonical, err := json.Marshal(manifest) + if err != nil { + t.Fatalf("marshal manifest: %v", err) + } + sum := sha256.Sum256(canonical) + manifestHash := "sha256:" + hex.EncodeToString(sum[:]) + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("keygen: %v", err) + } + bundleBody, err := json.Marshal(map[string]any{ + "manifest": manifest, + "manifest_hash": manifestHash, + "signature_refs": []string{"sig_1"}, + "signatures": []map[string]any{{ + "id": "sig_1", + "key_id": "sk_1", + "algorithm": "Ed25519", + "value": base64.RawStdEncoding.EncodeToString(ed25519.Sign(priv, []byte(manifestHash))), + }}, + "signing_keys": []map[string]any{{ + "id": "sk_1", + "algorithm": "Ed25519", + "status": "active", + "public_key": base64.RawStdEncoding.EncodeToString(pub), + }}, + }) + if err != nil { + t.Fatalf("marshal bundle: %v", err) + } + path := t.TempDir() + "/bundle.json" + if err := os.WriteFile(path, bundleBody, 0o600); err != nil { + t.Fatalf("write bundle: %v", err) + } + if err := verifyEvidenceBundle(path); err != nil { + t.Fatalf("verify signed bundle: %v", err) + } + tampered := strings.Replace(string(bundleBody), "sig_1", "sig_2", 1) + if err := os.WriteFile(path, []byte(tampered), 0o600); err != nil { + t.Fatalf("write tampered bundle: %v", err) + } + if err := verifyEvidenceBundle(path); err == nil || !strings.Contains(err.Error(), "signature verification failed") { + t.Fatalf("tampered signature refs err=%v", err) + } +} + +func TestVerifyCustomerPackageManifestArchiveAndBundle(t *testing.T) { + manifest := testCustomerPackageManifest() + body, err := json.Marshal(manifest) + if err != nil { + t.Fatalf("marshal manifest: %v", err) + } + hash, err := canonicalJSONBytesHash(body) + if err != nil { + t.Fatalf("manifest hash: %v", err) + } + dir := t.TempDir() + manifestPath := dir + "/manifest.json" + if err := os.WriteFile(manifestPath, body, 0o600); err != nil { + t.Fatalf("write manifest: %v", err) + } + decisionExport := mustMarshalJSON(t, map[string]any{ + "schema_version": "customer-vulnerability-decisions.v1.0.0", + "package_id": "csp_1", + "product_id": "prod_1", + "release_id": "rel_1", + "source_manifest_hash": hash, + "decisions": []any{map[string]any{"id": "vd_1", "vulnerability": "CVE-2026-0001", "status": "fixed", "impact_statement": "Customer-safe impact."}}, + "assumptions": []any{"Package-scoped decisions only."}, + "limitations": []any{"This export does not prove legal compliance or complete vulnerability coverage."}, + "generated_at": "2026-05-28T12:00:00Z", + }) + archivePath := writeTestCustomerPackageArchive(t, dir+"/package.zip", body, hash, "csp_1", decisionExport) + bundlePath := writeSignedEvidenceBundle(t, dir+"/evidence-bundle.json", []any{"ev_1"}, "sk_1") + if err := verifyCustomerPackage([]string{ + "--manifest", manifestPath, + "--archive", archivePath, + "--bundle", bundlePath, + "--hash", hash, + "--expected-tenant-id", "ten_1", + "--expected-product-id", "prod_1", + "--expected-release-id", "rel_1", + "--expected-package-id", "csp_1", + "--expected-signing-key-id", "sk_1", + }); err != nil { + t.Fatalf("verify customer package: %v", err) + } + + badArchivePath := writeTestCustomerPackageArchive(t, dir+"/package-bad.zip", body, "sha256:"+strings.Repeat("0", 64), "csp_1") + if err := verifyCustomerPackage([]string{"--archive", badArchivePath}); err == nil || !strings.Contains(err.Error(), "manifest_hash mismatch") { + t.Fatalf("bad archive err=%v", err) + } +} + +func TestVerifyCustomerPackageRejectsSensitiveFieldsAndMismatches(t *testing.T) { + manifest := testCustomerPackageManifest() + manifest["payload_ref"] = "objects/ten_1/raw-secret.json" + body, err := json.Marshal(manifest) + if err != nil { + t.Fatalf("marshal manifest: %v", err) + } + path := t.TempDir() + "/manifest.json" + if err := os.WriteFile(path, body, 0o600); err != nil { + t.Fatalf("write manifest: %v", err) + } + if err := verifyCustomerPackage([]string{"--manifest", path}); err == nil || !strings.Contains(err.Error(), "prohibited field payload_ref") { + t.Fatalf("sensitive field err=%v", err) + } + + delete(manifest, "payload_ref") + body, err = json.Marshal(manifest) + if err != nil { + t.Fatalf("marshal safe manifest: %v", err) + } + if err := os.WriteFile(path, body, 0o600); err != nil { + t.Fatalf("write safe manifest: %v", err) + } + if err := verifyCustomerPackage([]string{"--manifest", path, "--expected-tenant-id", "ten_other"}); err == nil || !strings.Contains(err.Error(), "tenant id mismatch") { + t.Fatalf("tenant mismatch err=%v", err) + } +} + +func TestVerifyAuditChainDetectsHashTampering(t *testing.T) { + first := testAuditEntry(t, "", 1) + second := testAuditEntry(t, first["entry_hash"].(string), 2) + path := t.TempDir() + "/chain.json" + body, err := json.Marshal(map[string]any{"entries": []map[string]any{first, second}}) + if err != nil { + t.Fatalf("marshal chain: %v", err) + } + if err := os.WriteFile(path, body, 0o600); err != nil { + t.Fatalf("write chain: %v", err) + } + if err := verifyAuditChain(path); err != nil { + t.Fatalf("verify chain: %v", err) + } + second["previous_entry_hash"] = "sha256:" + strings.Repeat("0", 64) + body, err = json.Marshal([]map[string]any{first, second}) + if err != nil { + t.Fatalf("marshal tampered chain: %v", err) + } + if err := os.WriteFile(path, body, 0o600); err != nil { + t.Fatalf("write tampered chain: %v", err) + } + if err := verifyAuditChain(path); err == nil || !strings.Contains(err.Error(), "previous hash") { + t.Fatalf("tampered chain err=%v", err) + } +} + func TestGitHubActionsUploadBuildRequiresGitHubMetadata(t *testing.T) { t.Setenv("EVYDENCE_API_URL", "http://127.0.0.1") t.Setenv("EVYDENCE_API_KEY", "evy_secret") + t.Setenv("GITHUB_RUN_ID", "") + t.Setenv("GITHUB_SHA", "") + t.Setenv("GITHUB_REPOSITORY", "") + t.Setenv("GITHUB_WORKFLOW_REF", "") err := uploadGitHubActionsBuild(t.Context(), http.DefaultClient, []string{"--project-id", "proj_1", "--release-id", "rel_1"}) if err == nil || !strings.Contains(err.Error(), "GITHUB_RUN_ID") { t.Fatalf("err=%v, want missing GitHub metadata error", err) @@ -196,3 +532,543 @@ func TestGitHubActionsUploadBuildPostsBuildAndAttestationSafely(t *testing.T) { t.Fatalf("sawBuild=%v sawAttestation=%v", sawBuild, sawAttestation) } } + +func TestReleaseUploadEvidenceDryRunValidatesFilesAndPrintsNextSteps(t *testing.T) { + dir := t.TempDir() + artifactPath := writeTestFile(t, dir+"/api.tar.gz", []byte("artifact")) + sbomPath := writeTestFile(t, dir+"/sbom.json", []byte(`{"bomFormat":"CycloneDX","specVersion":"1.6","components":[{"name":"api","purl":"pkg:github/acme/api@abc"}]}`)) + scanPath := writeTestFile(t, dir+"/scan.json", []byte(`{"findings":[]}`)) + vexPath := writeTestFile(t, dir+"/vex.json", []byte(`{"@context":"https://openvex.dev/ns/v0.2.0","@id":"https://example.test/vex","author":"security@example.test","timestamp":"2026-05-27T12:00:00Z","version":1,"statements":[{"vulnerability":{"name":"CVE-2026-0001"},"products":[{"@id":"pkg:github/acme/api@abc"}],"status":"not_affected","justification":"component_not_present","impact_statement":"not shipped","action_statement":"none"}]}`)) + t.Setenv("EVYDENCE_API_KEY", "evy_secret_should_not_print") + + out, err := captureStdout(t, func() error { + return uploadReleaseEvidence(t.Context(), http.DefaultClient, []string{ + "--dry-run", + "--product-id", "prod_1", + "--release-id", "rel_1", + "--artifact-id", "art_1", + "--artifact", artifactPath, + "--sbom", sbomPath, + "--scan", scanPath, + "--scan-scanner", "generic", + "--target-ref", "pkg:github/acme/api@abc", + "--vex", vexPath, + }) + }) + if err != nil { + t.Fatalf("dry-run upload: %v", err) + } + for _, expected := range []string{"/v1/sboms", "/v1/vulnerability-scans", "/v1/vex", "/v1/release-bundles", "/v1/reports/release-readiness?release_id=rel_1"} { + if !strings.Contains(out, expected) { + t.Fatalf("dry-run output missing %q:\n%s", expected, out) + } + } + if strings.Contains(out, "evy_secret_should_not_print") { + t.Fatalf("dry-run output leaked API key: %s", out) + } +} + +func TestReleaseUploadEvidenceCreatesMissingResourcesAndUploads(t *testing.T) { + dir := t.TempDir() + artifactPath := writeTestFile(t, dir+"/api.tar.gz", []byte("artifact")) + sbomPath := writeTestFile(t, dir+"/sbom.json", []byte(`{"bomFormat":"CycloneDX","specVersion":"1.6","components":[{"name":"api","purl":"pkg:github/acme/api@abc"}]}`)) + scanPath := writeTestFile(t, dir+"/scan.json", []byte(`{"scanner":"generic","findings":[]}`)) + vexPath := writeTestFile(t, dir+"/vex.json", []byte(`{"@context":"https://openvex.dev/ns/v0.2.0","@id":"https://example.test/vex","author":"security@example.test","timestamp":"2026-05-27T12:00:00Z","version":1,"statements":[{"vulnerability":{"name":"CVE-2026-0001"},"products":[{"@id":"pkg:github/acme/api@abc"}],"status":"fixed","justification":"fixed","impact_statement":"patched","action_statement":"upgrade"}]}`)) + seen := []string{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Authorization"); got != "Bearer evy_secret" { + t.Fatalf("authorization header=%q", got) + } + if !strings.HasPrefix(r.Header.Get("Idempotency-Key"), "one-shot-") { + t.Fatalf("idempotency key=%q", r.Header.Get("Idempotency-Key")) + } + seen = append(seen, r.URL.Path) + var payload map[string]any + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Fatalf("decode payload for %s: %v", r.URL.Path, err) + } + switch r.URL.Path { + case "/v1/products": + if payload["name"] != "Payments" || payload["slug"] != "payments" { + t.Fatalf("product payload=%#v", payload) + } + _, _ = w.Write([]byte(`{"data":{"id":"prod_1"},"meta":{"api_version":"v1"}}`)) + case "/v1/releases": + if payload["product_id"] != "prod_1" || payload["version"] != "1.0.0" { + t.Fatalf("release payload=%#v", payload) + } + _, _ = w.Write([]byte(`{"data":{"id":"rel_1"},"meta":{"api_version":"v1"}}`)) + case "/v1/artifacts": + if payload["name"] != "api.tar.gz" || !strings.HasPrefix(payload["digest"].(string), "sha256:") { + t.Fatalf("artifact payload=%#v", payload) + } + _, _ = w.Write([]byte(`{"data":{"id":"art_1"},"meta":{"api_version":"v1"}}`)) + case "/v1/sboms": + if payload["release_id"] != "rel_1" || payload["artifact_id"] != "art_1" { + t.Fatalf("sbom payload=%#v", payload) + } + _, _ = w.Write([]byte(`{"data":{"id":"sbom_1"},"meta":{"api_version":"v1"}}`)) + case "/v1/vulnerability-scans": + if payload["release_id"] != "rel_1" || payload["target_ref"] != "pkg:github/acme/api@abc" { + t.Fatalf("scan payload=%#v", payload) + } + _, _ = w.Write([]byte(`{"data":{"id":"scan_1"},"meta":{"api_version":"v1"}}`)) + case "/v1/vex": + if payload["release_id"] != "rel_1" || payload["artifact_id"] != "art_1" { + t.Fatalf("vex payload=%#v", payload) + } + _, _ = w.Write([]byte(`{"data":{"id":"vex_1"},"meta":{"api_version":"v1"}}`)) + case "/v1/release-bundles": + if payload["release_id"] != "rel_1" { + t.Fatalf("bundle payload=%#v", payload) + } + _, _ = w.Write([]byte(`{"data":{"id":"bundle_1"},"meta":{"api_version":"v1"}}`)) + default: + t.Fatalf("unexpected path %s", r.URL.Path) + } + })) + defer server.Close() + + if err := uploadReleaseEvidence(t.Context(), server.Client(), []string{ + "--url", server.URL, + "--api-key", "evy_secret", + "--create-product", + "--product-name", "Payments", + "--product-slug", "payments", + "--create-release", + "--release-version", "1.0.0", + "--create-artifact", + "--artifact", artifactPath, + "--sbom", sbomPath, + "--scan", scanPath, + "--target-ref", "pkg:github/acme/api@abc", + "--vex", vexPath, + "--idempotency-prefix", "one-shot", + }); err != nil { + t.Fatalf("upload evidence: %v", err) + } + want := []string{"/v1/products", "/v1/releases", "/v1/artifacts", "/v1/sboms", "/v1/vulnerability-scans", "/v1/vex", "/v1/release-bundles"} + if strings.Join(seen, ",") != strings.Join(want, ",") { + t.Fatalf("paths=%v want=%v", seen, want) + } +} + +func TestReleaseUploadEvidenceRequiresExplicitCreateFlags(t *testing.T) { + dir := t.TempDir() + sbomPath := writeTestFile(t, dir+"/sbom.json", []byte(`{"bomFormat":"CycloneDX","specVersion":"1.6","components":[{"name":"api"}]}`)) + err := uploadReleaseEvidence(t.Context(), http.DefaultClient, []string{"--dry-run", "--sbom", sbomPath}) + if err == nil || !strings.Contains(err.Error(), "--release-id") { + t.Fatalf("missing release err=%v", err) + } + artifactPath := writeTestFile(t, dir+"/api.tar.gz", []byte("artifact")) + err = uploadReleaseEvidence(t.Context(), http.DefaultClient, []string{"--dry-run", "--release-id", "rel_1", "--artifact", artifactPath, "--sbom", sbomPath}) + if err == nil || !strings.Contains(err.Error(), "--artifact-id") { + t.Fatalf("missing artifact err=%v", err) + } +} + +func TestUsageRunAndManifestVerificationHelpers(t *testing.T) { + if err := usage(); err == nil || !strings.Contains(err.Error(), "evydence hash") { + t.Fatalf("usage err=%v", err) + } + if err := run(nil); err == nil || !strings.Contains(err.Error(), "usage") { + t.Fatalf("empty run err=%v", err) + } + if err := run([]string{"unknown"}); err == nil || !strings.Contains(err.Error(), "usage") { + t.Fatalf("unknown run err=%v", err) + } + + manifest := map[string]any{"name": "release", "artifacts": []any{}} + canonical, err := json.Marshal(manifest) + if err != nil { + t.Fatalf("marshal manifest: %v", err) + } + sum := sha256.Sum256(canonical) + path := t.TempDir() + "/manifest.json" + if err := os.WriteFile(path, []byte(`{"artifacts":[],"name":"release"}`), 0o600); err != nil { + t.Fatalf("write manifest: %v", err) + } + if err := verifyManifest(path, "sha256:"+hex.EncodeToString(sum[:])); err != nil { + t.Fatalf("verify manifest: %v", err) + } + if err := verifyManifest(path, hex.EncodeToString(sum[:])); err == nil || !strings.Contains(err.Error(), "sha256") { + t.Fatalf("bad expected hash err=%v", err) + } + if err := verifyManifest(path, "sha256:"+strings.Repeat("0", 64)); err == nil || !strings.Contains(err.Error(), "mismatch") { + t.Fatalf("hash mismatch err=%v", err) + } +} + +func writeTestFile(t *testing.T, path string, body []byte) string { + t.Helper() + if err := os.WriteFile(path, body, 0o600); err != nil { + t.Fatalf("write %s: %v", path, err) + } + return path +} + +func captureStdout(t *testing.T, fn func() error) (string, error) { + t.Helper() + old := os.Stdout + reader, writer, err := os.Pipe() + if err != nil { + t.Fatalf("pipe: %v", err) + } + os.Stdout = writer + runErr := fn() + if err := writer.Close(); err != nil { + t.Fatalf("close writer: %v", err) + } + os.Stdout = old + out, err := io.ReadAll(reader) + if err != nil { + t.Fatalf("read stdout: %v", err) + } + if err := reader.Close(); err != nil { + t.Fatalf("close reader: %v", err) + } + return string(out), runErr +} + +func TestGenerateReleaseSigningKeyWritesBase64Keys(t *testing.T) { + dir := t.TempDir() + privatePath := dir + "/private.key" + publicPath := dir + "/public.key" + if err := generateReleaseSigningKey([]string{"--private-out", privatePath, "--public-out", publicPath}); err != nil { + t.Fatalf("keygen: %v", err) + } + privateKey, err := readBase64File(privatePath, ed25519.PrivateKeySize) + if err != nil { + t.Fatalf("private key: %v", err) + } + publicKey, err := readBase64File(publicPath, ed25519.PublicKeySize) + if err != nil { + t.Fatalf("public key: %v", err) + } + if !ed25519.PrivateKey(privateKey).Public().(ed25519.PublicKey).Equal(ed25519.PublicKey(publicKey)) { + t.Fatal("public key does not match private key") + } +} + +func TestSafeAPIErrorUsesProblemCodeWithoutLeakingRawFallbackBody(t *testing.T) { + err := safeAPIError(http.StatusConflict, []byte(`{"code":"IDEMPOTENCY_KEY_REUSED","detail":"same key changed content"}`)) + if err == nil || !strings.Contains(err.Error(), "IDEMPOTENCY_KEY_REUSED") || !strings.Contains(err.Error(), "same key changed content") { + t.Fatalf("problem error=%v", err) + } + err = safeAPIError(http.StatusForbidden, []byte(`bearer token secret`)) + if err == nil || !strings.Contains(err.Error(), "Forbidden") || strings.Contains(err.Error(), "secret") { + t.Fatalf("fallback error leaked body or missed status text: %v", err) + } +} + +func TestResponseAndURLHelpersValidateInputs(t *testing.T) { + if got, err := cleanAPIURL("https://example.test/api?token=secret#frag"); err != nil || got != "https://example.test/api" { + t.Fatalf("cleanAPIURL got=%q err=%v", got, err) + } + if _, err := cleanAPIURL("file:///tmp/evydence"); err == nil || !strings.Contains(err.Error(), "http") { + t.Fatalf("file URL err=%v", err) + } + if _, err := responseDataID([]byte(`{"data":{}}`)); err == nil || !strings.Contains(err.Error(), "data.id") { + t.Fatalf("missing id err=%v", err) + } + if _, err := responseDataID([]byte(`not-json`)); err == nil { + t.Fatal("expected JSON decode error") + } + if got := atoiDefault(" 3 ", 1); got != 3 { + t.Fatalf("atoi configured = %d", got) + } + if got := atoiDefault("nope", 1); got != 1 { + t.Fatalf("atoi fallback = %d", got) + } + if _, err := cleanOperatorPath("\x00"); err == nil { + t.Fatal("expected NUL path error") + } +} + +func TestRunCoversHashBundleAndReleaseCommands(t *testing.T) { + dir := t.TempDir() + artifact := dir + "/artifact.bin" + if err := os.WriteFile(artifact, []byte("binary"), 0o600); err != nil { + t.Fatalf("write artifact: %v", err) + } + if err := run([]string{"hash", artifact}); err != nil { + t.Fatalf("run hash: %v", err) + } + manifest := dir + "/manifest.json" + if err := run([]string{"release", "manifest", "--out", manifest, artifact}); err != nil { + t.Fatalf("run release manifest: %v", err) + } + privateKey := dir + "/private.key" + publicKey := dir + "/public.key" + if err := run([]string{"release", "keygen", "--private-out", privateKey, "--public-out", publicKey}); err != nil { + t.Fatalf("run release keygen: %v", err) + } + signature := dir + "/manifest.sig.json" + if err := run([]string{"release", "sign", "--manifest", manifest, "--private-key", privateKey, "--out", signature}); err != nil { + t.Fatalf("run release sign: %v", err) + } + if err := run([]string{"release", "verify", "--manifest", manifest, "--signature", signature}); err != nil { + t.Fatalf("run release verify: %v", err) + } + var decoded map[string]any + canonical, hash, err := canonicalFileHash(manifest) + if err != nil { + t.Fatalf("canonical hash: %v", err) + } + if err := json.Unmarshal(canonical, &decoded); err != nil || decoded["schema_version"] == "" { + t.Fatalf("canonical manifest decode=%#v err=%v", decoded, err) + } + if err := run([]string{"verify-manifest", manifest, "--hash", hash}); err != nil { + t.Fatalf("run verify manifest: %v", err) + } + bundleManifest := map[string]any{"schema_version": "evidence-bundle.v1.0.0", "evidence_ids": []any{"ev_1"}} + bundleBody, err := json.Marshal(bundleManifest) + if err != nil { + t.Fatalf("marshal bundle manifest: %v", err) + } + sum := sha256.Sum256(bundleBody) + bundlePath := dir + "/bundle.json" + body, err := json.Marshal(map[string]any{"manifest": bundleManifest, "manifest_hash": "sha256:" + hex.EncodeToString(sum[:])}) + if err != nil { + t.Fatalf("marshal bundle: %v", err) + } + if err := os.WriteFile(bundlePath, body, 0o600); err != nil { + t.Fatalf("write bundle: %v", err) + } + if err := run([]string{"verify-evidence-bundle", bundlePath}); err != nil { + t.Fatalf("run verify evidence bundle: %v", err) + } + customerManifestBody, err := json.Marshal(testCustomerPackageManifest()) + if err != nil { + t.Fatalf("marshal customer package manifest: %v", err) + } + customerManifest := dir + "/customer-package.json" + if err := os.WriteFile(customerManifest, customerManifestBody, 0o600); err != nil { + t.Fatalf("write customer package manifest: %v", err) + } + if err := run([]string{"package", "verify", "--manifest", customerManifest}); err != nil { + t.Fatalf("run package verify: %v", err) + } + chainPath := dir + "/chain.json" + entry := testAuditEntry(t, "", 1) + chainBody, err := json.Marshal([]map[string]any{entry}) + if err != nil { + t.Fatalf("marshal chain: %v", err) + } + if err := os.WriteFile(chainPath, chainBody, 0o600); err != nil { + t.Fatalf("write chain: %v", err) + } + if err := run([]string{"verify-audit-chain", chainPath}); err != nil { + t.Fatalf("run verify audit chain: %v", err) + } +} + +func TestRunUploadCommandsAndGitHubUsageBranches(t *testing.T) { + if err := run([]string{"github-actions"}); err == nil || !strings.Contains(err.Error(), "usage") { + t.Fatalf("github usage err=%v", err) + } + if err := run([]string{"import-bundle"}); err == nil || !strings.Contains(err.Error(), "usage") { + t.Fatalf("import usage err=%v", err) + } + if err := run([]string{"upload"}); err == nil || !strings.Contains(err.Error(), "usage") { + t.Fatalf("upload usage err=%v", err) + } + if err := run([]string{"release", "unknown"}); err == nil || !strings.Contains(err.Error(), "usage") { + t.Fatalf("release usage err=%v", err) + } +} + +func testAuditEntry(t *testing.T, previous string, sequence int64) map[string]any { + t.Helper() + entry := map[string]any{ + "tenant_id": "ten_1", + "sequence": sequence, + "entry_type": "evidence.created", + "subject_type": "evidence", + "subject_id": "ev_1", + "actor_type": "api_key", + "actor_id": "key_1", + "occurred_at": "2026-05-28T12:00:00Z", + "payload_hash": "sha256:" + strings.Repeat("a", 64), + "previous_entry_hash": previous, + "signature_ref": "", + "schema_version": "audit-chain-entry.v1.0.0", + "id": "ace_1", + "canonical_entry_hash": "", + "entry_hash": "", + } + canonical, err := auditEntryCanonicalHash(offlineAuditChainEntry{ + TenantID: entry["tenant_id"].(string), + Sequence: sequence, + EntryType: entry["entry_type"].(string), + SubjectType: entry["subject_type"].(string), + SubjectID: entry["subject_id"].(string), + ActorType: entry["actor_type"].(string), + ActorID: entry["actor_id"].(string), + OccurredAt: mustParseTime(t, entry["occurred_at"].(string)), + PayloadHash: entry["payload_hash"].(string), + PreviousEntryHash: previous, + SignatureRef: entry["signature_ref"].(string), + SchemaVersion: entry["schema_version"].(string), + }) + if err != nil { + t.Fatalf("canonical audit hash: %v", err) + } + entry["canonical_entry_hash"] = canonical + entry["entry_hash"] = hashString(previous + "\n" + canonical) + return entry +} + +func mustParseTime(t *testing.T, value string) time.Time { + t.Helper() + parsed, err := time.Parse(time.RFC3339, value) + if err != nil { + t.Fatalf("parse time: %v", err) + } + return parsed +} + +func testCustomerPackageManifest() map[string]any { + return map[string]any{ + "schema_version": "customer-security-package.v2.0.0", + "package_version": "customer-security-package.v2.0.0", + "package_id": "csp_1", + "id": "csp_1", + "title": "Customer package", + "generated_at": "2026-05-28T12:00:00Z", + "tenant": map[string]any{"id": "ten_1", "name": "Tenant"}, + "product": map[string]any{"id": "prod_1", "name": "Payments API"}, + "product_id": "prod_1", + "release": map[string]any{"id": "rel_1", "product_id": "prod_1", "version": "1.0.0", "state": "approved", "created_at": "2026-05-28T12:00:00Z"}, + "release_id": "rel_1", + "redaction_profile_id": "rp_1", + "redaction_profile": map[string]any{"id": "rp_1", "name": "customer_safe", "allowed_types": []any{"artifact", "release_bundle"}, "excluded_fields": []any{"payload_ref"}, "schema_version": "redaction-profile.v1.0.0"}, + "evidence_ids": []any{"ev_1"}, + "artifact_digests": []any{map[string]any{"id": "art_1", "name": "artifact.tar.gz", "media_type": "application/gzip", "size": 123, "digest": "sha256:ca978112ca1bbdcafac231b39a23dc4da786eff8147c4e72b9807785afee48bb", "created_at": "2026-05-28T12:00:00Z"}}, + "readiness_summary": map[string]any{"result": "passed", "checks": []any{}, "gaps": []any{}, "limitations": []any{"Readiness is derived from package-scoped evidence only."}}, + "verification_material": map[string]any{"hash_algorithm": "sha256", "canonicalization": "canonicalization-profile.v1.0.0", "manifest_hash_field": "manifest_hash", "release_bundles": []any{map[string]any{"id": "rb_1", "manifest_hash": "sha256:" + strings.Repeat("b", 64), "signature_refs": []any{"sig_1"}}}}, + "limitations": []any{"Package contents are scoped by product, release, redaction profile, and package expiry."}, + "non_claims": []any{"This package supports technical evidence review and compliance readiness only."}, + } +} + +func writeTestCustomerPackageArchive(t *testing.T, path string, manifest []byte, manifestHash, packageID string, decisionExport ...[]byte) string { + t.Helper() + file, err := os.Create(path) + if err != nil { + t.Fatalf("create archive: %v", err) + } + zw := zip.NewWriter(file) + verification := map[string]any{"package_id": packageID, "manifest_hash": manifestHash, "manifest_file": "manifest.json"} + if len(decisionExport) > 0 { + verification["decision_export_file"] = "vulnerability-decisions.json" + } + entries := []struct { + name string + body []byte + }{ + {name: "manifest.json", body: manifest}, + {name: "package.json", body: mustMarshalJSON(t, map[string]any{"id": packageID, "manifest_hash": manifestHash})}, + {name: "verification.json", body: mustMarshalJSON(t, verification)}, + } + if len(decisionExport) > 0 { + entries = append(entries, struct { + name string + body []byte + }{name: "vulnerability-decisions.json", body: decisionExport[0]}) + } + for _, entry := range entries { + writer, err := zw.Create(entry.name) + if err != nil { + t.Fatalf("create archive entry: %v", err) + } + if _, err := writer.Write(entry.body); err != nil { + t.Fatalf("write archive entry: %v", err) + } + } + if err := zw.Close(); err != nil { + t.Fatalf("close archive writer: %v", err) + } + if err := file.Close(); err != nil { + t.Fatalf("close archive: %v", err) + } + return path +} + +func writeSignedEvidenceBundle(t *testing.T, path string, evidenceIDs []any, keyID string) string { + t.Helper() + manifest := map[string]any{"bundle_version": "evidence-bundle.v1.0.0", "evidence_ids": evidenceIDs} + canonical, err := json.Marshal(manifest) + if err != nil { + t.Fatalf("marshal bundle manifest: %v", err) + } + sum := sha256.Sum256(canonical) + manifestHash := "sha256:" + hex.EncodeToString(sum[:]) + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("generate bundle key: %v", err) + } + body := mustMarshalJSON(t, map[string]any{ + "manifest": manifest, + "manifest_hash": manifestHash, + "signature_refs": []any{"sig_1"}, + "signatures": []any{map[string]any{ + "id": "sig_1", + "key_id": keyID, + "algorithm": "Ed25519", + "value": base64.RawStdEncoding.EncodeToString(ed25519.Sign(priv, []byte(manifestHash))), + "created_at": "2026-05-28T12:00:00Z", + }}, + "signing_keys": []any{map[string]any{ + "id": keyID, + "algorithm": "Ed25519", + "status": "active", + "public_key": base64.RawStdEncoding.EncodeToString(pub), + }}, + }) + if err := os.WriteFile(path, body, 0o600); err != nil { + t.Fatalf("write evidence bundle: %v", err) + } + return path +} + +func mustMarshalJSON(t *testing.T, value any) []byte { + t.Helper() + body, err := json.Marshal(value) + if err != nil { + t.Fatalf("marshal JSON: %v", err) + } + return body +} + +func validPreflightManifest(t *testing.T, dir string) string { + t.Helper() + path := dir + "/preflight-valid.json" + body := mustMarshalJSON(t, map[string]any{ + "schema_version": uploadManifestSchemaVersion, + "requests": []map[string]any{{ + "kind": "sbom", + "path": "/v1/sboms", + "idempotency_key": "sbom-1", + "payload": map[string]any{"release_id": "rel_1", "artifact_id": "art_1", "payload": map[string]any{"bomFormat": "CycloneDX"}}, + }}, + }) + if err := os.WriteFile(path, body, 0o600); err != nil { + t.Fatalf("write preflight manifest: %v", err) + } + return path +} + +func assertCLIExitCode(t *testing.T, err error, want int) { + t.Helper() + if err == nil { + t.Fatalf("expected exit code %d, got nil", want) + } + var exitErr interface{ ExitCode() int } + if !errors.As(err, &exitErr) { + t.Fatalf("err=%v does not expose exit code %d", err, want) + } + if got := exitErr.ExitCode(); got != want { + t.Fatalf("exit code=%d want=%d err=%v", got, want, err) + } +} diff --git a/cmd/openapi/main.go b/cmd/openapi/main.go index 0a0000c..ec77eb4 100644 --- a/cmd/openapi/main.go +++ b/cmd/openapi/main.go @@ -2,6 +2,7 @@ package main import ( "fmt" + "io" "os" "github.com/aatuh/evydence/internal/adapters/httpapi" @@ -9,16 +10,24 @@ import ( ) func main() { - server, err := httpapi.NewServer(app.NewLedger(app.Config{APIKeyPepper: "openapi-generation"})) - if err != nil { + if err := run(os.Stdout); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } +} + +func run(out io.Writer) error { + server, err := httpapi.NewServer(app.NewLedger(app.Config{APIKeyPepper: "openapi-generation"})) + if err != nil { + return err + } doc, err := server.OpenAPI() if err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) + return err + } + if _, err := out.Write(doc); err != nil { + return err } - _, _ = os.Stdout.Write(doc) - _, _ = os.Stdout.Write([]byte("\n")) + _, err = out.Write([]byte("\n")) + return err } diff --git a/cmd/openapi/main_test.go b/cmd/openapi/main_test.go new file mode 100644 index 0000000..3d786f1 --- /dev/null +++ b/cmd/openapi/main_test.go @@ -0,0 +1,17 @@ +package main + +import ( + "bytes" + "strings" + "testing" +) + +func TestRunWritesOpenAPI(t *testing.T) { + var out bytes.Buffer + if err := run(&out); err != nil { + t.Fatalf("run: %v", err) + } + if !strings.Contains(out.String(), `"openapi"`) || !strings.HasSuffix(out.String(), "\n") { + t.Fatalf("unexpected OpenAPI output: %.80q", out.String()) + } +} diff --git a/compose.production-like.yml b/compose.production-like.yml new file mode 100644 index 0000000..471a09a --- /dev/null +++ b/compose.production-like.yml @@ -0,0 +1,118 @@ +# Production-like local evaluation stack. +# +# This file is for local operator rehearsal, not a turnkey production +# deployment. Replace all secrets, use TLS at the edge, back up PostgreSQL and +# object storage together, and keep API replicas at one for the current +# supported writer profile. + +services: + postgres: + image: postgres:16-alpine@sha256:16bc17c64a573ef34162af9298258d1aec548232985b33ed7b1eac33ba35c229 + environment: + POSTGRES_DB: ${POSTGRES_DB:-evydence} + POSTGRES_USER: ${POSTGRES_USER:-evydence} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required} + ports: + - "${POSTGRES_PORT:-5432}:5432" + volumes: + - postgres-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER:-evydence}"] + interval: 5s + timeout: 5s + retries: 20 + + minio: + image: minio/minio@sha256:14cea493d9a34af32f524e538b8346cf79f3321eff8e708c1e2960462bd8936e + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: ${MINIO_ROOT_USER:-evydence} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD is required} + ports: + - "${MINIO_PORT:-9000}:9000" + - "${MINIO_CONSOLE_PORT:-9001}:9001" + volumes: + - minio-data:/data + + minio-init: + image: minio/mc@sha256:a7fe349ef4bd8521fb8497f55c6042871b2ae640607cf99d9bede5e9bdf11727 + depends_on: + minio: + condition: service_started + environment: + MINIO_ROOT_USER: ${MINIO_ROOT_USER:-evydence} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD is required} + EVYDENCE_S3_BUCKET: ${EVYDENCE_S3_BUCKET:-evydence} + entrypoint: + - /bin/sh + - -ec + - | + until mc alias set local http://minio:9000 "$${MINIO_ROOT_USER}" "$${MINIO_ROOT_PASSWORD}" >/dev/null 2>&1; do + sleep 2 + done + mc mb --ignore-existing "local/$${EVYDENCE_S3_BUCKET}" + + migrate: + build: . + image: ${EVYDENCE_IMAGE:-evydence:local} + depends_on: + postgres: + condition: service_healthy + environment: + EVYDENCE_DATABASE_URL: postgres://${POSTGRES_USER:-evydence}:${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}@postgres:5432/${POSTGRES_DB:-evydence}?sslmode=disable + entrypoint: ["evydence-migrate"] + + api: + build: . + image: ${EVYDENCE_IMAGE:-evydence:local} + depends_on: + migrate: + condition: service_completed_successfully + minio-init: + condition: service_completed_successfully + environment: + ENV: production + EVYDENCE_ADDR: :8080 + EVYDENCE_API_KEY_PEPPER: ${EVYDENCE_API_KEY_PEPPER:?EVYDENCE_API_KEY_PEPPER is required} + EVYDENCE_DATABASE_URL: postgres://${POSTGRES_USER:-evydence}:${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}@postgres:5432/${POSTGRES_DB:-evydence}?sslmode=disable + EVYDENCE_OBJECT_STORE: minio + EVYDENCE_S3_ENDPOINT: minio:9000 + EVYDENCE_S3_BUCKET: ${EVYDENCE_S3_BUCKET:-evydence} + EVYDENCE_S3_ACCESS_KEY_ID: ${MINIO_ROOT_USER:-evydence} + EVYDENCE_S3_SECRET_ACCESS_KEY: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD is required} + EVYDENCE_S3_USE_SSL: "false" + EVYDENCE_SIGNING_KEY_MODE: ${EVYDENCE_SIGNING_KEY_MODE:-external} + EVYDENCE_API_WRITER_MODE: single + EVYDENCE_API_WRITER_REPLICAS: "1" + EVYDENCE_PRINT_BOOTSTRAP_SECRET: "false" + ports: + - "${EVYDENCE_PORT:-8080}:8080" + healthcheck: + test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8080/v1/ready"] + interval: 10s + timeout: 3s + retries: 12 + + worker: + image: ${EVYDENCE_IMAGE:-evydence:local} + depends_on: + migrate: + condition: service_completed_successfully + minio-init: + condition: service_completed_successfully + environment: + ENV: production + EVYDENCE_API_KEY_PEPPER: ${EVYDENCE_API_KEY_PEPPER:?EVYDENCE_API_KEY_PEPPER is required} + EVYDENCE_DATABASE_URL: postgres://${POSTGRES_USER:-evydence}:${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}@postgres:5432/${POSTGRES_DB:-evydence}?sslmode=disable + EVYDENCE_OBJECT_STORE: minio + EVYDENCE_S3_ENDPOINT: minio:9000 + EVYDENCE_S3_BUCKET: ${EVYDENCE_S3_BUCKET:-evydence} + EVYDENCE_S3_ACCESS_KEY_ID: ${MINIO_ROOT_USER:-evydence} + EVYDENCE_S3_SECRET_ACCESS_KEY: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD is required} + EVYDENCE_S3_USE_SSL: "false" + EVYDENCE_SIGNING_KEY_MODE: ${EVYDENCE_SIGNING_KEY_MODE:-external} + entrypoint: ["evydence-worker"] + +volumes: + postgres-data: + minio-data: diff --git a/deploy/airgap/manifest.yaml b/deploy/airgap/manifest.yaml index f3b1086..52f63f7 100644 --- a/deploy/airgap/manifest.yaml +++ b/deploy/airgap/manifest.yaml @@ -2,7 +2,8 @@ schema_version: evydence-airgap-package.v1.0.0 images: - name: evydence repository: ghcr.io/aatuh/evydence - tag: dev + tag: v0.1.0-rc.5 + digest: sha256:38188044a3e5ded3c6094564ab39ce989185f65e22cf4296985ec19ba0eb1888 required: true bundled_paths: - migrations/ @@ -13,4 +14,4 @@ verification: checksums: SHA256SUMS signature: SHA256SUMS.sig.json limitations: - - The air-gapped package manifest describes required artifacts; operators must build, sign, and transfer actual images and archives through their controlled process. + - Project-owned release-candidate images are published through the maintainer Container Image workflow when run for the tag. Operators must verify and pin the image digest, or build, sign, and transfer actual images and archives through their controlled process. diff --git a/deploy/helm/evydence/templates/configmap.yaml b/deploy/helm/evydence/templates/configmap.yaml index 4cf1711..29ea311 100644 --- a/deploy/helm/evydence/templates/configmap.yaml +++ b/deploy/helm/evydence/templates/configmap.yaml @@ -5,6 +5,8 @@ metadata: data: ENV: {{ ternary "production" "development" .Values.env.production | quote }} EVYDENCE_ADDR: {{ .Values.api.addr | quote }} + EVYDENCE_API_WRITER_MODE: {{ .Values.api.writerMode | quote }} + EVYDENCE_API_WRITER_REPLICAS: {{ .Values.api.replicas | quote }} EVYDENCE_OBJECT_STORE: {{ .Values.env.objectStore | quote }} EVYDENCE_S3_ENDPOINT: {{ .Values.env.s3Endpoint | quote }} EVYDENCE_S3_BUCKET: {{ .Values.env.s3Bucket | quote }} diff --git a/deploy/helm/evydence/templates/deployment-api.yaml b/deploy/helm/evydence/templates/deployment-api.yaml index d25e1f8..1d30924 100644 --- a/deploy/helm/evydence/templates/deployment-api.yaml +++ b/deploy/helm/evydence/templates/deployment-api.yaml @@ -1,3 +1,4 @@ +{{- $imageTag := required "image.tag is required for Evydence release installs" .Values.image.tag -}} apiVersion: apps/v1 kind: Deployment metadata: @@ -14,11 +15,15 @@ spec: app.kubernetes.io/name: {{ include "evydence.name" . }} app.kubernetes.io/component: api spec: + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} containers: - name: api - image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + image: "{{ .Values.image.repository }}:{{ $imageTag }}" imagePullPolicy: {{ .Values.image.pullPolicy }} - command: ["/evydence-api"] + securityContext: + {{- toYaml .Values.containerSecurityContext | nindent 12 }} + command: ["evydence-api"] ports: - name: http containerPort: 8080 @@ -45,4 +50,4 @@ spec: path: /v1/health port: http resources: - {{- toYaml .Values.resources | nindent 12 }} + {{- toYaml .Values.api.resources | nindent 12 }} diff --git a/deploy/helm/evydence/templates/deployment-worker.yaml b/deploy/helm/evydence/templates/deployment-worker.yaml index 5376d5e..0b49a8f 100644 --- a/deploy/helm/evydence/templates/deployment-worker.yaml +++ b/deploy/helm/evydence/templates/deployment-worker.yaml @@ -1,3 +1,4 @@ +{{- $imageTag := required "image.tag is required for Evydence release installs" .Values.image.tag -}} apiVersion: apps/v1 kind: Deployment metadata: @@ -14,11 +15,15 @@ spec: app.kubernetes.io/name: {{ include "evydence.name" . }} app.kubernetes.io/component: worker spec: + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} containers: - name: worker - image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + image: "{{ .Values.image.repository }}:{{ $imageTag }}" imagePullPolicy: {{ .Values.image.pullPolicy }} - command: ["/evydence-worker"] + securityContext: + {{- toYaml .Values.containerSecurityContext | nindent 12 }} + command: ["evydence-worker"] envFrom: - configMapRef: name: {{ include "evydence.fullname" . }}-config @@ -33,5 +38,15 @@ spec: secretKeyRef: name: {{ .Values.existingSecret }} key: {{ .Values.env.apiKeyPepperSecretKey }} + {{- if .Values.worker.probes.enabled }} + readinessProbe: + exec: + command: + {{- toYaml .Values.worker.probes.readinessCommand | nindent 16 }} + livenessProbe: + exec: + command: + {{- toYaml .Values.worker.probes.livenessCommand | nindent 16 }} + {{- end }} resources: - {{- toYaml .Values.resources | nindent 12 }} + {{- toYaml .Values.worker.resources | nindent 12 }} diff --git a/deploy/helm/evydence/templates/networkpolicy.yaml b/deploy/helm/evydence/templates/networkpolicy.yaml new file mode 100644 index 0000000..e9c12ac --- /dev/null +++ b/deploy/helm/evydence/templates/networkpolicy.yaml @@ -0,0 +1,17 @@ +{{- if .Values.networkPolicy.enabled }} +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: {{ include "evydence.fullname" . }} +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: {{ include "evydence.name" . }} + app.kubernetes.io/component: api + policyTypes: + - Ingress + ingress: + - ports: + - protocol: TCP + port: 8080 +{{- end }} diff --git a/deploy/helm/evydence/values.yaml b/deploy/helm/evydence/values.yaml index caca018..6c2bca6 100644 --- a/deploy/helm/evydence/values.yaml +++ b/deploy/helm/evydence/values.yaml @@ -1,15 +1,42 @@ image: repository: ghcr.io/aatuh/evydence - tag: dev + # Release installs must set an explicit immutable tag or digest-style tag. + # Local demos may override this with a local development tag. + tag: "" pullPolicy: IfNotPresent api: + # Keep API replicas at 1 for the current production profile. Production API + # startup also takes a PostgreSQL advisory writer lease and a second writer + # fails closed until multi-writer concurrency controls are implemented and + # documented. replicas: 1 + writerMode: single addr: ":8080" + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + memory: 512Mi worker: replicas: 1 pollInterval: 1s + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + memory: 512Mi + probes: + enabled: true + readinessCommand: + - evydence-worker + - healthcheck + livenessCommand: + - evydence-worker + - healthcheck env: production: true @@ -34,4 +61,17 @@ ingress: host: evydence.example.com tlsSecretName: "" -resources: {} +podSecurityContext: + runAsNonRoot: true + seccompProfile: + type: RuntimeDefault + +containerSecurityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + readOnlyRootFilesystem: false + +networkPolicy: + enabled: false diff --git a/deploy/observability/grafana-dashboard.json b/deploy/observability/grafana-dashboard.json new file mode 100644 index 0000000..981f714 --- /dev/null +++ b/deploy/observability/grafana-dashboard.json @@ -0,0 +1,82 @@ +{ + "annotations": { + "list": [] + }, + "editable": true, + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 1, + "targets": [ + { + "expr": "evydence_resource_count", + "legendFormat": "{{resource}}" + } + ], + "title": "Tenant Resource Counts", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 2, + "targets": [ + { + "expr": "increase(evydence_customer_portal_failed_access_count[1h])", + "legendFormat": "failed portal access" + }, + { + "expr": "evydence_customer_portal_revoked_access_count", + "legendFormat": "revoked portal access" + } + ], + "title": "Customer Portal Access Guardrails", + "type": "timeseries" + } + ], + "schemaVersion": 39, + "tags": [ + "evydence", + "operations" + ], + "templating": { + "list": [] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timezone": "utc", + "title": "Evydence Runtime", + "uid": "evydence-runtime", + "version": 1 +} diff --git a/deploy/observability/prometheus-rules.yaml b/deploy/observability/prometheus-rules.yaml new file mode 100644 index 0000000..2fdac7b --- /dev/null +++ b/deploy/observability/prometheus-rules.yaml @@ -0,0 +1,27 @@ +groups: + - name: evydence-runtime + rules: + - alert: EvydenceAPIDown + expr: up{job="evydence-api"} == 0 + for: 2m + labels: + severity: page + annotations: + summary: Evydence API scrape target is down + description: The API target has not been reachable by Prometheus for at least two minutes. + - alert: EvydenceWorkerBacklogGrowing + expr: evydence_resource_count{resource="audit_chain_entries"} > 0 and absent(evydence_worker_last_success_timestamp_seconds) + for: 15m + labels: + severity: ticket + annotations: + summary: Evydence worker success signal is missing + description: Configure worker success instrumentation or check the worker when API ledger activity is present without a worker success signal. + - alert: EvydencePortalAccessFailures + expr: increase(evydence_customer_portal_failed_access_count[15m]) > 10 + for: 5m + labels: + severity: ticket + annotations: + summary: Customer portal access failures increased + description: Failed customer portal access attempts increased for a tenant-scoped scrape target. Review access records and gateway rate limits. diff --git a/docs/README.md b/docs/README.md index 5ce6e04..33fef82 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,23 +1,111 @@ # Evydence Documentation -This documentation is organized by task type so roadmap intent and implemented behavior stay separate. +This documentation is organized by reader task. Implementation claims should be backed by committed code, `openapi.yaml`, tests, deployment files, Makefile targets, and the canonical references listed in [Documentation source of truth](reference/source-of-truth.md). -## Tutorials +## Start Here -- [Getting started](tutorials/getting-started.md) walks through a local API run and a small release evidence flow. +- [Buyer evaluation overview](buyer-overview.md): choose the package, demo, release-evidence, and API paths for customer-review evaluation. +- [Operator overview](operator-overview.md): choose the install, configuration, deployment, runbook, and production-gate paths for self-hosting. +- [Evaluate Evydence in 10 minutes](tutorials/evaluate-in-10-minutes.md): inspect the public release verifier, sample customer package, local package viewer, and VEX-first proof path. +- [Customer CVE review demo](tutorials/customer-cve-review-demo.md): run the deterministic package-scope CVE answer without external services. +- [Getting started](tutorials/getting-started.md): run the API locally and create a minimal release evidence flow. +- [Install and operate](how-to/install-and-operate.md): choose a local runtime mode, start dependencies, run migrations, and launch API/worker processes. +- [Operations](operations.md): find the canonical operator references for configuration, workers, CI, deployment, and validation. +- [API reference](api.md): integrate with the `/v1` HTTP API using authentication, idempotency, examples, and endpoint tables. +- [Rendered OpenAPI docs](openapi/index.html): browse the generated static operation and schema reference. ## How-To Guides -- [Install and operate](how-to/install-and-operate.md) covers local dependencies, migrations, and runtime choices. +- [Integrate CI collectors](how-to/integrate-ci.md): connect the GitHub Actions quickstart, the scanner workflow, the composite upload action, GitLab CI, source snapshots, and collector supply-chain records. +- [Tool-specific integration templates](integrations/tool-templates.md): Syft, Grype, Trivy, GitHub Actions, GitLab CI, Dependency-Track, Jira metadata, and S3-compatible object storage handoff snippets. +- [Kubernetes deployment](kubernetes.md): install the Helm chart and verify a self-hosted cluster deployment. +- [Air-gapped installation](air-gapped.md): build, sign, transfer, verify, and import an offline package. +- [Release signing](release-signing.md): create and verify local release artifact manifests. +- [View packages locally](how-to/view-packages.md): inspect package, readiness, and evidence-bundle JSON without uploading it. +- [Review a customer package](how-to/review-customer-package.md): verify, inspect, and escalate a scoped package with checked fixtures. +- [Production hardening review](production-hardening.md): review production configuration, backups, ingress, diagnostics, and customer package controls. +- [Pilot deployment checklist](how-to/pilot-deployment-checklist.md): copy-paste checklist for the narrow design-partner pilot profile. +- [Publish the marketing site](how-to/publish-marketing-site.md): deploy the Astro site to GitHub Pages at `evydence.app` with consent-gated Google Analytics. +- [Backup and restore runbook](runbooks/backup-restore.md): rehearse paired database/object-store restore and verification. +- [Upgrade runbook](runbooks/upgrade.md): verify release artifacts, migrations, and post-upgrade checks. +- [Incident response runbook](runbooks/incident-response.md): handle operator incidents without leaking secrets or raw evidence. +- [Key rotation runbook](runbooks/key-rotation.md): rotate API, collector, SSO/session, portal, signing, and provider credentials. +- [Object store recovery runbook](runbooks/object-store-recovery.md): recover missing or mismatched raw payloads and package/export objects. ## Reference -- [API reference](api.md) describes the implemented `/v1` HTTP surface. -- [OpenAPI contract](reference/openapi.md) explains how `openapi.yaml` is generated and checked. +- [Configuration](reference/configuration.md): canonical environment variables and the roles of `.env.example`, `.api.env.example`, and `.test.env.example`. +- [Documentation source of truth](reference/source-of-truth.md): canonical source map to keep commands, status, and limitations from drifting. +- [Capability map](reference/capability-map.md): advanced inventory of implemented capabilities, implementation limits, and implemented-but-partial areas. +- [API contract matrix](reference/api-contract-matrix.md): generated route-by-route contract precision inventory for production hardening. +- [OpenAPI contract](reference/openapi.md): generation, drift checks, and review tips for `openapi.yaml`. +- [Rendered OpenAPI docs](openapi/index.html): static page generated from the committed OpenAPI contract. +- [Vulnerability decisions](reference/vulnerability-decisions.md): current VEX/decision model, buyer-facing gaps, and planned API changes. +- [Customer package manifest](reference/customer-package-manifest.md): v2 package schema, included metadata, exclusions, and local viewer compatibility. +- [Observability](reference/observability.md): readiness, admin metrics, Prometheus rules, and dashboard starter assets. +- [Capacity and failure modes](reference/capacity-and-failures.md): supported concurrency profile, sizing inputs, and failure behavior. +- [Benchmark results](reference/benchmark-results.md): current local benchmark command and interpretation. +- [Production readiness](reference/production-readiness.md): self-hosted production profiles, production gates, and exit criteria. +- [Production readiness traceability](reference/production-readiness-traceability.md): maps production/productization findings to repo-local evidence or external blockers. +- [Internal production-readiness exit checklist](reference/production-internal-exit-checklist.md): final repository-local closure checklist before claiming internal completion. +- [Production readiness audit closeout](reference/production-readiness-audit-closeout.md): fresh audit result after internal backlog implementation. +- [Highest achievable internal score](reference/production-internal-score.md): score summary and external blockers after repository-local closure. +- [Persistence decomposition inventory](reference/persistence-decomposition.md): generated map of focused and broad relational persistence call sites. +- [Production exit review](reference/production-exit-review.md): current release-positioning decision and unresolved blockers. +- [Stable v0.1.0 exit criteria](reference/stable-v0.1.0-exit-criteria.md): criteria for moving from release candidate to a stable `v0.1.0` tag. +- [Hardened reference deployment](reference/hardened-reference-deployment.md): controlled self-hosted topology with external PostgreSQL, object storage, TLS, secrets, backups, monitoring, and signing. +- [HA strategy](reference/ha-strategy.md): single-writer API decision, worker scaling stance, recovery boundaries, and multi-writer prerequisites. +- [External controls matrix](reference/external-controls-matrix.md): owner boundaries for regulated or high-trust self-hosted deployments. +- [Production gate troubleshooting](reference/production-gate-troubleshooting.md): safe diagnostics for `make production-check` failures. +- [Upgrade and compatibility policy](reference/upgrade-compatibility-policy.md): supported upgrade paths, migration expectations, and API compatibility rules. +- [Release candidate checklist](reference/release-candidate.md): required evidence before tagging a controlled self-hosted release candidate. +- [Release evidence index](reference/release-evidence-index.md): release artifact map, generation commands, and verification commands. +- [Release notes template](reference/release-notes-template.md): checked wording used by release-candidate packaging when a tag-specific note file is absent. +- [Release notes v0.1.0-rc.1](reference/release-notes-v0.1.0-rc.1.md): checked release-note wording for the first controlled self-hosted release candidate. +- [Maintainer review policy](reference/maintainer-review-policy.md): CODEOWNERS-backed review expectations for high-risk paths. +- [Roadmap and release cadence](reference/roadmap.md): current release-line focus, external trust controls, and cadence expectations. +- [Worker outbox contract](reference/worker-outbox.md): durable job kinds, idempotency, and safe logging rules. +- [Release validation](reference/release-validation.md): canonical `make release-check` behavior and summary evidence. +- [Upload manifest](reference/upload-manifest.md): schema, validation command, supported evidence request kinds, and safe payload-file handling. +- [SDK workflow](sdk/README.md): current Go, TypeScript, and Python wrapper usage and limitations. +- [SDK quickstarts](sdk/quickstarts.md): Go, TypeScript, and Python create/read snippets, Problem Details handling, idempotency, and package verification boundaries. +- [Collector supply chain](collectors/supply-chain.md): collector release evidence and health checks. +- [Source snapshot collectors](collectors/source-snapshots.md): GitHub and GitLab source metadata upload examples. + +## Project Metadata + +- [License](../LICENSE): `AGPL-3.0-only` public license text. +- [Commercial licensing](../COMMERCIAL.md): license decision table, commercial license exceptions, self-hosted support, release evidence packages, deployment review, and custom integration support. +- [Design partner pilot](commercial/design-partner-pilot.md): narrow paid pilot shape for one self-hosted release evidence workflow. +- [Product landing copy](commercial/product-landing-copy.md): conservative reusable positioning for README, website, outreach, or pilot materials. +- [Category comparison](commercial/category-comparison.md): fair positioning against Dependency-Track, GUAC, OpenVEX tooling, scanners, SBOM inventory tools, broad GRC, trust centers, and scripts. +- [Security policy](../SECURITY.md): vulnerability reporting guidance for tenant isolation, evidence integrity, credentials, collectors, object storage, signing, reports, exports, and raw evidence payloads. +- [Support](../SUPPORT.md): community support expectations, commercial support boundaries, and sanitized bug-report requirements. +- [Governance](../GOVERNANCE.md): maintainer-led decision process, contribution acceptance, release evidence expectations, and conservative product-language policy. +- [Contributing](../CONTRIBUTING.md): contribution workflow, CLA expectation, licensing compatibility, and implementation invariants. +- [Code of conduct](../CODE_OF_CONDUCT.md): participation expectations for public project spaces. +- [Trademarks](../TRADEMARKS.md): conservative use of the Evydence name and modified-build naming rules. +- [Release evidence](../RELEASE_EVIDENCE.md): release evidence routing, local acceptance checks, and limits of release validation. +- [Changelog](../CHANGELOG.md): unreleased and future public-release notes. +- [CODEOWNERS](../CODEOWNERS): expected maintainer ownership for high-trust code, release, deployment, and claim surfaces. + +## Workflow Examples + +- [GitHub Actions quickstart release evidence workflow](github-actions/quickstart-release-evidence.yml) +- [End-to-end GitHub Actions release evidence guide](github-actions/end-to-end-release-evidence.md) +- [GitHub Actions scanner release evidence workflow](github-actions/release-evidence-workflow.yml) +- [GitHub Actions upload-build composite action](github-actions/upload-build/action.yml) +- [GitHub release evidence manifest generator](../scripts/github_release_evidence_manifest.py) +- [GitLab release evidence CI template](gitlab/evydence-release-evidence.gitlab-ci.yml) +- [End-to-end release evidence example](../examples/end-to-end-release-evidence/README.md) +- [SDK examples](../examples/sdk) + +These examples require tenant-scoped API or collector secrets created through the API. They capture CI metadata as submitted evidence; provider-side truth still depends on the CI provider, workflow controls, and any verification receipts recorded separately. ## Explanation -- [Architecture](architecture.md) explains ports, adapters, storage, worker, and trust boundaries. -- [Trust model](explanation/trust-model.md) summarizes what Evydence verifies and what remains an assumption. +- [Architecture](architecture.md): ports/adapters boundaries, persistence, object storage, append-only behavior, and current limitations. +- [Architecture diagram](explanation/architecture-diagram.md): compact diagram of API, worker, storage, signing, and provider boundaries. +- [Trust model](explanation/trust-model.md): what Evydence verifies, what it records as assumptions, and where external review remains required. -Evydence supports compliance readiness and technical evidence organization. The documentation intentionally avoids legal compliance, certification, complete-SBOM, authoritative scanner, and secure-release claims. +Evydence supports compliance readiness and technical evidence organization. The documentation avoids claims that Evydence makes legal compliance conclusions, grants certification, proves SBOM completeness, treats scanner output as authoritative, or guarantees release security. diff --git a/docs/air-gapped.md b/docs/air-gapped.md index 82bac26..92c241e 100644 --- a/docs/air-gapped.md +++ b/docs/air-gapped.md @@ -2,6 +2,8 @@ This is a how-to guide for offline or disconnected environments. +## Build The Package + Prepare the package on a connected build host: ```sh @@ -9,26 +11,62 @@ go build -o dist/evydence-api ./cmd/evydence-api go build -o dist/evydence-worker ./cmd/evydence-worker go build -o dist/evydence ./cmd/evydence cp -R migrations openapi.yaml deploy/helm/evydence docs/air-gapped.md dist/ -evydence release manifest --out dist/evydence-release-manifest.json dist/evydence-api dist/evydence-worker dist/evydence -evydence release keygen --private-out dist/release-private.key --public-out dist/release-public.key -evydence release sign --manifest dist/evydence-release-manifest.json --private-key dist/release-private.key --out dist/evydence-release-manifest.sig.json +./dist/evydence release manifest --out dist/evydence-release-manifest.json dist/evydence-api dist/evydence-worker dist/evydence +./dist/evydence release keygen --private-out dist/release-private.key --public-out dist/release-public.key +./dist/evydence release sign --manifest dist/evydence-release-manifest.json --private-key dist/release-private.key --out dist/evydence-release-manifest.sig.json ``` -Transfer only the package, public key, checksums, and signature through the approved media path. Do not transfer private signing keys into the target environment unless that is the controlled signing location. +Expected package contents include: + +- `evydence-api` +- `evydence-worker` +- `evydence` +- `migrations/` +- `openapi.yaml` +- `deploy/helm/evydence/` +- `docs/air-gapped.md` +- `evydence-release-manifest.json` +- `evydence-release-manifest.sig.json` +- `release-public.key` + +The package manifest at `deploy/airgap/manifest.yaml` lists expected package contents. Keep it with release evidence. + +## Transfer -Verify offline: +Transfer only the package, public key, checksums, and signature through the approved media path. Do not transfer private signing keys into the target environment unless that environment is the controlled signing location. + +Record transfer approvals, media handling, and hash verification as separate operational evidence where your process requires it. + +## Verify Offline + +Run the verifier from the transferred package directory. Use `./evydence` for the packaged binary unless your operating procedure installs the same binary as `evydence` on `PATH`. ```sh -evydence release verify --manifest evydence-release-manifest.json --signature evydence-release-manifest.sig.json +./evydence release verify --manifest evydence-release-manifest.json --signature evydence-release-manifest.sig.json ``` +Expected result: the command exits successfully and reports that the manifest signature and referenced artifact hashes verify. + +Evidence bundles and audit-chain exports can also be checked without API access: + +```sh +./evydence verify-evidence-bundle evidence-bundle.json +./evydence verify-audit-chain audit-chain.json +``` + +`verify-evidence-bundle` always checks the canonical manifest hash. If the bundle file includes `signature_refs`, `signatures`, and `signing_keys`, it also verifies an Ed25519 signature over the manifest hash. `verify-audit-chain` checks sequence continuity, previous-entry linkage, canonical entry hashes, and entry hashes. These checks validate the exported bytes and included public material; they do not contact external transparency logs or key-management systems. + +## Import Evidence Bundles + For CI systems that cannot call the API, write an evidence bundle in CI, move it through the same controlled transfer process, then upload it from the connected side: ```sh -evydence import-bundle upload \ +./evydence import-bundle upload \ --url "$EVYDENCE_API_URL" \ --api-key "$EVYDENCE_API_KEY" \ --path evidence-bundle.json ``` -The package manifest at `deploy/airgap/manifest.yaml` lists expected package contents. It is not a backup and does not replace database, object-store, or key-management restore procedures. +Expected result: Evydence verifies and records the imported bundle manifest through the tenant-scoped API path. + +Air-gapped package manifests are not backups and do not replace database, object-store, or key-management restore procedures. Pair package evidence with the backup guidance in [Production hardening review](production-hardening.md). diff --git a/docs/api.md b/docs/api.md index 2b9a478..92675ed 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1,6 +1,12 @@ # API Reference -The public API base path is `/v1`. +The public API base path is `/v1`. The generated contract is committed at [`../openapi.yaml`](../openapi.yaml) and served by the API at `/v1/openapi.json`. +The static rendered companion is committed at [`openapi/index.html`](openapi/index.html) for browser-based review of operations and schemas. + +Use this page for common integration workflows and route lookup. The endpoint catalog is expected to list every path in `openapi.yaml`; the generated contract remains the source of truth for operation details, schemas, status codes, security metadata, and route drift checks. +The OpenAPI contract also includes non-sensitive examples for the main release evidence flow: create release, upload SBOM, upload vulnerability scan, record a vulnerability decision, read release readiness, and create a customer package. + +## Request Contract Common headers: @@ -11,140 +17,537 @@ Content-Type: application/json Accept: application/json ``` -Create and action endpoints require `Idempotency-Key`. Reusing the same key with the same request returns the original response. Reusing it with different request content returns `409` with `IDEMPOTENCY_KEY_REUSED`. - -Errors use RFC 9457 Problem Details and include a stable `code` field. - -The generated OpenAPI document is committed at `openapi.yaml` and served at `/v1/openapi.json`. - -## Enterprise Identity And Administration - -`POST /v1/organizations` creates a tenant-scoped organization. `POST /v1/users` creates a human user with normalized email, and `POST /v1/users/{id}/deactivate` records a deactivation transition. - -`POST /v1/role-bindings` assigns a role to a user or collector. Current roles are `tenant_admin`, `security_engineer`, `release_manager`, `customer_verifier`, and `collector`. Human SSO session actors derive API scopes from role bindings. `GET /v1/role-bindings` lists tenant-scoped bindings. - -`POST /v1/sso/providers` records OIDC or SAML provider metadata. `POST /v1/sso/identity-links` links a verified provider subject to an existing user. `POST /v1/sso/sessions` issues an expiring one-time session token response; the token hash is stored server-side and is not returned by list/read paths. `POST /v1/sso/sessions/{id}/revoke` revokes the session. - -`GET /v1/admin/instance` returns low-detail instance diagnostics for instance admins. It exposes operational counts only, not raw evidence payloads, API keys, session hashes, or customer portal tokens. - -## Controls And Reports - -`POST /v1/control-frameworks` creates a tenant-scoped framework version such as an internal CRA-readiness or SSDF-lite mapping. `GET /v1/control-frameworks` lists framework versions for the tenant. - -`POST /v1/controls` creates a framework-owned control with evidence requirements. Supported requirement types currently match implemented evidence resources: `sbom`, `vulnerability_scan`, `vex`, `vulnerability_decision`, `artifact`, `build`, `build_attestation`, `openapi_contract`, `release_bundle`, and `exception`. `GET /v1/controls/{id}` reads a tenant-scoped control. +Create and action endpoints require `Idempotency-Key`. Reusing the same key with the same request returns the original response. Reusing the same key with different request content returns `409` with `IDEMPOTENCY_KEY_REUSED`. -`POST /v1/controls/{id}/evidence` appends a control evidence link to an existing subject and confidence value of `high`, `medium`, `low`, or `unsupported`. Duplicate links return the existing link. `GET /v1/control-evidence` lists links by optional control, product, or release filters. +Successful JSON responses use a `data` envelope. Errors use RFC 9457 Problem Details with stable `code` and `request_id` fields. Clients may send `X-Request-ID`; otherwise the API generates one and returns it in the response header and Problem Details body. -`GET /v1/reports/control-coverage?framework_id=...&product_id=...&release_id=...` returns deterministic coverage statuses per control: `satisfied`, `partial`, `missing`, `waived`, `not_applicable`, or `unknown`. Approved, unexpired exceptions with a matching `control_id` may waive a control. +Example validation problem: -`GET /v1/reports/cra-readiness?product_id=...&release_id=...` returns a CRA-oriented technical evidence report built from the same control coverage engine. It includes assumptions and limitations and does not make legal compliance, certification, complete-SBOM, or secure-release claims. - -`GET /v1/control-framework-template-packs` lists built-in starter packs for CRA-readiness, NIST SSDF-lite, SOC 2-style technical evidence, and ISO 27001-style technical evidence. `POST /v1/control-framework-template-packs/{slug}/install` copies a pack into tenant-owned framework/control records. - -`POST /v1/waivers` creates a first-class waiver for a release, finding, control, or custom policy. `POST /v1/waivers/{id}/approve` records the append-only approval transition. Waivers are separate from exceptions and carry owner, risk, reason, expiry, supersession, and audit-chain entries. - -`POST /v1/approvals` creates immutable approval records for releases, contract diffs, waivers, security reviews, and customer packages. - -## CI Provenance +```json +{ + "type": "about:blank", + "title": "Bad Request", + "status": 400, + "code": "VALIDATION_FAILED", + "request_id": "req-test-validation" +} +``` -Collectors are tenant-scoped automated ingesters. `POST /v1/collectors` creates a `github_actions`, `gitlab_ci`, or `generic_ci` collector and returns a one-time API key secret scoped for build/evidence upload. `GET /v1/collectors` lists collectors without key hashes or secrets. The server binds collector identity from the API key; clients must not submit `collector_id` for build attribution. +## Minimal Release Evidence Workflow -`import_bundle` collectors are also supported for air-gapped workflows that move evidence bundles through controlled media before upload. `POST /v1/collectors/{id}/releases` records collector release supply-chain evidence such as version, artifact digest, signature, SBOM, scan, and pinning. `GET /v1/collectors/{id}/health` returns a tenant-scoped collector health report with checks and limitations. +The getting-started tutorial has a runnable curl flow. This section is the compact API shape for client implementers. -`POST /v1/builds` records an immutable build run. For `provider=github_actions`, the request must include `project_id`, `release_id`, `commit_sha`, `status`, `started_at`, `repository`, `workflow_ref`, `run_id`, and `run_attempt`. Supported statuses are `queued`, `running`, `passed`, `failed`, and `cancelled`. `GET /v1/builds/{id}` requires `build:read`. +### 1. Create Product, Project, And Release -`POST /v1/builds/{id}/attestations` accepts raw DSSE JSON containing `payloadType`, base64 `payload`, and `signatures`. Evydence decodes the in-toto Statement, records subjects, predicate type, SLSA builder/build metadata when present, stores raw bytes as tenant-prefixed evidence, and marks the record structurally valid. This slice does not perform cryptographic trust-root verification. +```sh +curl -sS -X POST "$EVYDENCE_URL/v1/products" \ + -H "Authorization: Bearer $EVYDENCE_API_KEY" \ + -H "Idempotency-Key: product-payments-api" \ + -H "Content-Type: application/json" \ + --data '{"name":"Payments API","slug":"payments-api"}' +``` -## Evidence Lifecycle And Search +Expected status: `201`. -`GET /v1/evidence/search` filters tenant evidence by product, project, release, build, deployment, type, subtype, source system, collector, verification status, subject ref, tag, created time, and limit. Results are tenant-scoped and sorted newest first. +Representative response shape: -`POST /v1/evidence/{id}/lifecycle-events` appends lifecycle records for `amendment`, `redaction`, `tombstone`, or `retention_marker`. These records do not mutate the immutable evidence core fields. `GET /v1/evidence/{id}/lifecycle-events` returns the timeline for one evidence item. +```json +{ + "data": { + "id": "prod_...", + "name": "Payments API", + "slug": "payments-api", + "tenant_id": "ten_..." + } +} +``` -## Release Candidates And Artifacts +Then create: -`POST /v1/release-candidates` records an immutable release-candidate snapshot over explicit builds, artifacts, SBOMs, scans, VEX documents, OpenAPI contracts, and bundles. `POST /v1/release-candidates/{id}/promote` and `/reject` append the terminal transition; a candidate cannot be transitioned twice. +```http +POST /v1/projects +POST /v1/releases +POST /v1/releases/{id}/evidence-flow/start +GET /v1/releases/{id}/security-summary +``` -`POST /v1/container-images` records OCI-style image repository, tag, digest, platform, and optional artifact binding. `POST /v1/artifact-signatures` records detached artifact signature metadata and optional raw signature payload bytes in object storage. `POST /v1/verify` supports `subject_type=artifact_signature` for digest binding and signature-presence verification. +Representative request bodies: -`POST /v1/artifact-signatures/{id}/verify-cosign` records cosign-style digest-bound verification metadata, including optional Rekor UUID/log index and certificate identity/issuer. The check verifies the stored artifact digest binding and signature presence, and captures transparency metadata when supplied. It does not claim full Sigstore trust-chain verification unless a later trust-root integration proves it. +```json +{"product_id":"prod_...","name":"api"} +``` -## Source And Deployment Evidence +```json +{"product_id":"prod_...","version":"1.0.0"} +``` -`POST /v1/source/repositories`, `/v1/source/commits`, `/v1/source/branches`, and `/v1/source/pull-requests` record source-control evidence without replacing the source provider as the source of truth. Commit messages are represented by hashes, not stored as raw report text. +`POST /v1/releases/{id}/evidence-flow/start` returns a read-only workflow plan +with current evidence counts, required endpoints, scopes, idempotency guidance, +assumptions, and limitations. It does not create evidence or replace the +resource-specific endpoints. -`POST /v1/collectors/github/source-snapshots` and `/v1/collectors/gitlab/source-snapshots` accept strict JSON snapshots containing repository, commit, branch-protection, and pull-request metadata. No live provider API calls or OIDC token verification are performed by these endpoints. +`GET /v1/releases/{id}/security-summary` returns a tenant-scoped quick-check +summary for review surfaces. It includes artifact, SBOM, scan, finding, +decision, approval, exception, readiness, and package-generation status, but it +does not include raw evidence payload bytes or private decision notes. -`POST /v1/environments` creates deployment environments for a product. `POST /v1/deployments` records deployment events linking an environment, release, artifacts, status, timing, and optional rollback reference. Rollbacks are recorded as new deployment events, not destructive edits. +### 2. Register Artifact And Upload Evidence -## Release Risk Decisions +Register an artifact: -OpenVEX ingestion is available at `POST /v1/vex` with a request body containing `release_id`, optional `artifact_id`, and `payload`. The payload must be OpenVEX JSON with author, timestamp, and one or more statements. Raw VEX bytes are stored as tenant-prefixed object payloads and represented as immutable `vex` evidence. +```json +{ + "name": "api.tar.gz", + "media_type": "application/gzip", + "digest": "sha256:ca978112ca1bbdcafac231b39a23dc4da786eff8147c4e72b9807785afee48bb", + "size": 42 +} +``` -CycloneDX VEX ingestion is available at `POST /v1/vex/cyclonedx` for CycloneDX JSON with `vulnerabilities[].analysis`. It preserves raw payload bytes, records a `vex` evidence item, and normalizes matching finding decisions when a release finding has the same vulnerability ID. +Upload generic evidence: + +```json +{ + "product_id": "prod_...", + "project_id": "proj_...", + "release_id": "rel_...", + "type": "build", + "subtype": "log", + "title": "Build evidence", + "payload_hash": "sha256:ca978112ca1bbdcafac231b39a23dc4da786eff8147c4e72b9807785afee48bb", + "tags": ["ci"], + "limitations": ["Captured from CI metadata submitted by the collector."] +} +``` -Vulnerability findings from `POST /v1/vulnerability-scans` can be resolved with `POST /v1/vulnerability-findings/{id}/decisions`. Supported statuses are `affected`, `not_affected`, `fixed`, and `under_investigation`. New decisions supersede earlier decisions for the same finding. +`POST /v1/evidence` creates immutable evidence metadata. Later changes are represented by supersession, lifecycle events, links, or new evidence records. -`POST /v1/vulnerability-findings/{id}/workflow` appends workflow records such as scanner metadata, SLA notes, scanner disagreement, supersession, or re-open markers. `GET /v1/reports/vulnerability-posture?release_id=...` summarizes uploaded findings. These records organize scanner evidence and do not make scanner findings authoritative. +### 3. Upload SBOM And Vulnerability Evidence -Scoped exceptions are created with `POST /v1/exceptions` and approved separately with `POST /v1/exceptions/{id}/approve`. Only approved, unexpired exceptions can affect release readiness. +CycloneDX SBOM: -`GET /v1/reports/release-readiness?release_id=...` returns deterministic readiness JSON with checks, blocking findings, accepted exceptions, gaps, assumptions, and limitations. Readiness requires SBOM, vulnerability scan, artifact digest evidence, signed bundle, passed build provenance, build attestation subject matching a release artifact digest, and no unhandled open critical finding. It supports compliance readiness only and is not a legal compliance or secure-release conclusion. +```http +POST /v1/sboms +``` -## Incidents And Remediation +```json +{ + "release_id": "rel_...", + "artifact_id": "art_...", + "payload": { + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "components": [ + {"name": "openssl", "purl": "pkg:apk/openssl@3.1.0"} + ] + } +} +``` -`POST /v1/incidents` creates an incident linked to a product and optional release. `POST /v1/incidents/{id}/timeline` appends timeline events, optionally linked to evidence. `POST /v1/remediation-tasks` creates remediation tasks linked to an incident or release. +Vulnerability scan: -`GET /v1/reports/incident-package?incident_id=...` returns a deterministic incident package with timeline events, remediation tasks, linked evidence IDs, assumptions, and limitations. It is an evidence organization report and does not prove root cause completeness or remediation sufficiency. +```http +POST /v1/vulnerability-scans +``` -## Security Evidence And SBOM Expansion +```json +{ + "scanner": "grype", + "target_ref": "pkg:oci/payments-api", + "release_id": "rel_...", + "findings": [ + { + "vulnerability": "CVE-2026-0099", + "component": "pkg:apk/openssl@3.1.0", + "severity": "critical", + "state": "open" + } + ] +} +``` -`POST /v1/security-scans` uploads normalized SAST, DAST, secret scan, license scan, or API-security scan JSON. `POST /v1/api-security-scans` is a convenience route for API security scan evidence. Generic payloads use `findings[].severity`; SARIF payloads use `runs[].results[].level`. Secret scan evidence is marked redacted and quarantined when findings are present. +Scanner evidence is recorded for review and workflow decisions. It is not treated as authoritative by itself. -`POST /v1/security-documents` uploads sensitive manual security documents with type `threat_model`, `security_review`, or `pen_test_report` and sensitivity `internal`, `confidential`, or `restricted`. Raw bytes are stored as object payloads and the API response exposes metadata, not payload contents. +Decision on a finding: -`POST /v1/sboms/spdx` ingests SPDX JSON as first-class SBOM evidence. `POST /v1/sbom-diffs` compares two stored SBOMs and records added, removed, and unchanged component counts. SBOM handling does not prove SBOM completeness. +```http +POST /v1/vulnerability-findings/{id}/decisions +``` -## Packages, Templates, And Bundles +```json +{ + "status": "not_affected", + "justification": "vulnerable code path is not present", + "impact_statement": "This release is not affected because the vulnerable runtime code is not included.", + "action_statement": "No customer action is required for this finding.", + "customer_visible": true, + "internal_notes": "tenant-internal triage note", + "evidence_ids": ["ev_supporting_review"], + "vex_document_id": "vex_imported_review" +} +``` -`POST /v1/redaction-profiles` creates an explicit package redaction profile. `POST /v1/customer-packages` creates a scoped customer security package manifest with expiry and access auditing. `GET /v1/customer-packages/{id}` reads the manifest and records an access event; raw payload bytes are not returned. +Supported decision statuses are `affected`, `not_affected`, `fixed`, and +`under_investigation`. Customer-visible decisions require +`impact_statement`; `internal_notes` are tenant-internal and must not be copied +into customer-safe package summaries. A later decision for the same finding +supersedes the previous active decision and records audit-chain entries for the +supersession and replacement. `evidence_ids` may reference tenant-scoped +supporting evidence from the same release; foreign-tenant or wrong-release +evidence links are rejected. `vex_document_id` can link a manual decision to an +imported VEX document from the same release, including cases where automated VEX +mapping did not create a decision. -`POST /v1/customer-portal/access` creates an expiring customer portal access token for one package and returns the token once. `POST /v1/customer-portal/package` accepts that token and returns the scoped package manifest without exposing the token or raw tenant evidence. +Decision history: -`POST /v1/questionnaire-templates` creates tenant-defined customer questionnaire templates with question-to-evidence hints. `POST /v1/questionnaire-packages` generates deterministic evidence-backed responses for a package, product, and release scope. Responses cite linked evidence IDs and include limitations for human review. +```http +GET /v1/vulnerability-decisions?release_id=rel_...&vulnerability=CVE-2026-0099&active=true +``` -`GET /v1/reports/security-review-package?package_id=...` returns a redaction-aware security review package report. `GET /v1/reports/cra-readiness-html?product_id=...&release_id=...` returns deterministic HTML content for CRA-readiness review with limitations and no compliance conclusion. +The history endpoint supports `product_id`, `release_id`, `vulnerability`, +`component`, `status`, and `active` filters. It returns append-only decision +records, including timestamps and supersession fields. It is tenant-scoped and +requires `evidence:read`. -`POST /v1/report-templates` creates a tenant report template with explicit allowed fields. `POST /v1/report-templates/{id}/render` renders only those allowed fields into a deterministic JSON report. +VEX import parser report: -`POST /v1/evidence-bundles` exports a portable evidence bundle manifest with evidence IDs, manifest hash, signature references, and verification instructions. `POST /v1/evidence-bundles/import` verifies and records an imported bundle manifest. The CLI command `evydence verify-evidence-bundle ` verifies bundle manifest hashes offline. +```http +GET /v1/vex/{id}/import-report +``` -`POST /v1/dsse-trust-roots` configures an Ed25519 DSSE verification trust root. `POST /v1/build-attestations/{id}/verify-signature` verifies stored DSSE attestation signatures against configured trust roots and records a verification result. +The import report records parser version, statement counts, decision counts, +supersession counts, warnings, invalid statement issues, and mapping failures. +When worker replay fails it records safe `failure_code` and `failure_detail` +fields. It does not include raw VEX payload bytes, object-store payload +references, backend error strings, or bearer tokens. -## Integrity, Audit, And Operations +VEX import preview: -`POST /v1/signing-keys/{id}/revoke` revokes a tenant signing key. Historical signatures created before revocation remain valid-at-signing when their cryptographic signature still verifies; revoked keys are not used for new signatures. +```http +POST /v1/vex/preview +POST /v1/vex/cyclonedx/preview +``` -`POST /v1/signing-providers` records a signing-provider configuration reference such as `local_encrypted_dev`, `aws_kms`, `gcp_kms`, `azure_key_vault`, or `pkcs11_hsm`. The API stores provider metadata and key references, not private key material. Production still requires an external signing mode. +Preview validates OpenVEX or CycloneDX VEX payloads and returns advisory +mapping counts, warnings, invalid-statement issues, and mapping failures. It +does not store raw payloads, create evidence, create decisions, or enqueue +worker jobs, so it does not require `Idempotency-Key`. -`POST /v1/merkle-batches` creates a signed Merkle batch over a tenant audit-chain sequence range. `GET /v1/merkle-batches/{id}/verify` recomputes the Merkle root and verifies the checkpoint signature. +Customer-safe decision summary: -`POST /v1/transparency-checkpoints` records optional external timestamp/transparency anchoring metadata for a Merkle batch. Public transparency is optional and not required for local trust. +```http +GET /v1/reports/vulnerability-decision-summary?release_id=rel_... +``` -`POST /v1/object-retention-policies` records tenant-prefixed object-retention policy intent. `POST /v1/object-retention-policies/{id}/verify` records a verification transition for the policy record. Object-lock enforcement depends on the configured object store. +The summary endpoint returns only active decisions marked `customer_visible`. +It excludes `internal_notes`, raw payload bytes, object-store references, bearer +tokens, and private review context. The response includes assumptions and +limitations and is intended for package viewers and exports. -`POST /v1/backup-manifests` generates a backup manifest with a state hash, resource counts, audit-chain consistency checks, and limitations. `GET /v1/backup-manifests/{id}/verify` verifies the recorded manifest checks. The manifest intentionally excludes raw payload bytes and private keys; restore requires matched database and object-store backups. +### 4. Readiness And Bundle Retrieval -`GET /v1/ready` returns a low-detail readiness response. `GET /v1/metrics` returns tenant-scoped safe resource counts and requires admin scope. +Create a release bundle: -`GET /v1/audit-log` lists tenant audit-chain entries with optional `subject_type`, `subject_id`, `since`, and `limit` filters. It requires admin scope and returns tenant-scoped audit fields only. +```http +POST /v1/release-bundles +``` -`POST /v1/legal-holds` records a legal hold for tenant, product, project, release, or evidence scope. `POST /v1/retention-overrides` records an expiring retention override. `GET /v1/reports/retention` lists tenant-scoped legal hold and retention override records with limitations. +```json +{"release_id":"rel_..."} +``` -`POST /v1/commercial-collectors` records a commercial collector definition with provider, version, manifest digest, allowed scopes, and status. `GET /v1/commercial-collectors` lists tenant-scoped definitions. These records define extension metadata and do not grant provider trust by themselves. +Read readiness: -## Contracts And Policy V2 +```http +GET /v1/reports/release-readiness?release_id=rel_... +``` -`POST /v1/openapi-diffs` compares two uploaded OpenAPI contracts by stored contract metadata and classifies the result as unchanged, changed, or breaking when target path count decreases. Rich operation-level diffing remains future work. +Representative response shape: + +```json +{ + "data": { + "release_id": "rel_...", + "result": "failed", + "checks": [], + "blocking_findings": [], + "gaps": [], + "assumptions": [], + "limitations": [] + } +} +``` -`POST /v1/custom-policies` creates a versioned deterministic policy with simple required evidence rules. `POST /v1/custom-policies/{id}/evaluate` stores a replayable evaluation with input hash and policy checks for one release. +Readiness is deterministic and evidence-scoped. It reports checks, reviewer +question sections, missing evidence, failed policy checks, blockers, gaps, +assumptions, non-claims, and limitations; it is not a legal compliance or +release-security conclusion. + +The default `policy-set.v1.0.0` release checks require release-linked artifact +and digest evidence, SBOM evidence, vulnerability-scan evidence, handled open +critical/high findings, review-safe customer-visible decisions, justifications +for `not_affected` decisions, complete exception metadata, passed build +provenance, build-attestation subject coverage, a signed release bundle, and +valid redaction profiles for generated customer packages. Failed checks include +`remediation` text with the next evidence action to take. + +## Authentication And Scopes + +API keys and collector keys are tenant-scoped bearer secrets. Human SSO session actors derive scopes from role bindings and enforce resource constraints where those resources are part of the request. + +Important scope boundaries: + +| Boundary | Behavior | +|----------|----------| +| Tenant data | Cross-tenant reads return `404` where applicable. | +| Collector identity | Build attribution is derived from the authenticated collector key; clients must not submit `collector_id` for build attribution. | +| Instance admin | `GET /v1/admin/instance` requires explicit `instance:admin`; tenant admin and ordinary wildcard tenant keys are insufficient. | +| Customer portal | `POST /v1/customer-portal/package` and `/v1/customer-portal/package/download` are public token exchange endpoints and intentionally do not use bearer authentication. Optional NDA acceptance and distribution watermarks are recorded without storing supplied tokens. Successful exchanges and downloads are visible through the tenant audit log without storing the supplied token. | + +## Endpoint Catalog + +### System + +| Method | Path | Notes | +|--------|------|-------| +| `GET` | `/v1/health` | Process health. | +| `GET` | `/v1/ready` | Low-detail readiness. | +| `GET` | `/v1/version` | Version metadata. | +| `GET` | `/v1/metrics` | Tenant-safe counts; admin scope required. | +| `GET` | `/v1/openapi.json` | Generated OpenAPI. | +| `GET` | `/v1/admin/instance` | Low-detail instance counts; `instance:admin` required. | + +### Identity And Administration + +| Method | Path | Notes | +|--------|------|-------| +| `POST` | `/v1/organizations` | Create tenant-scoped organization. | +| `POST` | `/v1/users` | Create normalized human user. | +| `POST` | `/v1/users/{id}/deactivate` | Record deactivation transition. | +| `POST` | `/v1/role-bindings` | Assign role to user or collector. | +| `GET` | `/v1/role-bindings` | List tenant-scoped bindings. | +| `POST` | `/v1/sso/providers` | Record OIDC or SAML provider metadata. | +| `POST` | `/v1/sso/providers/{id}/trust-material` | Rotate OIDC JWKS or SAML signing certificates used for local verification. | +| `POST` | `/v1/sso/providers/{id}/discover-oidc` | Fetch OIDC discovery metadata and refresh public JWKS trust material. | +| `POST` | `/v1/sso/identity-links` | Link verified provider subject to user. | +| `POST` | `/v1/sso/sessions` | Issue one-time session secret. | +| `POST` | `/v1/sso/session-exchanges` | Exchange a locally verified OIDC ID token or SAML assertion for a session secret and HttpOnly cookie. | +| `POST` | `/v1/sso/sessions/{id}/revoke` | Revoke session. | +| `POST` | `/v1/sso/logout` | Revoke the currently authenticated SSO session. | +| `POST` | `/v1/api-keys` | Create one-time API key secret. | +| `GET` | `/v1/api-keys` | List keys without secret hashes. | + +Current SSO endpoints model admin-managed provider, identity-link, trust-material, and session records plus API-first session logout. OIDC provider records can include public JWKS material, and SAML provider records can include PEM-encoded assertion signing certificates; both can be rotated through `POST /v1/sso/providers/{id}/trust-material`. OIDC public JWKS can also be refreshed from the configured issuer with `POST /v1/sso/providers/{id}/discover-oidc`. `POST /v1/provider-verifications` can verify a supplied OIDC ID token or SAML assertion locally for issuer, audience, subject, time bounds, and signature. When an OIDC `access_token` is supplied, the same endpoint can call either the provider's discovered UserInfo endpoint or a configured operator-controlled provider validation gateway, verify the returned subject, and record any configured group-claim mapping checks without storing the access token. The provider validation gateway receives only non-secret request metadata and an `access_token_present` flag, not the supplied token. `POST /v1/sso/session-exchanges` uses local token/assertion verification and a verified identity link to issue a one-time SSO bearer secret and an HttpOnly cookie for browser clients. OIDC group claim values can map to session-scoped roles through the provider `groups_claim` and `role_mapping`; no permanent role binding is created from token claims. External group synchronization into permanent role bindings is not implemented in this slice. + +### Products, Releases, Evidence, And Risk + +| Method | Path | Notes | +|--------|------|-------| +| `POST` | `/v1/products` | Create product. | +| `GET` | `/v1/products` | List products. | +| `GET` | `/v1/products/{id}` | Read product. | +| `POST` | `/v1/projects` | Create project under product. | +| `GET` | `/v1/projects/{id}` | Read project. | +| `POST` | `/v1/releases` | Create release. | +| `GET` | `/v1/releases/{id}` | Read release. | +| `POST` | `/v1/releases/{id}/evidence-flow/start` | Read high-level release evidence workflow plan. | +| `GET` | `/v1/releases/{id}/security-summary` | Read customer-safe release security summary status. | +| `POST` | `/v1/releases/{id}/freeze` | Append freeze transition. | +| `POST` | `/v1/releases/{id}/approve` | Append approval transition. | +| `POST` | `/v1/artifacts` | Register artifact digest metadata. | +| `GET` | `/v1/artifacts/{id}` | Read artifact digest metadata. | +| `POST` | `/v1/evidence` | Create immutable evidence metadata. | +| `GET` | `/v1/evidence` | List evidence by release/type. | +| `GET` | `/v1/evidence/search` | Search by product, project, release, build, deployment, type, subtype, source, collector, verification status, subject, tag, created time, and limit. | +| `POST` | `/v1/evidence-summaries` | Create evidence-cited technical summary with assumptions and limitations. | +| `POST` | `/v1/evidence-graph-snapshots` | Persist product/release evidence adjacency snapshot. | +| `GET` | `/v1/evidence/{id}` | Read evidence. | +| `POST` | `/v1/evidence/{id}/supersede` | Supersede without mutating original. | +| `POST` | `/v1/evidence/{id}/link` | Link evidence to another subject. | +| `POST` | `/v1/evidence/{id}/lifecycle-events` | Append amendment/redaction/tombstone/retention marker. | +| `GET` | `/v1/evidence/{id}/lifecycle-events` | Read lifecycle timeline. | +| `POST` | `/v1/sboms` | Upload CycloneDX SBOM. | +| `POST` | `/v1/sboms/spdx` | Upload SPDX SBOM. | +| `GET` | `/v1/sboms/{id}` | Read SBOM metadata. | +| `GET` | `/v1/sbom-components` | Search stored SBOM components by SBOM, release, artifact, query, exact PURL, and limit. | +| `POST` | `/v1/sbom-diffs` | Compare stored SBOMs. | +| `POST` | `/v1/vulnerability-scans` | Upload normalized vulnerability scan. | +| `GET` | `/v1/vulnerability-scans/{id}` | Read vulnerability scan metadata. | +| `POST` | `/v1/vulnerability-findings/{id}/decisions` | Superseding decision record. | +| `GET` | `/v1/vulnerability-decisions` | List decision history by product, release, vulnerability, component, status, and active state. | +| `POST` | `/v1/vulnerability-findings/{id}/workflow` | Append workflow event. | +| `GET` | `/v1/reports/vulnerability-posture` | Summarize findings for a release. | +| `GET` | `/v1/reports/vulnerability-decision-summary` | Customer-safe active vulnerability decision summary for a release. | +| `GET` | `/v1/reports/release-readiness` | Deterministic readiness report. | +| `GET` | `/v1/reports/missing-evidence` | Missing evidence report for review. | +| `POST` | `/v1/reports/anomaly` | Generate deterministic evidence anomaly signals. | +| `POST` | `/v1/release-candidates` | Create release candidate. | +| `GET` | `/v1/release-candidates` | List release candidates. | +| `GET` | `/v1/release-candidates/{id}` | Read release candidate. | +| `POST` | `/v1/release-candidates/{id}/promote` | Promote release candidate. | +| `POST` | `/v1/release-candidates/{id}/reject` | Reject release candidate. | +| `POST` | `/v1/remediation-tasks` | Create remediation task. | + +### CI, Source, Deployment, And Collectors + +| Method | Path | Notes | +|--------|------|-------| +| `POST` | `/v1/collectors` | Create collector and one-time key. | +| `GET` | `/v1/collectors` | List collectors without secrets. | +| `POST` | `/v1/collectors/{id}/releases` | Record collector release evidence. | +| `GET` | `/v1/collectors/{id}/health` | Collector health report. | +| `POST` | `/v1/commercial-collectors` | Create commercial collector definition. | +| `GET` | `/v1/commercial-collectors` | List commercial collector definitions. | +| `POST` | `/v1/marketplace-collectors` | Register marketplace collector package metadata. | +| `GET` | `/v1/marketplace-collectors` | List marketplace collector package records. | +| `GET` | `/v1/marketplace-collectors/{id}/health` | Review marketplace collector package evidence gaps. | +| `POST` | `/v1/builds` | Record immutable build run. | +| `GET` | `/v1/builds/{id}` | Read build run. | +| `POST` | `/v1/builds/{id}/attestations` | Upload DSSE in-toto attestation JSON. | +| `POST` | `/v1/build-attestations/{id}/verify-signature` | Verify against configured DSSE trust roots. | +| `POST` | `/v1/dsse-trust-roots` | Create DSSE trust root. | +| `POST` | `/v1/source/repositories` | Record source repository. | +| `GET` | `/v1/source/repositories` | List repositories. | +| `POST` | `/v1/source/commits` | Record source commit. | +| `POST` | `/v1/source/branches` | Record branch state. | +| `POST` | `/v1/source/pull-requests` | Record pull request metadata. | +| `POST` | `/v1/collectors/github/source-snapshots` | Upload strict GitHub source snapshot. | +| `POST` | `/v1/collectors/gitlab/source-snapshots` | Upload strict GitLab source snapshot. | +| `POST` | `/v1/environments` | Create deployment environment. | +| `GET` | `/v1/environments` | List environments. | +| `POST` | `/v1/deployments` | Record deployment event. | +| `GET` | `/v1/deployments` | List deployments. | +| `GET` | `/v1/deployments/{id}` | Read deployment event. | +| `POST` | `/v1/container-images` | Register container image metadata. | + +Source snapshots capture submitted provider metadata. They do not call provider APIs or verify OIDC tokens. + +### Controls, Reports, Packages, And Governance + +| Method | Path | Notes | +|--------|------|-------| +| `POST` | `/v1/control-frameworks` | Create framework version. | +| `GET` | `/v1/control-frameworks` | List frameworks. | +| `GET` | `/v1/control-framework-template-packs` | List built-in starter packs. | +| `POST` | `/v1/control-framework-template-packs/{slug}/install` | Copy starter pack to tenant records. | +| `POST` | `/v1/controls` | Create control. | +| `GET` | `/v1/controls/{id}` | Read control. | +| `POST` | `/v1/controls/{id}/evidence` | Append control evidence link. | +| `GET` | `/v1/control-evidence` | List links. | +| `GET` | `/v1/reports/control-coverage` | Deterministic control coverage. | +| `GET` | `/v1/reports/cra-readiness` | Technical evidence readiness report with limitations. | +| `GET` | `/v1/reports/cra-vulnerability-handling` | CRA-oriented vulnerability handling evidence report with limitations. | +| `GET` | `/v1/reports/security-update-evidence` | Release-scoped security update evidence report with limitations. | +| `POST` | `/v1/exceptions` | Create exception. | +| `POST` | `/v1/exceptions/{id}/approve` | Approve exception. | +| `GET` | `/v1/exceptions` | List exceptions. | +| `POST` | `/v1/waivers` | Create waiver. | +| `POST` | `/v1/waivers/{id}/approve` | Approve waiver. | +| `POST` | `/v1/approvals` | Create immutable approval record. | +| `POST` | `/v1/redaction-profiles` | Create package redaction profile. | +| `POST` | `/v1/customer-packages` | Create scoped customer package manifest. | +| `GET` | `/v1/customer-packages/{id}` | Read package manifest and record access. | +| `GET` | `/v1/customer-packages/{id}/download` | Download scoped ZIP package with manifest metadata and verification guidance. | +| `POST` | `/v1/customer-portal/access` | Create named external reviewer access with one-time package token. | +| `GET` | `/v1/customer-portal/access` | List external reviewer access records without token hashes or secrets. | +| `POST` | `/v1/customer-portal/access/{id}/revoke` | Revoke an external reviewer access record. | +| `POST` | `/v1/customer-portal/package` | Exchange package token for scoped manifest. | +| `POST` | `/v1/customer-portal/package/download` | Exchange package token for scoped ZIP package download. | +| `GET` | `/v1/customer-portal/package/view` | Render public token-entry page for scoped package review. | +| `POST` | `/v1/customer-portal/package/view` | Exchange package token from a form body for scoped HTML package review. | +| `POST` | `/v1/customer-portal/package/view/download` | Exchange package token from a form body for scoped ZIP package download. | +| `POST` | `/v1/questionnaire-templates` | Create questionnaire template. | +| `POST` | `/v1/questionnaire-packages` | Generate evidence-backed responses. | +| `POST` | `/v1/questionnaire-drafts` | Create evidence-backed draft answers for review. | +| `GET` | `/v1/questionnaire-answer-library` | List reusable questionnaire answer drafts. | +| `POST` | `/v1/questionnaire-answer-library` | Create reusable questionnaire answer draft. | +| `GET` | `/v1/reports/security-review-package` | Redaction-aware package report. | +| `GET` | `/v1/reports/cra-readiness-html` | HTML CRA-readiness review content. | +| `POST` | `/v1/reports/pdf` | Create reproducible PDF report package metadata and payload hash. | +| `GET` | `/v1/reports/incident-package` | Incident package report. | +| `POST` | `/v1/report-templates` | Create allowed-field template. | +| `POST` | `/v1/report-templates/{id}/render` | Render deterministic JSON report. | +| `POST` | `/v1/incidents` | Create incident. | +| `POST` | `/v1/incidents/{id}/timeline` | Append incident timeline event. | +| `POST` | `/v1/incidents/{id}/webhook-receivers` | Create incident-scoped Ed25519 webhook receiver. | +| `POST` | `/v1/incident-webhooks/{receiver_id}` | Receive signed incident timeline webhook without bearer authentication. | + +Customer package manifests use the documented +[`customer-security-package.v2.0.0`](reference/customer-package-manifest.md) +schema. They include scoped release metadata, evidence summaries, redaction +profile details, readiness checks, verification material, limitations, and +non-claims while excluding raw payload bytes, object-store references, secrets, +token hashes, and internal decision notes. Redaction profiles can be created +from explicit `allowed_types` or the `customer_safe` / `security_review` +presets; preset policy fields cannot be overridden in the create request. + +### Integrity, Verification, And Operations + +| Method | Path | Notes | +|--------|------|-------| +| `POST` | `/v1/release-bundles` | Create signed release bundle. | +| `GET` | `/v1/release-bundles/{id}` | Read bundle metadata. | +| `GET` | `/v1/release-bundles/{id}/manifest` | Read bundle manifest. | +| `GET` | `/v1/release-bundles/{id}/verify` | Verify bundle. | +| `POST` | `/v1/evidence-bundles` | Export evidence bundle. | +| `POST` | `/v1/evidence-bundles/import` | Import evidence bundle. | +| `POST` | `/v1/verify` | Verify supported subject types. | +| `GET` | `/v1/audit-chain/verify` | Verify tenant audit chain. | +| `GET` | `/v1/audit-log` | List tenant audit entries; admin scope required. | +| `GET` | `/v1/signing-keys` | List keys. | +| `POST` | `/v1/signing-keys/rotate` | Rotate signing key. | +| `POST` | `/v1/signing-keys/{id}/revoke` | Revoke key for new signatures. | +| `POST` | `/v1/signing-providers` | Record external signing-provider metadata. | +| `POST` | `/v1/signing-operations` | Record external signing-provider operation receipt and signature ref. | +| `POST` | `/v1/provider-verifications` | Verify stored provider identity metadata and optional local OIDC ID-token signature/claims. | +| `POST` | `/v1/saas/profiles` | Create explicit-instance-admin SaaS edition profile record. | +| `POST` | `/v1/artifact-signatures` | Create artifact signature metadata. | +| `GET` | `/v1/artifact-signatures/{id}` | Read artifact signature. | +| `POST` | `/v1/artifact-signatures/{id}/verify-cosign` | Verify cosign-style artifact signature. | +| `POST` | `/v1/merkle-batches` | Create signed checkpoint batch. | +| `GET` | `/v1/merkle-batches/{id}/verify` | Verify batch. | +| `POST` | `/v1/transparency-checkpoints` | Record external anchoring metadata. | +| `POST` | `/v1/public-transparency-logs` | Record optional public transparency log configuration. | +| `POST` | `/v1/public-transparency-log-entries` | Record published public transparency log entry metadata. | +| `POST` | `/v1/public-transparency-log-entries/{id}/verify` | Verify operator-supplied RFC6962-style public transparency inclusion proof material. | +| `POST` | `/v1/public-transparency-log-entries/{id}/fetch-proof` | Fetch proof material from the configured transparency endpoint or proof gateway and verify it locally. | +| `POST` | `/v1/object-retention-policies` | Record retention policy intent with optional tenant-prefixed sample object key and legal-hold proof requirement. | +| `POST` | `/v1/object-retention-policies/{id}/verify` | Record provider verification transition. | +| `POST` | `/v1/legal-holds` | Record legal hold. | +| `POST` | `/v1/retention-overrides` | Record retention override. | +| `GET` | `/v1/reports/retention` | List retention records. | +| `GET` | `/v1/reports/custody-review` | Review tenant signing-provider and object-lock verification metadata for deployment custody review. | +| `POST` | `/v1/backup-manifests` | Generate backup manifest. | +| `GET` | `/v1/backup-manifests/{id}/verify` | Verify backup manifest. | + +### Security Evidence And Contracts + +| Method | Path | Notes | +|--------|------|-------| +| `POST` | `/v1/security-scans` | Upload SAST, DAST, secret scan, license scan, or API-security scan JSON. | +| `POST` | `/v1/api-security-scans` | Convenience API-security scan route. | +| `POST` | `/v1/security-documents` | Upload sensitive manual security document metadata/payload. | +| `POST` | `/v1/vex` | Upload OpenVEX. | +| `POST` | `/v1/vex/preview` | Preview OpenVEX mapping without storing evidence. | +| `POST` | `/v1/vex/cyclonedx` | Upload CycloneDX VEX. | +| `POST` | `/v1/vex/cyclonedx/preview` | Preview CycloneDX VEX mapping without storing evidence. | +| `GET` | `/v1/vex/{id}` | Read VEX metadata. | +| `GET` | `/v1/vex/{id}/import-report` | Read VEX parser report with safe counts, warnings, and mapping failures. | +| `POST` | `/v1/openapi-contracts` | Upload OpenAPI contract. | +| `GET` | `/v1/openapi-contracts/{id}` | Read contract metadata. | +| `POST` | `/v1/openapi-diffs` | Compare stored contracts. | +| `POST` | `/v1/policies/evaluate` | Evaluate built-in release policy. | +| `POST` | `/v1/custom-policies` | Create deterministic custom policy. | +| `POST` | `/v1/custom-policies/{id}/evaluate` | Store replayable policy evaluation. | + +## Current Contract Limitations + +- `openapi.yaml` is generated as compact JSON-style YAML and is optimized for drift checks and tooling, not prose review. +- The current OpenAPI precision gate reports every registered public operation + as precise. New or changed routes should add endpoint-specific request, + response, parameter, auth-scope, idempotency, and error metadata before they + are merged. +- Operation-level examples are maintained here and in tests until the generator emits richer examples. +- OpenAPI diffing classifies operation removal/addition, required request-body and request-field changes, response status changes, and broad path-count fallback for older stored contracts. + +These limitations should be called out in release evidence when API contract review is part of the release process. diff --git a/docs/architecture.md b/docs/architecture.md index d1814a4..a6dced5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -10,16 +10,48 @@ Evydence follows a ports-and-adapters shape: - `internal/adapters/objectstore/s3` stores the same tenant-prefixed object keys in S3/MinIO-compatible buckets. - `cmd/*` contains process entry points. -Core logic does not depend on HTTP routers, SQL drivers, object storage SDKs, queues, KMS providers, provider clients, or UI frameworks. PostgreSQL persistence currently stores a versioned ledger snapshot and also rebuilds tenant-scoped relational projection rows plus forward-compatible per-resource tables for the implemented release, evidence, source, deployment, and control resources. The projection supports operational querying and migration discipline while keeping the application store contract small. +Core logic does not depend on HTTP routers, SQL drivers, object storage SDKs, queues, KMS providers, provider clients, or UI frameworks. PostgreSQL persistence currently stores a versioned ledger snapshot and rebuilds tenant-scoped relational projection rows plus forward-compatible per-resource tables for implemented release, evidence, source, deployment, and control resources. Identity, idempotency, and customer portal token records are synchronized into relational rows with non-secret hashes and tenant-scoped constraints. Release-ledger core rows are also synchronized for products, projects, releases, artifacts, evidence, audit-chain entries, signing keys, signatures, SBOMs, vulnerability scans, OpenAPI contracts, policy evaluations, release bundles, and verification receipts. Collector/build provenance, source/deployment, incident, security evidence, SBOM diff, vulnerability workflow, contract diff, custom policy, waiver, approval, DSSE trust-root, collector release, Cosign verification, signing provider, Merkle batch, transparency checkpoint, evidence lifecycle, release candidate, VEX/risk decision, control, package, report, retention, provider verification, signing operation, and future-extension records are synchronized into their migration-backed relational tables. Production API and worker startup defaults to relational-only reconstruction and disables compatibility snapshot writes; local development defaults to snapshot-preferred compatibility. In production, API startup also takes a PostgreSQL advisory writer lease so an accidental second API writer fails closed while the supported profile remains single-writer. When the PostgreSQL store is configured, critical runtime mutations for tenants, API-key hashes, SSO-session hashes, customer-portal token hashes, idempotency records, audit-chain entries, signing keys, signatures, release bundles, verification results, provider verification receipts, vulnerability decisions, and outbox jobs are written through focused transaction-backed mutations instead of relying only on aggregate `SaveState`. Release-ledger and evidence-core mutations use focused transaction-backed mutations, and remaining aggregate persistence calls synchronize relational rows without writing the compatibility snapshot. If the snapshot row is absent, the Postgres store can rebuild identity, SSO session, customer portal token, release-ledger core, build provenance, source/deployment, incident, security evidence, SBOM diff, vulnerability workflow, contract diff, custom policy, waiver, approval, DSSE trust-root, collector release, Cosign verification, signing provider, Merkle batch, transparency checkpoint, evidence lifecycle, release candidate, VEX/risk decision, control, package, report, retention, provider verification, signing operation, and future-extension families from relational rows. Moving all canonical production writes to dependency-ordered relational repositories is tracked as production hardening, not as a completed production maturity claim. -## Security Boundaries +## Tenant And Auth Boundaries -Tenant isolation is enforced in application methods before reads and writes return data. API keys are scoped, revocable, and stored as HMAC-SHA256 hashes with `EVYDENCE_API_KEY_PEPPER`. Human SSO session tokens and customer portal package tokens are also stored as hashes and returned only once. Human actors derive scopes from tenant role bindings; collector identity is server-derived from the API key binding and is not trusted from build upload request bodies. Evidence, evidence lifecycle events, incidents, remediation tasks, security scans, manual security documents, SBOM diffs, VEX documents, vulnerability decisions/workflow records, organizations, users, role bindings, SSO providers, SSO sessions, legal holds, retention overrides, customer portal access records, questionnaire packages, commercial collector definitions, waivers, approvals, customer packages, report templates, evidence bundles, exceptions, build runs, build attestations, release candidates, artifact signatures, source-control records, deployment events, contract diffs, custom policy evaluations, control evidence links, and release bundle records are append-only in behavior; changes are represented by supersession, lifecycle events, approval transitions, session revocation, package access records, links, verification receipts, rollback-as-new-event records, or new audit-chain entries. +Tenant isolation is enforced in application methods before reads and writes return data. API keys are scoped, revocable, and stored as HMAC-SHA256 hashes with `EVYDENCE_API_KEY_PEPPER`. -When `EVYDENCE_DATABASE_URL` is set, mutations are saved to PostgreSQL before successful responses return. Upload payload bytes, including raw SBOM, vulnerability scan, OpenAPI, OpenVEX, and DSSE build-attestation payloads, are written to the configured filesystem object store with tenant-prefixed keys and SHA-256 digest checks before metadata is accepted. Outbox jobs are persisted in PostgreSQL and claimed by workers with `FOR UPDATE SKIP LOCKED`. +Human SSO session tokens and customer portal package tokens are also stored as hashes and returned only once. Human actors derive scopes from tenant role bindings. Collector identity is server-derived from the API key binding and is not trusted from build upload request bodies. -Release readiness is deterministic and evidence-scoped. Open critical vulnerability findings block readiness unless the latest decision marks the finding `not_affected` or `fixed`, or an approved unexpired exception applies to the release/finding. Passed build provenance and a structurally valid build attestation must link to release artifact digests. Source snapshots, deployment records, incident packages, security scans, manual reviews, SBOM diffs, contract diffs, API security checks, customer packages, customer portal package access, questionnaire packages, evidence bundles, and custom policies add traceability and reproducible decisions but do not prove provider truth, scanner authority, runtime security, legal sufficiency, or secure releases. Control coverage and CRA-readiness reports use versioned tenant-created controls, explicit evidence links, approved unexpired control exceptions, and built-in starter packs for CRA-readiness, NIST SSDF-lite, SOC 2-style technical evidence, and ISO 27001-style technical evidence. GitHub OIDC subject metadata can be captured, but OIDC token verification and live provider API verification are roadmap work. DSSE attestation signatures can be verified against configured Ed25519 trust roots when raw attestation bytes are available. Cosign-style artifact verification records digest binding, signature presence, and optional Rekor metadata without overstating full Sigstore trust-chain validation. Signing keys support revocation and valid-at-signing semantics for historical signatures. Merkle batches, signed checkpoints, optional transparency checkpoint records, backup manifests, object-retention policy records, legal holds, retention overrides, readiness, metrics, instance admin diagnostics, and admin audit queries provide operational integrity and review surfaces. Collector supply-chain records track pinned collector versions with signature, SBOM, and scan evidence where available; commercial collector definitions add extension metadata without granting provider trust. Air-gapped import-bundle workflows preserve the same tenant-scoped import path after controlled transfer. Reports include gaps, assumptions, and limitations and do not make legal compliance or secure-release claims. +Instance diagnostics require explicit `instance:admin` scope. Tenant admin and ordinary wildcard tenant keys do not satisfy that instance-wide scope by themselves. + +## Storage And Append-Only Behavior + +When `EVYDENCE_DATABASE_URL` is set, mutations are saved to PostgreSQL before successful responses return. Upload payload bytes, including raw SBOM, vulnerability scan, OpenAPI, OpenVEX, CycloneDX VEX, and DSSE build-attestation payloads, are written to the configured object store with tenant-prefixed keys and SHA-256 digest checks before metadata is accepted. + +Evidence, evidence lifecycle events, incidents, remediation tasks, security scans, manual security documents, SBOM diffs, VEX documents, vulnerability decisions/workflow records, organizations, users, role bindings, SSO providers, SSO sessions, legal holds, retention overrides, customer portal access records, questionnaire packages and drafts, evidence summaries, evidence graph snapshots, commercial and marketplace collector definitions, waivers, approvals, customer packages, report templates, evidence bundles, exceptions, build runs, build attestations, release candidates, artifact signatures, source-control records, deployment events, contract diffs, custom policy evaluations, provider verifications, signing operations, control evidence links, public transparency log entries, and release bundle records are append-only in behavior. Changes are represented by supersession, lifecycle events, approval transitions, session revocation, package access records, links, verification receipts, rollback-as-new-event records, or new audit-chain entries. + +Outbox jobs are persisted in PostgreSQL and claimed by workers with `FOR UPDATE SKIP LOCKED`. Parser jobs re-read tenant-prefixed object-store payloads, verify size and digest, validate durable state, persist missing parser-derived normalized fields, and fail closed on mismatches. With `EVYDENCE_WORKER_OWNED_PARSER_SIDE_EFFECTS=true`, parser-backed uploads store accepted records and workers populate normalized document fields from replayed payloads. VEX-derived vulnerability decisions are created idempotently by the `parse_vex` worker in that mode. + +## Verification And Trust + +Release readiness is deterministic and evidence-scoped. Open critical vulnerability findings block readiness unless the latest decision marks the finding `not_affected` or `fixed`, or an approved unexpired exception applies to the release or finding. Passed build provenance and a structurally valid build attestation must link to release artifact digests. + +DSSE attestation signatures can be verified against configured Ed25519 trust roots when raw attestation bytes are available. Cosign-style artifact verification records digest binding, signature presence, and optional Rekor metadata without overstating full Sigstore trust-chain validation. Signing keys support revocation and valid-at-signing semantics for historical signatures. + +Merkle batches, signed checkpoints, optional transparency checkpoint/public transparency records with operator-supplied or fetched inclusion proof verification, backup manifests, object-retention policy records with verification hashes, legal holds, retention overrides, readiness, metrics, instance admin diagnostics, external signing gateway/AWS KMS signing receipts, and admin audit queries provide operational integrity and review surfaces. + +## Reports And Customer-Facing Packages + +Control coverage and CRA-readiness reports use versioned tenant-created controls, explicit evidence links, approved unexpired control exceptions, and built-in starter packs for CRA-readiness, NIST SSDF-lite, SOC 2-style technical evidence, and ISO 27001-style technical evidence. + +Source snapshots, deployment records, signed incident webhook events, incident packages, security scans, manual reviews, SBOM diffs, contract diffs, API security checks, customer packages, customer portal package access, questionnaire packages, evidence bundles, and custom policies add traceability and reproducible decisions. Reports include gaps, assumptions, and limitations. + +Evidence summaries, questionnaire drafts, graph snapshots, PDF packages, and anomaly reports are generated from stored records with citations, assumptions, and limitations. Customer-facing packages require explicit package scope, redaction profile, expiry, and access auditing. Customer package JSON and ZIP download paths return scoped manifest metadata and verification guidance; raw tenant evidence payload bytes are not returned. + +## Provider And Deployment Boundaries + +GitHub OIDC subject metadata can be captured, and stored OIDC/SAML identity links can be verified against tenant metadata. OIDC provider records can include public JWKS material for local EdDSA or RS256 ID-token signature and claim verification, and SAML provider records can include PEM signing certificates for local assertion signature and claim verification. Trust material can be rotated without recreating the provider, and OIDC public JWKS can be refreshed from discovery metadata. SSO credential exchange can issue bearer session secrets plus HttpOnly cookies for browser clients after local OIDC/SAML verification. Live provider management API verification, browser redirect/callback orchestration, and external group synchronization remain trust boundaries outside those records. Collector supply-chain records track pinned collector versions with signature, SBOM, and scan evidence where available; commercial and marketplace collector definitions add extension metadata without granting provider trust. + +Air-gapped import-bundle workflows preserve the same tenant-scoped import path after controlled transfer. Object-retention policy APIs record tenant-scoped retention intent and, when the S3/MinIO object-store adapter is configured, verify bucket versioning plus default object-lock mode and duration. Policies can also name a tenant-prefixed sample object key for object-level retention and optional legal-hold checks where the provider supports them. Those checks are evidence for the configured bucket and sample object only; WORM/object-lock enforcement, IAM policy, lifecycle rules, and deployment-specific retention review remain operator responsibilities. ## Limitations -The in-process store remains available only when `EVYDENCE_DATABASE_URL` is unset. S3/MinIO runtime object storage is available through the object-store port. Signing-provider records are implemented, but production-grade KMS/HSM signing operations remain a hardening area behind the external signing mode. Hand-tuned per-resource repository implementations remain roadmap work. `ENV=production` rejects the in-process store, default API-key pepper, and local plaintext signing-key mode. +The in-process store remains available only when `EVYDENCE_DATABASE_URL` is unset. S3/MinIO runtime object storage is available through the object-store port. Signing-provider operation receipts, an optional HTTPS signing gateway executor, built-in AWS KMS, GCP Cloud KMS, and Azure Key Vault signing executors, gateway-backed `pkcs11-hsm` mode, native PKCS#11/HSM custody profile records, OIDC discovery refresh, optional live OIDC UserInfo validation, an optional provider validation gateway, SSO credential exchange with session-scoped OIDC group-role mapping, public-transparency proof fetching, an optional transparency proof gateway, and optional worker-owned parser side effects are implemented, but native HSM module loading/execution, direct provider-specific management API clients, and external group synchronization remain deployment hardening work. Hand-tuned per-resource repository implementations remain production-readiness work. `ENV=production` rejects the in-process store, default API-key pepper, unsupported API writer modes or replica counts above one, local plaintext signing-key mode, and bootstrap secret printing. + +Evydence does not prove provider truth, scanner authority, runtime security, legal compliance, or release security by itself. diff --git a/docs/assets/package-viewer-desktop.png b/docs/assets/package-viewer-desktop.png new file mode 100644 index 0000000..3de3706 Binary files /dev/null and b/docs/assets/package-viewer-desktop.png differ diff --git a/docs/assets/package-viewer-mobile.png b/docs/assets/package-viewer-mobile.png new file mode 100644 index 0000000..2028ebb Binary files /dev/null and b/docs/assets/package-viewer-mobile.png differ diff --git a/docs/assets/package-viewer-preview.svg b/docs/assets/package-viewer-preview.svg new file mode 100644 index 0000000..a3913c2 --- /dev/null +++ b/docs/assets/package-viewer-preview.svg @@ -0,0 +1,33 @@ + + Evydence package viewer preview + Static preview of a customer package manifest, readiness result, linked evidence, assumptions, and limitations. + + + + Evydence Package Viewer + local file only + + + Readiness + blocked + critical finding requires + VEX decision or exception + + + Evidence Linked + SBOM: CycloneDX JSON + Scan: generic vulnerability JSON + Bundle: signed release manifest + + + Limitations + not legal compliance proof + not complete SBOM proof + not a secure-release guarantee + + + Customer Package Manifest + package_id: pkg_sample_customer_review + release: rel_payments_api_2026_05 + included: readiness report, bundle manifest, evidence summary + diff --git a/docs/assets/reviewer-journey.svg b/docs/assets/reviewer-journey.svg new file mode 100644 index 0000000..9df928b --- /dev/null +++ b/docs/assets/reviewer-journey.svg @@ -0,0 +1,56 @@ + + Evydence reviewer journey + Static journey through release summary, vulnerability decisions, evidence contents, verification, gaps, and limitations using non-sensitive sample package data. + + + Reviewer Journey + Sample package, local viewer only, no upload + + + + + + + + + + 1. Release Summary + Product, release, package scope, + artifact digests, report result. + + + 2. VEX Decisions + Affected, fixed, not affected, + approval and impact statements. + + + 3. Evidence Contents + SBOM, scan, VEX, bundle, + approvals, redaction metadata. + + + + + + 4. Verification Status + Manifest hash, archive scope, + bundle and audit-chain checks. + + + 5. Gaps + Missing evidence, stale items, + unhandled findings, exceptions. + + + 6. Limitations + No legal compliance proof, + no complete SBOM proof. + + + + + + + + Input: examples/end-to-end-release-evidence/sample-customer-package-manifest.json + diff --git a/docs/buyer-overview.md b/docs/buyer-overview.md new file mode 100644 index 0000000..0971639 --- /dev/null +++ b/docs/buyer-overview.md @@ -0,0 +1,49 @@ +# Buyer Evaluation Overview + +This overview is for product security, AppSec, platform, release, procurement, +and customer-security-review readers who want to understand what Evydence can +show before operating it. + +Evydence supports compliance readiness and technical evidence organization. It +does not make legal compliance conclusions, grant certification, prove SBOM +completeness, treat scanner output as authoritative, or guarantee release +security. + +## Fastest Buyer Path + +1. Run the [customer CVE review demo](tutorials/customer-cve-review-demo.md) to + inspect one release, one SBOM component, one vulnerability finding, one + decision, one approval, one package, and one offline verification path. +2. Open the [package viewer guide](how-to/view-packages.md) to see the + customer-safe package review surface and screenshots. +3. Follow [Evaluate Evydence in 10 minutes](tutorials/evaluate-in-10-minutes.md) + to verify public release artifacts and inspect package fixtures. +4. Review the [release evidence index](reference/release-evidence-index.md) to + see what the current public release candidate publishes as reproducible + engineering evidence. +5. Read the [capability map](reference/capability-map.md) to separate + implemented behavior from implemented-but-partial areas and external + controls. + +## Questions To Answer + +Use the buyer path to answer: + +- Which release and artifact are being reviewed? +- Which SBOM and vulnerability scan raised the finding? +- What VEX or manual decision was recorded? +- Who approved or recorded the decision? +- What package can be shared safely? +- What can be verified offline? +- What gaps, assumptions, exceptions, and limitations remain? + +## When To Switch To Operator Docs + +Move to [Operator Overview](operator-overview.md) when you need to run the API, +worker, PostgreSQL, object storage, release gates, backup/restore rehearsal, +Kubernetes deployment, or production-readiness checks. + +For public API integration review, use [OpenAPI contract](reference/openapi.md) +and [API reference](api.md). For production status, use +[Production readiness](reference/production-readiness.md) and +[Production exit review](reference/production-exit-review.md). diff --git a/docs/collectors/supply-chain.md b/docs/collectors/supply-chain.md index dc74129..9a4b9fc 100644 --- a/docs/collectors/supply-chain.md +++ b/docs/collectors/supply-chain.md @@ -2,12 +2,54 @@ This is a reference for collector release evidence. -Collector releases can be recorded with: +Collector records describe automated ingesters such as GitHub Actions, GitLab CI, generic CI, and import-bundle collectors. A collector API key is tenant-scoped and should be stored in the CI secret store. + +## Record A Collector Release ```http POST /v1/collectors/{id}/releases ``` -The release record includes version, artifact digest, optional signature evidence, optional SBOM, optional vulnerability scan, and whether the version is pinned. `GET /v1/collectors/{id}/health` returns the collector status and supply-chain evidence checks. +Representative request: + +```json +{ + "version": "0.1.0", + "artifact_digest": "sha256:2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae", + "signature_id": "sig_...", + "sbom_id": "sbom_...", + "scan_id": "scan_...", + "pinned": true +} +``` + +Expected status is `201`. Optional fields should reference evidence already recorded in the same tenant. + +## Review Collector Health + +```http +GET /v1/collectors/{id}/health +``` + +The health report can show whether: + +- a collector release record exists; +- the collector version is pinned; +- signature, SBOM, or vulnerability scan evidence was linked where available. + +Health reports help operators see collector evidence gaps. They do not prove that a collector is free of vulnerabilities, provider-verified, or safe to run. + +Marketplace collector package records have a separate health report: + +```http +GET /v1/marketplace-collectors/{id}/health +``` + +That report checks whether package signature, SBOM, and vulnerability scan evidence references are present and still point to tenant-owned evidence. It is a package evidence-gap report, not a marketplace trust or endorsement decision. + +## Related Docs -Collector health reports help operators see whether collector evidence exists and whether a version is pinned. They do not prove that a collector is free of vulnerabilities or safe at runtime. +- [Integrate CI collectors](../how-to/integrate-ci.md) +- [Source snapshot collectors](source-snapshots.md) +- [GitHub Actions release evidence workflow](../github-actions/release-evidence-workflow.yml) +- [GitLab release evidence CI template](../gitlab/evydence-release-evidence.gitlab-ci.yml) diff --git a/docs/commercial/category-comparison.md b/docs/commercial/category-comparison.md new file mode 100644 index 0000000..c2189f6 --- /dev/null +++ b/docs/commercial/category-comparison.md @@ -0,0 +1,78 @@ +# Category Comparison + +This page explains where Evydence fits next to scanners, SBOM inventory tools, +GRC platforms, trust centers, and scripts. It is intended to help buyers choose +the right tool for the job. The categories below solve real problems; Evydence +exists because release-level evidence packaging and verification is usually a +separate workflow. + +## Short Version + +Evydence is a self-hosted release evidence ledger. It organizes technical +evidence, decisions, exceptions, bundles, audit chains, controls, and +customer-safe packages around a product release. + +It complements tools that generate findings, inventories, questionnaires, or +customer portals. It does not replace them. + +## Comparison + +| Category | Strong At | Common Boundary | Where Evydence Fits | +| --- | --- | --- | --- | +| Vulnerability scanners | Finding known issues in source, containers, dependencies, or deployed surfaces. | Scanner output often needs release context, VEX decisions, exceptions, and customer-safe explanation. | Stores scanner output as evidence, links findings to releases and artifacts, records decisions and exceptions, and reports unresolved release blockers. | +| SBOM inventory tools | Tracking components, dependency inventories, and vulnerability exposure over time. | Inventory views do not always answer which evidence package supports a specific customer release. | Links SBOMs to artifacts/releases, preserves hashes, and exports release-scoped package material. | +| Broad GRC tools | Managing controls, tasks, vendor questionnaires, policies, and organization-wide compliance workflows. | Broad workflow can be too high level for reproducible release evidence and raw technical artifact verification. | Provides release-level technical evidence and control coverage inputs that can support GRC workflows without claiming to replace review. | +| Trust centers | Publishing selected security documents and package material to customers. | Public portals usually depend on curated upstream evidence and redaction decisions. | Generates scoped, verifiable package manifests and static reports that can feed a trust center or be shared offline. | +| DIY scripts and spreadsheets | Fast local coordination for one team or one release. | Harder to keep append-only history, tenant scope, signatures, audit chains, and repeatable package structure. | Provides a repeatable API, manifest schema, verification commands, and release evidence workflow. | + +## Named Adjacent Tools + +These examples are intentionally respectful and category-focused. The named +tools solve useful problems; Evydence is aimed at the release-level packaging +and verification workflow that usually sits beside them. + +| Tool or family | How to think about it | Evydence boundary | +| --- | --- | --- | +| Dependency-Track | Strong SBOM and component-risk management for product portfolios. | Evydence can ingest SBOM and scan outputs, then bind them to a specific release, decision trail, signed bundle, and customer-safe package. | +| GUAC | Strong supply-chain graph exploration and relationship analysis. | Evydence stays on release records, package manifests, verification receipts, and reviewer-facing evidence rather than replacing graph analysis. | +| OpenVEX tooling | Strong at producing or consuming VEX statements. | Evydence stores OpenVEX as evidence, normalizes customer-visible vulnerability decisions, and links decisions to releases, scans, SBOM context, packages, and exceptions. | +| Vanta, Drata, and similar SaaS GRC platforms | Strong broad compliance workflow, policy/task tracking, and customer-trust operations. | Evydence is self-hosted and release-evidence-specific. It can produce technical evidence inputs, but it does not claim legal compliance or certification. | +| Syft, Grype, Trivy, and other scanners/generators | Strong at generating SBOMs, scan findings, and technical outputs. | Evydence records those outputs, preserves hashes, links them to release context, and documents decisions, gaps, limitations, and package verification. | +| Internal scripts, spreadsheets, object storage, and ad hoc databases | Flexible and fast for a small team. | Evydence provides stable API contracts, idempotency, tenant scope, append-only evidence behavior, audit-chain records, package schemas, and reusable verification commands. | + +## Why Evydence Exists + +Release review commonly needs a single answer to a concrete question: + +> What technical evidence supports this release, what decisions were made, what +> gaps remain, and what can be shared safely? + +Evydence focuses on that release-level answer: + +- release-scoped artifacts, SBOMs, scans, VEX, provenance, controls, decisions, + exceptions, and approvals; +- append-only evidence lifecycle and audit-chain records; +- signed release bundles and verification receipts; +- customer-safe packages with redaction, limitations, and non-claims; +- self-hosted storage and operator-controlled trust boundaries. + +## When Another Tool Should Lead + +- Use scanners to discover findings. +- Use SBOM generators to produce component lists. +- Use dependency-inventory tools for component management at scale. +- Use GUAC-style graph tooling when supply-chain graph exploration is the + primary question. +- Use OpenVEX-focused tooling when the immediate task is authoring VEX + statements outside a release package workflow. +- Use GRC platforms for broad organizational compliance workflow. +- Use trust centers for public or customer-facing distribution workflows. +- Use Evydence when the release team needs a reproducible evidence ledger and + package for a specific product release. + +## Boundaries + +Evydence supports compliance readiness and technical evidence organization. It +does not make legal compliance conclusions, grant certification, prove SBOM +completeness, treat scanner output as authoritative, or guarantee release +security. diff --git a/docs/commercial/design-partner-pilot.md b/docs/commercial/design-partner-pilot.md new file mode 100644 index 0000000..f753304 --- /dev/null +++ b/docs/commercial/design-partner-pilot.md @@ -0,0 +1,96 @@ +# Design Partner Pilot + +This page describes a narrow paid pilot shape for teams evaluating Evydence as a +self-hosted release evidence ledger. It is commercial positioning, not a legal +compliance statement, certification, audit opinion, complete SBOM claim, +authoritative vulnerability assessment, or secure-release guarantee. + +## Pilot Offer + +We wire one software product release into Evydence, ingest SBOM, +vulnerability-scan, VEX/provenance, and artifact evidence, produce one signed +customer-safe evidence bundle or package, and review the self-hosted deployment +profile with the operator. + +The goal is to prove whether Evydence can organize, verify, and present the +technical evidence a release team already has, while making gaps, assumptions, +exceptions, and limitations explicit. + +## Ideal Customer Profile + +This pilot is a fit when the team: + +- ships software products where release evidence is requested by customers, + procurement, internal security, or engineering leadership; +- can run a self-hosted service with PostgreSQL, object storage, TLS, backups, + and operator-owned secrets; +- already generates at least one SBOM and one vulnerability scan result in CI or + release tooling; +- wants VEX-style vulnerability decisions and customer-safe package outputs; +- accepts a controlled self-hosted candidate profile with one API writer replica + and scalable worker replicas. + +It is not a fit when the team needs a hosted SaaS service, broad GRC workflow, +legal compliance determination, certification, scanner replacement, or +regulator/auditor-ready evidence without independent review. + +## Deliverables + +- One agreed product and release modeled in Evydence. +- One SBOM upload path and one vulnerability-scan upload path connected to the + release. +- One VEX document, manual vulnerability decision, or approved exception path + for an intentionally reviewed finding. +- One artifact digest and release bundle generated through Evydence. +- One customer-safe package or evidence bundle with manifest, verification + material, limitations, and non-claims. +- One deployment-profile review using the + [pilot deployment checklist](../how-to/pilot-deployment-checklist.md). +- One short findings summary covering implemented evidence, gaps, assumptions, + and next steps. + +## Non-Deliverables + +- Legal compliance advice or certification. +- A guarantee that the release is secure. +- A completeness guarantee for SBOM contents. +- A completeness or authority guarantee for scanner output. +- Hosted SaaS operation by default. +- Replacement of customer security review, external audit, or legal review. +- Unlimited integration work, custom UI, or provider-specific development beyond + the agreed pilot scope. +- Public disclosure of customer data, secrets, raw evidence payloads, private + keys, bearer tokens, database URLs, provider credentials, or unreleased + product details. + +## License And Commercial Path + +Evydence is available under `AGPL-3.0-only`. Teams that can comply with AGPL +obligations may evaluate and operate the public code under that license. + +Commercial license exceptions and paid self-hosted support are available when +AGPL obligations are not suitable for a proprietary deployment, private +distribution model, or internal commercial use. See +[Commercial licensing](../../COMMERCIAL.md) for the current license boundary. + +## Support Boundary + +Pilot support focuses on the agreed deployment, evidence workflow, package +verification, and documentation. Operators remain responsible for infrastructure +controls such as TLS, network policy, backup tooling, secret storage, object +storage retention, KMS/HSM configuration, identity-provider configuration, and +incident response. + +Bug reports and security reports must be sanitized. Do not send raw customer +evidence, tokens, private keys, database URLs, object-store credentials, or +unredacted vulnerability reports through public channels. + +## Pilot Exit Criteria + +- The agreed release evidence flow runs end to end. +- The customer-safe package verifies offline and states its limitations. +- The deployment checklist is completed or has explicit accepted gaps. +- The operator can explain backup, restore, signing, redaction, and scoped + access responsibilities. +- Follow-up work is separated into repo-local product gaps, deployment + responsibilities, and external provider dependencies. diff --git a/docs/commercial/product-landing-copy.md b/docs/commercial/product-landing-copy.md new file mode 100644 index 0000000..b8f90e2 --- /dev/null +++ b/docs/commercial/product-landing-copy.md @@ -0,0 +1,99 @@ +# Product Landing Copy + +This page is reusable copy for README sections, a website, outreach, or pilot +materials. Keep the wording aligned with repository evidence and avoid legal +compliance, certification, complete SBOM, scanner-authority, or release-security +guarantees. + +## Headline + +Release evidence customers can verify without handing control to another SaaS. + +## One-Sentence Pitch + +Evydence is a self-hosted API evidence ledger that organizes release artifacts, +SBOMs, vulnerability decisions, VEX, build provenance, controls, exceptions, and +customer-safe packages into reproducible, tamper-evident records. + +## Problem + +Software teams are asked for release evidence by customers, procurement, +security review, and internal leadership. The evidence usually lives across CI, +artifact stores, scanners, spreadsheets, tickets, GRC tools, and ad hoc folders. + +That makes it hard to answer practical questions: + +- what shipped in this release; +- which SBOM, scan, build, VEX, exception, and approval records support it; +- what is missing or explicitly assumed; +- what can be shared with a customer without exposing raw internal evidence; +- how a reviewer can verify the package later. + +## How It Works + +1. Model products, releases, artifacts, collectors, and controls. +2. Ingest evidence from APIs, CI workflows, SBOMs, scans, OpenVEX, source + snapshots, and build attestations. +3. Preserve raw payload hashes and tenant-scoped object references while + normalizing reviewer-safe summaries. +4. Record decisions, exceptions, approvals, lifecycle events, and audit-chain + entries append-only. +5. Generate release-readiness reports, signed bundles, evidence bundles, and + customer-safe packages with limitations and verification material. + +## Why Self-Hosted + +Evydence is designed for teams that need evidence custody, tenant-local storage, +operator-controlled secrets, private package workflows, and deployment-specific +trust decisions. + +Self-hosting lets the operator choose PostgreSQL, object storage, signing mode, +backup strategy, network boundaries, and customer-sharing policy. It also keeps +Evydence out of the path of claiming legal or audit conclusions on the +operator's behalf. + +## What It Is Not + +- Not a vulnerability scanner. +- Not an SBOM generator. +- Not a broad SaaS GRC platform. +- Not a public trust center. +- Not a legal compliance determination. +- Not a certification service. +- Not a guarantee that a release is secure. +- Not a substitute for customer review, external audit, or legal advice. + +## Pilot CTA + +Design-partner pilot: connect one product release, ingest SBOM plus vulnerability +scan plus VEX/provenance evidence, produce one signed customer-safe evidence +bundle, and review the self-hosted deployment profile. + +See [Design Partner Pilot](design-partner-pilot.md) for scope, deliverables, +non-deliverables, support boundaries, and the AGPL/commercial license path. + +## Paid Readiness Offer + +Release evidence readiness review: install or review one self-hosted Evydence +deployment profile, configure one product release, connect one CI evidence path, +generate the first customer-safe package, verify it offline, and document gaps, +assumptions, limitations, operator responsibilities, and external dependencies. + +Included deliverables: + +- one short readiness summary; +- one deployment-checklist pass with accepted gaps; +- one SBOM/vulnerability/build/artifact upload path where the operator already + has the files or commands; +- one customer-safe package or evidence bundle with manifest, hashes, + verification material, limitations, and non-claims; +- one prioritized follow-up list. + +Excluded unless separately agreed: + +- hosted SaaS operation; +- legal compliance advice, certification, audit opinion, regulator acceptance, + secure-release guarantees, complete SBOM proof, or authoritative vulnerability + coverage; +- unlimited integration work, broad GRC workflows, scanner replacement, custom + portal work, or public handling of raw customer evidence and secrets. diff --git a/docs/explanation/architecture-diagram.md b/docs/explanation/architecture-diagram.md new file mode 100644 index 0000000..2d93e35 --- /dev/null +++ b/docs/explanation/architecture-diagram.md @@ -0,0 +1,42 @@ +# Architecture Diagram + +This explanation is a compact map of the current controlled self-hosted +production candidate architecture. It omits many resource families but shows the +trust boundaries operators need to understand first. + +```mermaid +flowchart LR + ci[CI collectors and CLI uploaders] --> api[Evydence API /v1] + users[Operators and API clients] --> api + portal[Customer portal token holder] --> api + + api --> auth[Scoped API key / SSO / portal auth] + auth --> app[Application ledger and policy services] + app --> pg[(PostgreSQL relational state)] + app --> obj[(S3/MinIO or filesystem object store)] + app --> outbox[(PostgreSQL outbox)] + app --> signer[Signing executor or KMS/gateway] + app --> provider[Optional provider validation gateway] + app --> transparency[Optional transparency proof gateway] + + worker[Evydence worker replicas] --> outbox + worker --> pg + worker --> obj + worker --> signer + + pg --> reports[Readiness, control, package, audit and backup reports] + obj --> reports +``` + +## Boundaries + +- API clients and collectors are untrusted until authenticated and authorized. +- Tenant IDs are enforced at application and persistence boundaries. +- Raw payload bytes live in object storage under tenant-prefixed keys. +- PostgreSQL is the production source of truth for the controlled profile. +- Workers re-read object payloads and verify digests before parser side effects. +- Signing providers receive payload hashes or signing requests, not raw evidence + payload bytes from Evydence. +- Provider validation, public transparency, broad WORM/object-lock proof, and + native HSM custody remain deployment/provider responsibilities unless a + specific deployment closes those checks. diff --git a/docs/explanation/trust-model.md b/docs/explanation/trust-model.md index 700d2f5..044d32b 100644 --- a/docs/explanation/trust-model.md +++ b/docs/explanation/trust-model.md @@ -2,8 +2,8 @@ Evydence records technical evidence, hashes payloads, links evidence to products and releases, and records append-only audit-chain entries. It supports reproducible review by exposing evidence gaps, assumptions, exceptions, waivers, verification receipts, and report limitations. -Evydence trusts tenant-scoped API keys and SSO session tokens after server-side hash verification. Collector identity is derived from the collector API key binding. Customer portal access uses expiring package tokens and exposes scoped package manifests, not raw tenant evidence. +Evydence trusts tenant-scoped API keys and SSO session tokens after server-side hash verification. Instance-wide diagnostics require the explicit `instance:admin` scope; tenant `admin` and wildcard tenant keys remain tenant-scoped unless that scope is also present. Collector identity is derived from the collector API key binding. Customer portal access uses expiring package tokens and exposes scoped package manifests, not raw tenant evidence. Repeated failed portal attempts revoke the access record without storing the supplied token. -Provider metadata such as GitHub Actions, GitLab, DSSE, VEX, SBOM, OpenAPI, and scanner payloads is treated as uploaded evidence unless a configured trust root or verification path proves more. Structural parsing is not the same as provider truth or cryptographic trust. +Provider metadata such as GitHub Actions, GitLab, DSSE, VEX, SBOM, OpenAPI, scanner payloads, and SSO provider records is treated as uploaded evidence unless a configured trust root or verification path proves more. Current SSO support records provider metadata, identity links, expiring sessions, local OIDC token verification, local SAML assertion verification against tenant-configured trust material, OIDC discovery/JWKS refresh, optional OIDC UserInfo validation when a caller supplies an access token, and an optional operator-controlled provider validation gateway. It does not include direct provider-specific management API clients, browser login callbacks, or external group synchronization. Structural parsing is not the same as provider truth or cryptographic trust. Reports are readiness and evidence-organization outputs. They do not state legal compliance, certification, complete SBOM coverage, authoritative vulnerability results, or secure releases. diff --git a/docs/github-actions/end-to-end-release-evidence.md b/docs/github-actions/end-to-end-release-evidence.md new file mode 100644 index 0000000..d76a517 --- /dev/null +++ b/docs/github-actions/end-to-end-release-evidence.md @@ -0,0 +1,168 @@ +# End-To-End GitHub Actions Release Evidence Guide + +This guide shows the checked GitHub Actions path for recording one release's +build, SBOM, scanner, bundle, optional customer package, and readiness evidence. +It is a how-to for wiring CI to Evydence; it is not a claim that GitHub metadata, +scanner output, SBOM content, or the resulting release is complete or secure. + +Use the workflow template at +[`docs/github-actions/release-evidence-workflow.yml`](release-evidence-workflow.yml) +after reviewing the assumptions below. + +## Supported Flow + +The workflow performs these phases: + +1. Check out source and build the artifact. +2. Compute the artifact digest with `dist/evydence hash`. +3. Upload GitHub Actions build metadata with `dist/evydence github-actions upload-build`. +4. Generate CycloneDX SBOM and scanner JSON with Syft, Grype, and Trivy. +5. Build an Evydence upload manifest with `scripts/github_release_evidence_manifest.py`. +6. Run `dist/evydence ci preflight` to validate URL, key scopes, tenant-scoped IDs, and manifest shape. +7. Upload SBOM, scanner, release bundle, and optional customer package requests with `dist/evydence upload manifest`. +8. Read `/v1/reports/release-readiness` into `.evydence/release-readiness.json`. +9. Upload `.evydence/` as a GitHub Actions artifact for operator review. + +The workflow does not call the live GitHub API. It records GitHub Actions +environment metadata submitted by the runner, such as repository, workflow ref, +run ID, run attempt, ref, and commit SHA. Treat those fields as CI-submitted +evidence unless your deployment also records separate provider verification +receipts. + +## Required Evydence Scopes + +Create a least-privilege collector API key when possible. Store the secret once +in GitHub Actions as `EVYDENCE_API_KEY`; do not print it in logs. + +Minimum scopes for the end-to-end workflow: + +| Scope | Why it is needed | +| --- | --- | +| `build:write` | Upload GitHub Actions build provenance. | +| `evidence:write` | Upload SBOM, vulnerability scan, VEX, and related evidence. | +| `bundle:write` | Create the release bundle from the upload manifest. | +| `verify:read` | Read release-readiness output. | +| `product:read` | Preflight the configured product ID. | +| `project:read` | Preflight the configured project ID. | +| `release:read` | Preflight the configured release ID and read readiness. | +| `evidence:read` | Support readiness and evidence lookups during preflight and upload flows. | + +Add these only when the workflow creates a customer package: + +| Scope | Why it is needed | +| --- | --- | +| `package:write` | Create a scoped customer package. | +| `package:read` | Download or inspect package metadata in follow-up jobs. | + +Use an admin key only to create the collector key. Do not run routine CI uploads +with broad tenant-admin credentials. + +## GitHub Secrets And Variables + +Secrets: + +| Name | Value | +| --- | --- | +| `EVYDENCE_API_URL` | Base URL such as `https://evydence.example.test`. | +| `EVYDENCE_API_KEY` | Collector or tenant API key with the scopes above. | + +Variables: + +| Name | Value | +| --- | --- | +| `EVYDENCE_PRODUCT_ID` | Existing Evydence product ID. | +| `EVYDENCE_PROJECT_ID` | Existing Evydence project ID. | +| `EVYDENCE_RELEASE_ID` | Existing Evydence release ID. | +| `EVYDENCE_ARTIFACT_ID` | Existing artifact ID linked to the release. | +| `EVYDENCE_REDACTION_PROFILE_ID` | Optional redaction profile ID for package creation. | + +Secret handling rules: + +- Never echo `EVYDENCE_API_KEY`, bearer tokens, private keys, database URLs, raw + evidence payloads, customer data, or unreleased package contents. +- Store IDs as variables only when revealing those IDs is acceptable for your + repository visibility model. +- Rotate the collector key if it appears in logs, screenshots, issue comments, + or artifact uploads. +- Keep downloaded customer packages in short-lived artifacts with explicit + retention and access review. + +## Workflow Template + +Copy the checked workflow into the repository that builds your release: + +```sh +mkdir -p .github/workflows +cp docs/github-actions/release-evidence-workflow.yml .github/workflows/evydence-release-evidence.yml +``` + +Review and pin the scanner versions used by your runner image or workflow. The +checked template uses `syft`, `grype`, and `trivy` commands as integration +points; production runners should install reviewed versions before the evidence +job runs. + +Customer package creation is optional. To enable it, set both +`EVYDENCE_PRODUCT_ID` and `EVYDENCE_REDACTION_PROFILE_ID`; the manifest +generator requires all package fields together and rejects partial package +configuration. + +## Expected Outputs + +The workflow writes these files under `.evydence/`: + +| File | Meaning | +| --- | --- | +| `artifact.digest` | SHA-256 digest of the built artifact. | +| `upload-manifest.json` | Batched Evydence create/action requests. | +| `sbom-cyclonedx-upload.json` | SBOM upload payload generated from Syft/CycloneDX output. | +| `scan-grype-upload.json` | Generic vulnerability-scan upload payload from Grype output. | +| `scan-trivy-upload.json` | Generic vulnerability-scan upload payload from Trivy output. | +| `release-bundle-upload.json` | Release bundle creation payload. | +| `customer-package-upload.json` | Optional customer package creation payload. | +| `upload-output.txt` | IDs returned by `dist/evydence upload manifest`. | +| `release-readiness.json` | Readiness report with result, gaps, assumptions, and limitations. | + +Expected successful log markers: + +```text +upload manifest valid +uploaded /v1/sboms: +uploaded /v1/vulnerability-scans: +uploaded /v1/release-bundles: +``` + +If package creation is enabled, also expect: + +```text +uploaded /v1/customer-packages: +``` + +Readiness may still fail when evidence is missing or a policy gate blocks the +release. Treat that as a review signal, not as a compliance, certification, +scanner-authority, or release-security conclusion. + +## Local Test Assumption + +Default repository tests do not make live GitHub API calls and do not require +real GitHub repositories, GitHub OIDC tokens, scanners, S3, KMS, or customer +systems. Use the local simulation before copying the workflow: + +```sh +make local-ci-simulation-check +``` + +That check exercises the same Evydence CLI path with local fixtures and a +loopback API. Live provider validation remains a separate deployment or release +readiness task because it needs real provider accounts, secrets, and repository +settings. + +## Troubleshooting + +| Symptom | Likely cause | Fix | +| --- | --- | --- | +| `401 Unauthorized` | Missing, revoked, or malformed `EVYDENCE_API_KEY`. | Rotate or re-store the key. Do not print it. | +| `403 Forbidden` | Key lacks a required scope. | Compare scopes with the table above. | +| `404 Not Found` | Product, project, release, or artifact ID belongs to another tenant or does not exist. | Re-copy IDs from the Evydence API using the same tenant. | +| `409 IDEMPOTENCY_KEY_REUSED` | A workflow run idempotency key was reused with different content. | Review the earlier upload and use the run attempt as part of the key. | +| Scanner command missing | Runner image does not include Syft, Grype, or Trivy. | Install pinned versions before the evidence job or use a reviewed runner image. | +| Readiness failed | Evidence gap, unhandled critical finding, missing passed build/attestation, missing bundle, or missing decision/exception. | Read `.evydence/release-readiness.json` and remediate the specific gap. | diff --git a/docs/github-actions/quickstart-release-evidence.yml b/docs/github-actions/quickstart-release-evidence.yml new file mode 100644 index 0000000..ed1d014 --- /dev/null +++ b/docs/github-actions/quickstart-release-evidence.yml @@ -0,0 +1,125 @@ +name: evydence-quickstart-release-evidence + +on: + workflow_dispatch: + +jobs: + evydence-release-evidence: + runs-on: ubuntu-latest + permissions: + contents: read + env: + EVYDENCE_API_URL: ${{ secrets.EVYDENCE_API_URL }} + EVYDENCE_API_KEY: ${{ secrets.EVYDENCE_API_KEY }} + EVYDENCE_PRODUCT_ID: ${{ vars.EVYDENCE_PRODUCT_ID }} + EVYDENCE_PROJECT_ID: ${{ vars.EVYDENCE_PROJECT_ID }} + EVYDENCE_RELEASE_ID: ${{ vars.EVYDENCE_RELEASE_ID }} + EVYDENCE_ARTIFACT_ID: ${{ vars.EVYDENCE_ARTIFACT_ID }} + EVYDENCE_OPENVEX_PATH: ${{ vars.EVYDENCE_OPENVEX_PATH }} + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Build CLI artifact + run: | + set -euo pipefail + mkdir -p dist .evydence + go build -o dist/evydence ./cmd/evydence + + - id: artifact + name: Compute artifact digest + run: | + set -euo pipefail + dist/evydence hash dist/evydence > .evydence/artifact.digest + printf 'digest=%s\n' "$(cat .evydence/artifact.digest)" >> "$GITHUB_OUTPUT" + + - name: Write quickstart SBOM and vulnerability scan evidence + run: | + set -euo pipefail + cat > .evydence/sbom.cdx.json < .evydence/grype.json < artifact.digest + - id: artifact-digest + run: | + dist/evydence hash dist/evydence > artifact.digest + printf 'digest=%s\n' "$(cat artifact.digest)" >> "$GITHUB_OUTPUT" - name: Upload build provenance env: EVYDENCE_API_URL: ${{ secrets.EVYDENCE_API_URL }} @@ -23,7 +26,7 @@ jobs: EVYDENCE_PROJECT_ID: ${{ vars.EVYDENCE_PROJECT_ID }} EVYDENCE_RELEASE_ID: ${{ vars.EVYDENCE_RELEASE_ID }} EVYDENCE_ARTIFACT_ID: ${{ vars.EVYDENCE_ARTIFACT_ID }} - EVYDENCE_ARTIFACT_DIGEST: ${{ vars.EVYDENCE_ARTIFACT_DIGEST }} + EVYDENCE_ARTIFACT_DIGEST: ${{ steps.artifact-digest.outputs.digest }} run: | dist/evydence github-actions upload-build \ --url "$EVYDENCE_API_URL" \ @@ -32,3 +35,84 @@ jobs: --release-id "$EVYDENCE_RELEASE_ID" \ --artifact-id "$EVYDENCE_ARTIFACT_ID" \ --artifact-digest "$EVYDENCE_ARTIFACT_DIGEST" + - name: Generate SBOM and scanner evidence + run: | + set -euo pipefail + # Install or provide reviewed, pinned scanner versions in your runner image. + # These commands produce files consumed by scripts/github_release_evidence_manifest.py. + syft dir:. -o cyclonedx-json=sbom.cdx.json + grype dir:. -o json > grype.json + trivy fs --format json --output trivy.json . + - name: Build Evydence upload manifest + env: + EVYDENCE_RELEASE_ID: ${{ vars.EVYDENCE_RELEASE_ID }} + EVYDENCE_ARTIFACT_ID: ${{ vars.EVYDENCE_ARTIFACT_ID }} + EVYDENCE_PRODUCT_ID: ${{ vars.EVYDENCE_PRODUCT_ID }} + EVYDENCE_REDACTION_PROFILE_ID: ${{ vars.EVYDENCE_REDACTION_PROFILE_ID }} + run: | + set -euo pipefail + python3 scripts/github_release_evidence_manifest.py \ + --out .evydence/upload-manifest.json \ + --release-id "$EVYDENCE_RELEASE_ID" \ + --artifact-id "$EVYDENCE_ARTIFACT_ID" \ + --target-ref "pkg:github/${GITHUB_REPOSITORY}@${GITHUB_SHA}" \ + --idempotency-prefix "gha-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" \ + --cyclonedx-sbom sbom.cdx.json \ + --grype-json grype.json \ + --trivy-json trivy.json \ + --include-release-bundle + if [ -n "${EVYDENCE_PRODUCT_ID:-}" ] && [ -n "${EVYDENCE_REDACTION_PROFILE_ID:-}" ]; then + python3 scripts/github_release_evidence_manifest.py \ + --out .evydence/upload-manifest.json \ + --release-id "$EVYDENCE_RELEASE_ID" \ + --artifact-id "$EVYDENCE_ARTIFACT_ID" \ + --target-ref "pkg:github/${GITHUB_REPOSITORY}@${GITHUB_SHA}" \ + --idempotency-prefix "gha-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" \ + --cyclonedx-sbom sbom.cdx.json \ + --grype-json grype.json \ + --trivy-json trivy.json \ + --include-release-bundle \ + --customer-package-product-id "$EVYDENCE_PRODUCT_ID" \ + --customer-package-redaction-profile-id "$EVYDENCE_REDACTION_PROFILE_ID" \ + --customer-package-title "Release evidence ${GITHUB_SHA}" \ + --customer-package-expires-at "$(date -u -d '+30 days' +%Y-%m-%dT%H:%M:%SZ)" + fi + - name: Upload SBOM, scanner, bundle, and package evidence + env: + EVYDENCE_API_URL: ${{ secrets.EVYDENCE_API_URL }} + EVYDENCE_API_KEY: ${{ secrets.EVYDENCE_API_KEY }} + EVYDENCE_PRODUCT_ID: ${{ vars.EVYDENCE_PRODUCT_ID }} + EVYDENCE_PROJECT_ID: ${{ vars.EVYDENCE_PROJECT_ID }} + EVYDENCE_RELEASE_ID: ${{ vars.EVYDENCE_RELEASE_ID }} + EVYDENCE_ARTIFACT_ID: ${{ vars.EVYDENCE_ARTIFACT_ID }} + run: | + dist/evydence upload validate-manifest \ + --manifest .evydence/upload-manifest.json + dist/evydence ci preflight \ + --url "$EVYDENCE_API_URL" \ + --api-key "$EVYDENCE_API_KEY" \ + --product-id "$EVYDENCE_PRODUCT_ID" \ + --project-id "$EVYDENCE_PROJECT_ID" \ + --release-id "$EVYDENCE_RELEASE_ID" \ + --artifact-id "$EVYDENCE_ARTIFACT_ID" \ + --manifest .evydence/upload-manifest.json + dist/evydence upload manifest \ + --url "$EVYDENCE_API_URL" \ + --api-key "$EVYDENCE_API_KEY" \ + --manifest .evydence/upload-manifest.json \ + | tee .evydence/upload-output.txt + - name: Read release readiness + env: + EVYDENCE_API_URL: ${{ secrets.EVYDENCE_API_URL }} + EVYDENCE_API_KEY: ${{ secrets.EVYDENCE_API_KEY }} + EVYDENCE_RELEASE_ID: ${{ vars.EVYDENCE_RELEASE_ID }} + run: | + set -euo pipefail + curl -fsS \ + -H "Authorization: Bearer $EVYDENCE_API_KEY" \ + "$EVYDENCE_API_URL/v1/reports/release-readiness?release_id=$EVYDENCE_RELEASE_ID" \ + | tee .evydence/release-readiness.json + - uses: actions/upload-artifact@v4 + with: + name: evydence-release-evidence + path: .evydence/ diff --git a/docs/gitlab/evydence-release-evidence.gitlab-ci.yml b/docs/gitlab/evydence-release-evidence.gitlab-ci.yml index a9937c2..bcac33b 100644 --- a/docs/gitlab/evydence-release-evidence.gitlab-ci.yml +++ b/docs/gitlab/evydence-release-evidence.gitlab-ci.yml @@ -19,6 +19,43 @@ evydence:evidence: dependencies: - build script: + - export EVYDENCE_ARTIFACT_DIGEST="$(cat artifact.digest)" + - | + cat > evydence-upload-manifest.json < .evydence/grype.json +``` + +Normalize and upload through the same manifest generator: + +```sh +python3 scripts/github_release_evidence_manifest.py \ + --out .evydence/upload-manifest.json \ + --release-id "$EVYDENCE_RELEASE_ID" \ + --artifact-id "$EVYDENCE_ARTIFACT_ID" \ + --target-ref "pkg:github/${GITHUB_REPOSITORY}@${GITHUB_SHA}" \ + --idempotency-prefix "ci-${GITHUB_RUN_ID:-local}-${GITHUB_RUN_ATTEMPT:-1}" \ + --grype-json .evydence/grype.json +``` + +Expected Evydence endpoint: `POST /v1/vulnerability-scans`. + +## Trivy Vulnerability Scan + +Use Trivy filesystem JSON: + +```sh +mkdir -p .evydence +trivy fs --format json --output .evydence/trivy.json . +``` + +Normalize and upload: + +```sh +python3 scripts/github_release_evidence_manifest.py \ + --out .evydence/upload-manifest.json \ + --release-id "$EVYDENCE_RELEASE_ID" \ + --artifact-id "$EVYDENCE_ARTIFACT_ID" \ + --target-ref "pkg:github/${GITHUB_REPOSITORY}@${GITHUB_SHA}" \ + --idempotency-prefix "ci-${GITHUB_RUN_ID:-local}-${GITHUB_RUN_ATTEMPT:-1}" \ + --trivy-json .evydence/trivy.json +``` + +Expected Evydence endpoint: `POST /v1/vulnerability-scans`. + +## GitHub Actions + +Use the end-to-end guide when your repository builds in GitHub Actions: + +- [End-to-end GitHub Actions release evidence guide](../github-actions/end-to-end-release-evidence.md) +- [Scanner workflow template](../github-actions/release-evidence-workflow.yml) +- [Quickstart workflow template](../github-actions/quickstart-release-evidence.yml) + +Use `contents: read` by default. Add `id-token: write` only when your deployment +intentionally records OIDC subject metadata. The current workflow does not call +the live GitHub API. + +## GitLab CI + +Use the checked GitLab CI template: + +```sh +cp docs/gitlab/evydence-release-evidence.gitlab-ci.yml .gitlab-ci.yml +``` + +Required variables: + +- `EVYDENCE_API_URL` +- `EVYDENCE_API_KEY` +- `EVYDENCE_PROJECT_ID` +- `EVYDENCE_RELEASE_ID` +- `EVYDENCE_ARTIFACT_ID` + +GitLab-provided variables such as `CI_PROJECT_PATH`, `CI_COMMIT_SHA`, +`CI_PIPELINE_ID`, and `CI_JOB_ID` are recorded as submitted CI metadata. They do +not prove GitLab-side branch protection, approval policy, runner integrity, or +pipeline identity by themselves. + +## Dependency-Track + +Dependency-Track is adjacent inventory and analysis, not replaced by Evydence. +Use its exports as evidence inputs when they are part of your review process. + +SBOM export handoff example: + +```sh +curl -fsS \ + -H "X-Api-Key: $DEPENDENCY_TRACK_API_KEY" \ + "$DEPENDENCY_TRACK_URL/api/v1/bom/cyclonedx/project/$DEPENDENCY_TRACK_PROJECT_UUID" \ + > .evydence/dependency-track-sbom.cdx.json +``` + +Then upload the exported CycloneDX document through the Syft/CycloneDX manifest +path above. Keep the Dependency-Track API key in CI secrets and do not store +the key or raw exported customer data in logs. + +If you export Dependency-Track findings, normalize them to Evydence generic +vulnerability scan JSON before upload: + +```json +{ + "release_id": "rel_example", + "scanner": "dependency-track", + "target_ref": "pkg:github/acme/service@sha", + "findings": [ + { + "vulnerability": "CVE-2026-0099", + "component": "pkg:maven/example/component@1.0.0", + "severity": "high", + "state": "open" + } + ] +} +``` + +Expected Evydence endpoint: `POST /v1/vulnerability-scans`. + +## Jira Metadata Links + +Jira links are metadata, not evidence trust by themselves. Use them to connect a +decision, exception, remediation task, or security review to the work item where +people coordinated the change. + +Generic evidence metadata example: + +```json +{ + "product_id": "prod_example", + "release_id": "rel_example", + "type": "issue_tracker", + "subtype": "jira_metadata", + "title": "Jira review metadata for CVE-2026-0099", + "payload_hash": "sha256:", + "metadata": { + "provider": "jira", + "issue_key": "SEC-1234", + "issue_url": "https://jira.example.test/browse/SEC-1234", + "linked_subject": "CVE-2026-0099" + }, + "limitations": [ + "Jira metadata links the review context but does not prove the ticket state or approval policy by itself." + ] +} +``` + +Expected Evydence endpoint: `POST /v1/evidence`. Use a redacted local JSON file +for `payload_hash` input and avoid uploading comments, attachments, credentials, +or customer data unless the package scope and redaction profile explicitly allow +it. + +## S3-Compatible Object Storage + +For shared or production-like deployments, use S3/MinIO-compatible object +storage rather than filesystem storage: + +```sh +EVYDENCE_OBJECT_STORE=s3 +EVYDENCE_S3_ENDPOINT=s3.example.test +EVYDENCE_S3_BUCKET=evydence +EVYDENCE_S3_REGION=eu-north-1 +EVYDENCE_S3_USE_SSL=true +EVYDENCE_S3_ACCESS_KEY_ID= +EVYDENCE_S3_SECRET_ACCESS_KEY= +``` + +Operator checklist: + +- create the bucket outside Evydence before startup; +- use TLS for remote object storage; +- store object credentials in deployment secrets, not source control; +- back up PostgreSQL and object storage from the same recovery point; +- enable provider-side versioning, retention, or object lock when required; +- rehearse restore with [Backup and restore runbook](../runbooks/backup-restore.md); +- review [Object store recovery runbook](../runbooks/object-store-recovery.md). + +Evydence tenant-prefixes object keys and verifies stored payload digests during +worker replay and verification flows. Bucket policy, IAM, encryption, lifecycle +rules, WORM/object-lock enforcement, and provider availability remain operator +responsibilities. + +## Local Validation + +Validate generated manifests without network access: + +```sh +go run ./cmd/evydence upload validate-manifest \ + --manifest .evydence/upload-manifest.json +``` + +Run the checked local CI path: + +```sh +make local-ci-simulation-check +``` + +Those checks validate Evydence-side manifest handling and local evidence flow. +They intentionally do not validate live provider credentials, scanner +installations, SaaS project permissions, or object-store service policies. diff --git a/docs/kubernetes.md b/docs/kubernetes.md index 1b3b9cf..2221586 100644 --- a/docs/kubernetes.md +++ b/docs/kubernetes.md @@ -1,10 +1,33 @@ # Kubernetes Deployment -This is a how-to guide for self-hosted Kubernetes deployments. +This is a how-to guide for controlled self-hosted Kubernetes deployments. The Helm chart lives at `deploy/helm/evydence`. It deploys the API, worker, service, optional ingress, readiness and liveness probes, and configuration for external PostgreSQL, S3/MinIO object storage, and external signing mode. -Create a secret with at least: +## Prerequisites + +- An Evydence image with an explicit immutable tag or digest in a registry the + cluster can pull from. Project-owned release-candidate images are published + through the maintainer Container Image workflow as + `ghcr.io/aatuh/evydence:` when that workflow has run for the tag. + Operators should pin the digest and verify cosign evidence, or build, sign, + and publish their own image through a controlled registry. +- External PostgreSQL and S3/MinIO-compatible object storage. +- A pre-created object-store bucket. +- A Kubernetes secret containing at least `EVYDENCE_DATABASE_URL` and `EVYDENCE_API_KEY_PEPPER`. +- A signing setup compatible with `EVYDENCE_SIGNING_KEY_MODE=external`, + `aws-kms`, `gcp-kms`, `azure-key-vault`, or the gateway-backed `pkcs11-hsm` + profile documented in the configuration reference. + +The chart does not create databases, buckets, KMS keys, or secrets. +Use the [external controls matrix](reference/external-controls-matrix.md) to +track which of those production controls are repo-owned, operator-owned, +provider-owned, or legal/review-owned for the deployment. +Use [Hardened reference deployment](reference/hardened-reference-deployment.md) +for the full self-hosted topology, backup pairing, ingress/TLS, monitoring, +and signing review. + +## Create Secrets ```sh kubectl create secret generic evydence-secrets \ @@ -12,14 +35,66 @@ kubectl create secret generic evydence-secrets \ --from-literal=EVYDENCE_API_KEY_PEPPER='replace-with-long-random-value' ``` -Install: +Store real values in your secret manager or sealed-secret process. Do not commit rendered secrets. + +## Install Or Upgrade ```sh helm upgrade --install evydence ./deploy/helm/evydence \ - --set image.repository=registry.example.com/evydence \ - --set image.tag=v0.1.0 \ + --set image.repository=ghcr.io/aatuh/evydence \ + --set image.tag='@sha256:' \ --set env.s3Endpoint=s3.example.com \ --set env.s3Bucket=evydence ``` -Production deployments should use external PostgreSQL, S3/MinIO-compatible object storage, TLS ingress, backup automation, and `EVYDENCE_SIGNING_KEY_MODE=external`. The chart does not create databases, buckets, KMS keys, or secrets. +Relevant chart values are defined in `deploy/helm/evydence/values.yaml`: + +| Value | Purpose | +|-------|---------| +| `image.repository`, `image.tag` | API and worker image. | +| `api.replicas` | API writer replicas. Keep `1` for the current production profile. | +| `api.writerMode` | Runtime writer mode passed as `EVYDENCE_API_WRITER_MODE`. Keep `single` for production. | +| `worker.replicas` | Worker replicas. May be scaled with PostgreSQL outbox locking. | +| `api.resources`, `worker.resources` | Resource requests and limits. | +| `podSecurityContext`, `containerSecurityContext` | Non-root and least-privilege pod/container defaults. | +| `worker.probes.*` | Worker exec probes using `evydence-worker healthcheck`. | +| `networkPolicy.enabled` | Optional starter NetworkPolicy for API ingress. | +| `existingSecret` | Secret containing runtime sensitive variables. | +| `env.databaseURLSecretKey` | Secret key for `EVYDENCE_DATABASE_URL`. | +| `env.apiKeyPepperSecretKey` | Secret key for `EVYDENCE_API_KEY_PEPPER`. | +| `env.objectStore` | `s3` or `minio` for cluster deployments. | +| `env.s3Endpoint`, `env.s3Bucket`, `env.s3Region`, `env.s3UseSSL` | Object-store configuration. | +| `env.signingKeyMode` | Production signing mode. Use `external`, `aws-kms`, `gcp-kms`, `azure-key-vault`, or gateway-backed `pkcs11-hsm`. | +| `ingress.*` | Optional ingress host and TLS secret. | + +## Verify + +```sh +kubectl rollout status deploy/evydence-api +kubectl rollout status deploy/evydence-worker +kubectl get pods -l app.kubernetes.io/name=evydence +kubectl port-forward svc/evydence 8080:8080 +curl -sS http://localhost:8080/v1/ready +``` + +Expected result: + +- API and worker deployments roll out. +- Pods stay ready. +- `/v1/ready` returns low-detail readiness JSON. +- Startup logs do not print bootstrap secrets when `ENV=production`. + +## Rollback + +Use Helm history and rollback commands: + +```sh +helm history evydence +helm rollback evydence +``` + +Rollback does not roll back PostgreSQL data or object-store payloads. Keep database migrations, object-store backups, and release artifact versions paired with the Helm revision used for deployment. + +## Production Notes + +Production deployments should use external PostgreSQL, S3/MinIO-compatible object storage, TLS ingress, backup automation, network access controls, and external signing. Current production guidance uses a single API writer replica; the chart passes `EVYDENCE_API_WRITER_MODE=single` and `EVYDENCE_API_WRITER_REPLICAS=1`, production API startup rejects unsupported writer modes or replica counts above one, then enforces the stance with a PostgreSQL advisory writer lease. Worker replicas can scale independently through PostgreSQL outbox row locking. See [HA strategy](reference/ha-strategy.md), [Hardened reference deployment](reference/hardened-reference-deployment.md), [Production hardening review](production-hardening.md), [Production readiness](reference/production-readiness.md), and [Configuration](reference/configuration.md). diff --git a/docs/openapi/index.html b/docs/openapi/index.html new file mode 100644 index 0000000..bb1407d --- /dev/null +++ b/docs/openapi/index.html @@ -0,0 +1,5228 @@ + + + + + + Evydence API - Rendered OpenAPI Docs + + + +
+

Evydence API

+

Self-hosted API evidence and compliance-readiness ledger.

+

This page is generated from openapi.yaml. It is a human-readable companion to the committed contract and does not replace route-contract tests.

+
+
3.1.0OpenAPI version
+
169registered paths
+
186operations
+
375schemas
+
+
+
+
+

Runtime secrets, tenant data, raw evidence payloads, private keys, bearer tokens, and customer package contents are not embedded in this generated page. Admin-scoped routes appear only when they are part of the committed public contract.

+

Error responses use RFC 9457-style Problem Details. The registered Problem schema currently requires: none.

+
+ +
+

Operations

+
+ + +
+
+ +
+
+ GET + /v1/admin/instance +
+

Instance admin snapshot

+

Returns instance-level diagnostic counts. Requires the explicit instance:admin scope; tenant admin and ordinary wildcard tenant keys are insufficient.

+
+
Operation ID
instanceAdminSnapshot
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
instance:admin
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: InstanceAdminSnapshotEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/api-keys +
+

List API keys

+

Lists tenant-scoped API key metadata without key hashes or one-time secrets.

+
+
Operation ID
listAPIKeys
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
admin
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: APIKeyListEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/api-keys +
+

Create API key

+

Creates a tenant-scoped API key and returns the secret exactly once. Stored records expose only non-secret key metadata.

+
+
Operation ID
createAPIKey
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
admin
+
Idempotency
Idempotency-Key
+
Request
application/json: CreateAPIKeyRequest
+
Responses
201 application/json: APIKeyCreateEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/api-security-scans +
+

Upload API security scan

+

Uploads SAST, DAST, secret, license, or API security scan metadata and raw JSON payload evidence without exposing raw payload bytes in responses.

+
+
Operation ID
uploadAPISecurityScan
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
security:write
+
Idempotency
Idempotency-Key
+
Request
application/json: UploadSecurityScanRequest
+
Responses
201 application/json: SecurityScanEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/approvals +
+

Create approval record

+

Creates an immutable approval record for a release, waiver, package, or review subject.

+
+
Operation ID
createApproval
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
release:write
+
Idempotency
Idempotency-Key
+
Request
application/json: CreateApprovalRequest
+
Responses
201 application/json: ApprovalRecordEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/artifact-signatures +
+

Create artifact signature

+

Records detached artifact signature evidence and optional raw signature payload metadata.

+
+
Operation ID
createArtifactSignature
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
evidence:write
+
Idempotency
Idempotency-Key
+
Request
application/json: CreateArtifactSignatureRequest
+
Responses
201 application/json: ArtifactSignatureEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/artifact-signatures/{id} +
+

Get artifact signature

+

Returns tenant-scoped artifact signature metadata by id.

+
+
Operation ID
getArtifactSignature
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
evidence:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: ArtifactSignatureEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/artifact-signatures/{id}/verify-cosign +
+

Verify cosign-style artifact signature

+

Records deterministic cosign-style verification metadata for an artifact signature without implying online transparency trust.

+
+
Operation ID
verifyCosignSignature
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
verify:read
+
Idempotency
Idempotency-Key
+
Request
application/json: VerifyCosignSignatureRequest
+
Responses
200 application/json: CosignVerificationEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/artifacts +
+

Register artifact

+

Registers an artifact digest for release evidence and later build/attestation matching.

+
+
Operation ID
registerArtifact
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
evidence:write
+
Idempotency
Idempotency-Key
+
Request
application/json: RegisterArtifactRequest
+
Responses
201 application/json: ArtifactEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/artifacts/{id} +
+

Get artifact

+

Returns a tenant-scoped artifact by id.

+
+
Operation ID
getArtifact
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
evidence:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: ArtifactEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/audit-chain/verify +
+

Verify audit chain

+

Verifies the tenant audit chain continuity and returns deterministic verification checks.

+
+
Operation ID
verifyAuditChain
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
verify:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: VerificationResultEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/audit-log +
+

List tenant audit log

+

Lists tenant-scoped append-only audit-chain entries in reverse chronological order.

+
+
Operation ID
listAuditLog
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
admin
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: AuditChainEntryListEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/backup-manifests +
+

Generate backup manifest

+

Generates a tenant-scoped backup manifest after an operator backup completes. The manifest excludes raw payload bytes and private key material.

+
+
Operation ID
generateBackupManifest
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
admin
+
Idempotency
Idempotency-Key
+
Request
application/json: EmptyObject
+
Responses
201 application/json: BackupManifestEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/backup-manifests/{id}/verify +
+

Verify backup manifest

+

Verifies a tenant-scoped backup manifest and returns deterministic manifest verification checks.

+
+
Operation ID
verifyBackupManifest
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
verify:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: VerificationResultEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/build-attestations/{id}/verify-signature +
+

Verify build attestation signature

+

Verifies a build attestation signature against configured tenant DSSE trust roots.

+
+
Operation ID
verifyBuildAttestationSignature
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
verify:read
+
Idempotency
Idempotency-Key
+
Request
application/json: EmptyObject
+
Responses
200 application/json: VerificationResultEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/builds +
+

Create build run

+

Records an immutable CI build run. Collector identity is derived from the authenticated key when present.

+
+
Operation ID
createBuild
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
build:write
+
Idempotency
Idempotency-Key
+
Request
application/json: CreateBuildRequest
+
Responses
201 application/json: BuildRunEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/builds/{id} +
+

Get build run

+

Returns a tenant-scoped build run by id.

+
+
Operation ID
getBuild
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
build:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: BuildRunEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/builds/{id}/attestations +
+

Upload build attestation

+

Uploads a DSSE/in-toto build attestation for a tenant-scoped build and stores raw bytes in object storage.

+
+
Operation ID
uploadBuildAttestation
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
build:write
+
Idempotency
Idempotency-Key
+
Request
application/json: DSSEEnvelope
+
Responses
201 application/json: BuildAttestationEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/collectors +
+

List collectors

+

Lists tenant-scoped collector metadata without API key hashes or one-time secrets.

+
+
Operation ID
listCollectors
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
collector:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: CollectorListEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/collectors +
+

Create collector

+

Creates a tenant-scoped collector identity, binds a scoped API key, and returns the collector key secret exactly once.

+
+
Operation ID
createCollector
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
collector:admin
+
Idempotency
Idempotency-Key
+
Request
application/json: CreateCollectorRequest
+
Responses
201 application/json: CollectorCreateEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/collectors/github/source-snapshots +
+

Upload GitHub source snapshot

+

Uploads a strict GitHub source snapshot, hashes commit messages, and stores repository, commit, branch, and pull-request evidence records.

+
+
Operation ID
uploadGitHubSourceSnapshot
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
source:write
+
Idempotency
Idempotency-Key
+
Request
application/json: SourceSnapshotRequest
+
Responses
201 application/json: SourceSnapshotEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/collectors/gitlab/source-snapshots +
+

Upload GitLab source snapshot

+

Uploads a strict GitLab source snapshot, hashes commit messages, and stores repository, commit, branch, and pull-request evidence records.

+
+
Operation ID
uploadGitLabSourceSnapshot
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
source:write
+
Idempotency
Idempotency-Key
+
Request
application/json: SourceSnapshotRequest
+
Responses
201 application/json: SourceSnapshotEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/collectors/{id}/health +
+

Collector health report

+

Returns collector supply-chain health from recorded tenant evidence, assumptions, and limitations.

+
+
Operation ID
collectorHealthReport
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
collector:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: CollectorHealthReportEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/collectors/{id}/releases +
+

Record collector release evidence

+

Records collector release supply-chain evidence for a tenant-scoped collector.

+
+
Operation ID
recordCollectorRelease
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
collector:admin
+
Idempotency
Idempotency-Key
+
Request
application/json: RecordCollectorReleaseRequest
+
Responses
201 application/json: CollectorReleaseEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/commercial-collectors +
+

List commercial collector definitions

+

Lists tenant-scoped commercial collector definitions.

+
+
Operation ID
listCommercialCollectors
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
collector:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: CommercialCollectorDefinitionListEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/commercial-collectors +
+

Create commercial collector definition

+

Creates tenant-scoped commercial collector metadata without installing external code.

+
+
Operation ID
createCommercialCollector
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
collector:admin
+
Idempotency
Idempotency-Key
+
Request
application/json: CreateCommercialCollectorRequest
+
Responses
201 application/json: CommercialCollectorDefinitionEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/container-images +
+

Register container image

+

Registers OCI/container image metadata and digest evidence linked to an optional artifact.

+
+
Operation ID
registerContainerImage
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
evidence:write
+
Idempotency
Idempotency-Key
+
Request
application/json: RegisterContainerImageRequest
+
Responses
201 application/json: ContainerImageEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/control-evidence +
+

List control evidence

+

Lists tenant-scoped control evidence links with optional control, product, and release filters.

+
+
Operation ID
listControlEvidence
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
controls:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: ControlEvidenceListEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/control-framework-template-packs +
+

List control framework template packs

+

Lists built-in control framework template packs available for explicit tenant installation.

+
+
Operation ID
listControlFrameworkTemplatePacks
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
controls:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: ControlFrameworkTemplatePackListEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/control-framework-template-packs/{slug}/install +
+

Install control framework template pack

+

Installs a named control framework template pack into the tenant as ordinary framework/control records.

+
+
Operation ID
installControlFrameworkTemplatePack
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
controls:admin
+
Idempotency
Idempotency-Key
+
Request
application/json: EmptyObject
+
Responses
201 application/json: ControlFrameworkEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/control-frameworks +
+

List control frameworks

+

Lists tenant-scoped control frameworks.

+
+
Operation ID
listControlFrameworks
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
controls:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: ControlFrameworkListEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/control-frameworks +
+

Create control framework

+

Creates a tenant-scoped versioned control framework.

+
+
Operation ID
createControlFramework
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
controls:admin
+
Idempotency
Idempotency-Key
+
Request
application/json: CreateControlFrameworkRequest
+
Responses
201 application/json: ControlFrameworkEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/controls +
+

Create security control

+

Creates a framework-owned security control with deterministic evidence requirements.

+
+
Operation ID
createSecurityControl
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
controls:admin
+
Idempotency
Idempotency-Key
+
Request
application/json: CreateSecurityControlRequest
+
Responses
201 application/json: SecurityControlEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/controls/{id} +
+

Get security control

+

Returns a tenant-scoped security control by id.

+
+
Operation ID
getSecurityControl
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
controls:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: SecurityControlEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/controls/{id}/evidence +
+

Link control evidence

+

Creates an append-only link between a security control and tenant-scoped evidence or related release resource.

+
+
Operation ID
linkControlEvidence
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
controls:write
+
Idempotency
Idempotency-Key
+
Request
application/json: LinkControlEvidenceRequest
+
Responses
201 application/json: ControlEvidenceEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/custom-policies +
+

Create custom policy

+

Creates a deterministic custom policy definition for tenant-managed release checks.

+
+
Operation ID
createCustomPolicy
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
policy:write
+
Idempotency
Idempotency-Key
+
Request
application/json: CreateCustomPolicyRequest
+
Responses
201 application/json: CustomPolicyEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/custom-policies/{id}/evaluate +
+

Evaluate custom policy

+

Evaluates a tenant custom policy against a release and records the input hash.

+
+
Operation ID
evaluateCustomPolicy
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
policy:read
+
Idempotency
Idempotency-Key
+
Request
application/json: EvaluatePolicyRequest
+
Responses
201 application/json: CustomPolicyEvaluationEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/customer-packages +
+

Create customer security package

+

Creates a scoped customer security package manifest using an explicit redaction profile.

+
+
Operation ID
createCustomerPackage
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
package:write
+
Idempotency
Idempotency-Key
+
Request
application/json: CreateCustomerPackageRequest
+
Responses
201 application/json: CustomerSecurityPackageEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/customer-packages/{id} +
+

Get customer security package

+

Returns a tenant-scoped customer security package manifest by id.

+
+
Operation ID
getCustomerPackage
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
package:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: CustomerSecurityPackageEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/customer-packages/{id}/download +
+

Download customer security package ZIP

+

Downloads a scoped customer security package ZIP. The archive contains redacted manifest metadata and verification guidance, not raw tenant evidence payload bytes.

+
+
Operation ID
downloadCustomerPackage
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
package:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/zip: string; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/customer-portal/access +
+

List customer portal access records

+

Lists tenant-scoped external reviewer access records without token hashes or token secrets.

+
+
Operation ID
listCustomerPortalAccess
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
package:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: CustomerPortalAccessListEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/customer-portal/access +
+

Create customer portal access

+

Creates a named, expiring external reviewer access record for a customer package and returns the portal token once.

+
+
Operation ID
createCustomerPortalAccess
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
package:write
+
Idempotency
Idempotency-Key
+
Request
application/json: CreateCustomerPortalAccessRequest
+
Responses
201 application/json: CustomerPortalAccessCreateEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/customer-portal/access/{id}/revoke +
+

Revoke customer portal access

+

Revokes a tenant-scoped external reviewer access record; revocation is append-only and the original token cannot be used afterwards.

+
+
Operation ID
revokeCustomerPortalAccess
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
package:write
+
Idempotency
Idempotency-Key
+
Request
application/json: EmptyObject
+
Responses
200 application/json: CustomerPortalAccessEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/customer-portal/package +
+

Access customer portal package

+

Public token exchange endpoint for a scoped customer package. It intentionally uses no bearer authentication and accepts only the issued portal token in the JSON body.

+
+
Operation ID
accessCustomerPortalPackage
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
none
+
Idempotency
Idempotency-Key
+
Request
application/json: CustomerPortalPackageRequest
+
Responses
200 application/json: CustomerSecurityPackageEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/customer-portal/package/download +
+

Download customer portal package ZIP

+

Public token exchange endpoint for downloading a scoped customer package ZIP. It intentionally uses no bearer authentication and accepts only the issued portal token in the JSON body.

+
+
Operation ID
downloadCustomerPortalPackage
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
none
+
Idempotency
Idempotency-Key
+
Request
application/json: CustomerPortalPackageRequest
+
Responses
200 application/zip: string; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/customer-portal/package/view +
+

Customer portal package review form

+

Public HTML form for reviewing or downloading a scoped customer package with a portal token. It does not accept tokens in URLs and does not use bearer authentication.

+
+
Operation ID
customerPortalPackageViewForm
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
none
+
Idempotency
not required
+
Request
none
+
Responses
200 text/html: string; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/customer-portal/package/view +
+

Customer portal package review page

+

Public HTML package review endpoint backed by the customer portal token exchange. Tokens are accepted only as form body fields.

+
+
Operation ID
customerPortalPackageView
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
none
+
Idempotency
Idempotency-Key
+
Request
application/x-www-form-urlencoded: CustomerPortalPackageRequest
+
Responses
200 text/html: string; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/customer-portal/package/view/download +
+

Download customer portal package ZIP from review form

+

Public HTML-form package ZIP download endpoint backed by the customer portal token exchange. Tokens are accepted only as form body fields.

+
+
Operation ID
downloadCustomerPortalPackageView
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
none
+
Idempotency
Idempotency-Key
+
Request
application/x-www-form-urlencoded: CustomerPortalPackageRequest
+
Responses
200 application/zip: string; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/deployments +
+

List deployments

+

Lists tenant-scoped deployment events by optional release and environment filters.

+
+
Operation ID
listDeployments
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
deployment:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: DeploymentEventListEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/deployments +
+

Record deployment

+

Records append-only deployment evidence for a release/environment/artifact set.

+
+
Operation ID
recordDeployment
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
deployment:write
+
Idempotency
Idempotency-Key
+
Request
application/json: RecordDeploymentRequest
+
Responses
201 application/json: DeploymentEventEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/deployments/{id} +
+

Get deployment

+

Returns a tenant-scoped deployment event by id.

+
+
Operation ID
getDeployment
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
deployment:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: DeploymentEventEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/dsse-trust-roots +
+

Create DSSE trust root

+

Creates a tenant-scoped DSSE trust root using public verification key material only.

+
+
Operation ID
createDSSETrustRoot
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
keys:admin
+
Idempotency
Idempotency-Key
+
Request
application/json: CreateDSSETrustRootRequest
+
Responses
201 application/json: DSSETrustRootEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/environments +
+

List deployment environments

+

Lists tenant-scoped deployment environments, optionally filtered by product.

+
+
Operation ID
listDeploymentEnvironments
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
deployment:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: DeploymentEnvironmentListEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/environments +
+

Create deployment environment

+

Creates a tenant-scoped deployment environment for release deployment evidence.

+
+
Operation ID
createDeploymentEnvironment
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
deployment:write
+
Idempotency
Idempotency-Key
+
Request
application/json: CreateDeploymentEnvironmentRequest
+
Responses
201 application/json: DeploymentEnvironmentEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/evidence +
+

List evidence

+

Lists tenant-scoped evidence by optional release and evidence type filters.

+
+
Operation ID
listEvidence
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
evidence:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: EvidenceItemListEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/evidence +
+

Create evidence

+

Creates immutable evidence metadata and optional raw payload evidence. Evidence core fields are append-only after creation.

+
+
Operation ID
createEvidence
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
evidence:write
+
Idempotency
Idempotency-Key
+
Request
application/json: CreateEvidenceRequest
+
Responses
201 application/json: EvidenceItemEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/evidence-bundles +
+

Export evidence bundle

+

Exports a portable evidence bundle manifest with hashes, signatures, and verification text.

+
+
Operation ID
exportEvidenceBundle
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
bundle:read
+
Idempotency
Idempotency-Key
+
Request
application/json: ExportEvidenceBundleRequest
+
Responses
201 application/json: EvidenceBundleEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/evidence-bundles/import +
+

Import evidence bundle

+

Imports a portable evidence bundle manifest and records deterministic import metadata.

+
+
Operation ID
importEvidenceBundle
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
bundle:write
+
Idempotency
Idempotency-Key
+
Request
application/json: EvidenceBundle
+
Responses
201 application/json: EvidenceBundleImportEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/evidence-graph-snapshots +
+

Create evidence graph snapshot

+

Creates a deterministic product/release evidence adjacency snapshot from stored tenant-scoped evidence records.

+
+
Operation ID
createGraphSnapshot
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
evidence:read
+
Idempotency
Idempotency-Key
+
Request
application/json: CreateGraphSnapshotRequest
+
Responses
201 application/json: EvidenceGraphSnapshotEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/evidence-summaries +
+

Create evidence-backed summary

+

Creates an evidence-backed summary with citations, assumptions, and limitations.

+
+
Operation ID
createEvidenceSummary
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
report:read
+
Idempotency
Idempotency-Key
+
Request
application/json: CreateEvidenceSummaryRequest
+
Responses
201 application/json: EvidenceSummaryEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/evidence/search +
+

Search evidence

+

Searches tenant-scoped evidence with deterministic filters and cursor-style pagination.

+
+
Operation ID
searchEvidence
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
evidence:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: EvidenceSearchEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/evidence/{id} +
+

Get evidence

+

Returns a tenant-scoped immutable evidence item by id.

+
+
Operation ID
getEvidence
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
evidence:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: EvidenceItemEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/evidence/{id}/lifecycle-events +
+

List evidence lifecycle events

+

Lists append-only lifecycle events for a tenant-scoped evidence item.

+
+
Operation ID
listEvidenceLifecycleEvents
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
evidence:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: EvidenceLifecycleEventListEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/evidence/{id}/lifecycle-events +
+

Record evidence lifecycle event

+

Appends an evidence lifecycle event such as amendment, redaction marker, tombstone, or retention marker.

+
+
Operation ID
recordEvidenceLifecycleEvent
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
evidence:write
+
Idempotency
Idempotency-Key
+
Request
application/json: RecordEvidenceLifecycleEventRequest
+
Responses
201 application/json: EvidenceLifecycleEventEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/evidence/{id}/link +
+

Link evidence

+

Creates an append-only relationship from evidence to another tenant-scoped subject.

+
+
Operation ID
linkEvidence
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
evidence:write
+
Idempotency
Idempotency-Key
+
Request
application/json: LinkEvidenceRequest
+
Responses
201 application/json: EvidenceItemEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/evidence/{id}/supersede +
+

Supersede evidence

+

Supersedes immutable evidence by linking it to replacement evidence and appending lifecycle metadata.

+
+
Operation ID
supersedeEvidence
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
evidence:write
+
Idempotency
Idempotency-Key
+
Request
application/json: SupersedeEvidenceRequest
+
Responses
201 application/json: EvidenceItemEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/exceptions +
+

List exceptions

+

Lists tenant-scoped exceptions, optionally filtered by release.

+
+
Operation ID
listExceptions
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
verify:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: ExceptionListEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/exceptions +
+

Create exception

+

Creates a scoped, expiring release/finding/control exception that is inactive until approved.

+
+
Operation ID
createException
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
release:write
+
Idempotency
Idempotency-Key
+
Request
application/json: CreateExceptionRequest
+
Responses
201 application/json: ExceptionEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/exceptions/{id}/approve +
+

Approve exception

+

Approves an unexpired exception as an audited append-only transition.

+
+
Operation ID
approveException
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
release:write
+
Idempotency
Idempotency-Key
+
Request
application/json: EmptyObject
+
Responses
200 application/json: ExceptionEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/health +
+

Health

+

Returns low-detail liveness status without touching tenant evidence or secret material.

+
+
Operation ID
health
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
none
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: HealthStatusEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/incident-webhooks/{receiver_id} +
+

Receive signed incident webhook event

+

Public signed webhook endpoint for incident timeline events. It verifies Ed25519 signature, event id replay, and timestamp before parsing payload fields.

+
+
Operation ID
receiveIncidentWebhook
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
none
+
Idempotency
not required
+
Request
application/json: SignedIncidentWebhookPayload
+
Responses
201 application/json: IncidentWebhookDeliveryEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/incidents +
+

Create incident

+

Creates an append-only incident record linked to tenant-scoped product and optional release evidence.

+
+
Operation ID
createIncident
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
incident:write
+
Idempotency
Idempotency-Key
+
Request
application/json: CreateIncidentRequest
+
Responses
201 application/json: IncidentEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/incidents/{id}/timeline +
+

Record incident timeline event

+

Appends an incident timeline event and optional evidence reference.

+
+
Operation ID
recordIncidentTimeline
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
incident:write
+
Idempotency
Idempotency-Key
+
Request
application/json: RecordIncidentTimelineRequest
+
Responses
201 application/json: IncidentTimelineEventEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/incidents/{id}/webhook-receivers +
+

Create signed incident webhook receiver

+

Creates an incident-scoped webhook receiver with an Ed25519 public key. The matching private key stays with the external incident tool.

+
+
Operation ID
createIncidentWebhookReceiver
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
incident:write
+
Idempotency
Idempotency-Key
+
Request
application/json: CreateIncidentWebhookReceiverRequest
+
Responses
201 application/json: IncidentWebhookReceiverEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/legal-holds +
+

Create legal hold

+

Creates an append-only legal-hold marker for a tenant-scoped retention subject.

+
+
Operation ID
createLegalHold
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
admin
+
Idempotency
Idempotency-Key
+
Request
application/json: CreateLegalHoldRequest
+
Responses
201 application/json: LegalHoldEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/marketplace-collectors +
+

List marketplace collector records

+

Lists tenant-scoped marketplace collector package metadata.

+
+
Operation ID
listMarketplaceCollectors
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
collector:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: MarketplaceCollectorListEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/marketplace-collectors +
+

Create marketplace collector record

+

Creates tenant-scoped marketplace collector package metadata and evidence references.

+
+
Operation ID
createMarketplaceCollector
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
collector:admin
+
Idempotency
Idempotency-Key
+
Request
application/json: CreateMarketplaceCollectorRequest
+
Responses
201 application/json: MarketplaceCollectorEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/marketplace-collectors/{id}/health +
+

Marketplace collector health report

+

Returns marketplace collector package health from recorded signature, SBOM, and scan evidence.

+
+
Operation ID
marketplaceCollectorHealth
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
collector:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: MarketplaceCollectorHealthReportEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/merkle-batches +
+

Create Merkle checkpoint batch

+

Creates a Merkle batch over tenant audit-chain entries for checkpoint export or transparency anchoring.

+
+
Operation ID
createMerkleBatch
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
keys:admin
+
Idempotency
Idempotency-Key
+
Request
application/json: CreateMerkleBatchRequest
+
Responses
201 application/json: MerkleBatchEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/merkle-batches/{id}/verify +
+

Verify Merkle checkpoint batch

+

Verifies a tenant-scoped Merkle batch root and leaf set against stored audit-chain entries.

+
+
Operation ID
verifyMerkleBatch
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
verify:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: VerificationResultEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/metrics +
+

Safe tenant metrics

+

Returns safe tenant-scoped resource metrics for admin actors. A Prometheus text response is also available when requested with Accept: text/plain.

+
+
Operation ID
metrics
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
admin
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: MetricsSnapshotEnvelope, text/plain: string; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/object-retention-policies +
+

Create object retention policy record

+

Creates an object retention policy record for storage immutability verification.

+
+
Operation ID
createObjectRetentionPolicy
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
admin
+
Idempotency
Idempotency-Key
+
Request
application/json: CreateObjectRetentionPolicyRequest
+
Responses
201 application/json: ObjectRetentionPolicyEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/object-retention-policies/{id}/verify +
+

Verify object retention policy record

+

Records verification metadata for a tenant object retention policy.

+
+
Operation ID
verifyObjectRetentionPolicy
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
verify:read
+
Idempotency
Idempotency-Key
+
Request
application/json: EmptyObject
+
Responses
200 application/json: ObjectRetentionPolicyEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/openapi-contracts +
+

Upload OpenAPI contract

+

Uploads an OpenAPI 3.1 contract, stores raw bytes as evidence, and records normalized operation metadata.

+
+
Operation ID
uploadOpenAPIContract
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
evidence:write
+
Idempotency
Idempotency-Key
+
Request
application/json: UploadOpenAPIContractRequest
+
Responses
201 application/json: OpenAPIContractEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/openapi-contracts/{id} +
+

Get OpenAPI contract

+

Returns a tenant-scoped OpenAPI contract metadata record by id.

+
+
Operation ID
getOpenAPIContract
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
evidence:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: OpenAPIContractEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/openapi-diffs +
+

Create OpenAPI contract diff

+

Creates a deterministic OpenAPI contract diff for release contract checks.

+
+
Operation ID
createOpenAPIDiff
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
evidence:read
+
Idempotency
Idempotency-Key
+
Request
application/json: CreateOpenAPIDiffRequest
+
Responses
201 application/json: ContractDiffEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/openapi.json +
+

OpenAPI

+

Returns the generated OpenAPI 3.1 document served by this process.

+
+
Operation ID
openapi
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
none
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: OpenAPIDocument; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/organizations +
+

Create organization

+

Creates a tenant-scoped organization record for human identity grouping.

+
+
Operation ID
createOrganization
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
identity:admin
+
Idempotency
Idempotency-Key
+
Request
application/json: CreateOrganizationRequest
+
Responses
201 application/json: OrganizationEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/policies/evaluate +
+

Evaluate release policy

+

Evaluates built-in deterministic release policy checks for a release.

+
+
Operation ID
evaluatePolicy
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
verify:read
+
Idempotency
Idempotency-Key
+
Request
application/json: EvaluatePolicyRequest
+
Responses
201 application/json: PolicyEvaluationEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/products +
+

List products

+

Lists tenant-scoped products visible to the authenticated actor.

+
+
Operation ID
listProducts
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
product:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: ProductListEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/products +
+

Create product

+

Creates a tenant-scoped product. Product slugs must be unique per tenant.

+
+
Operation ID
createProduct
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
product:write
+
Idempotency
Idempotency-Key
+
Request
application/json: CreateProductRequest
+
Responses
201 application/json: ProductEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/products/{id} +
+

Get product

+

Returns a tenant-scoped product by id.

+
+
Operation ID
getProduct
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
product:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: ProductEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/projects +
+

Create project

+

Creates a tenant-scoped project under a product.

+
+
Operation ID
createProject
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
project:write
+
Idempotency
Idempotency-Key
+
Request
application/json: CreateProjectRequest
+
Responses
201 application/json: ProjectEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/projects/{id} +
+

Get project

+

Returns a tenant-scoped project by id.

+
+
Operation ID
getProject
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
project:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: ProjectEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/provider-verifications +
+

Verify stored provider identity metadata

+

Verifies stored provider identity metadata and, when supplied, locally verifies OIDC ID-token or SAML assertion issuer, audience, subject, time bounds, and signature against configured tenant trust material.

+
+
Operation ID
verifyProviderIdentity
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
identity:admin
+
Idempotency
Idempotency-Key
+
Request
application/json: VerifyProviderIdentityRequest
+
Responses
201 application/json: ProviderVerificationEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/public-transparency-log-entries +
+

Publish public transparency log entry record

+

Records publication metadata for a checkpoint submitted to a configured public transparency log.

+
+
Operation ID
publishPublicTransparencyLogEntry
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
keys:admin
+
Idempotency
Idempotency-Key
+
Request
application/json: PublishPublicTransparencyLogEntryRequest
+
Responses
201 application/json: PublicTransparencyLogEntryEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/public-transparency-log-entries/{id}/fetch-proof +
+

Fetch and verify public transparency log inclusion proof

+

Fetches public transparency inclusion proof material from the configured log endpoint or transparency proof gateway and verifies it locally. Endpoint trust and provider semantics remain deployment responsibilities.

+
+
Operation ID
fetchPublicTransparencyLogEntryProof
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
keys:admin
+
Idempotency
Idempotency-Key
+
Request
application/json: EmptyObject
+
Responses
200 application/json: PublicTransparencyLogEntryEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/public-transparency-log-entries/{id}/verify +
+

Verify public transparency log inclusion proof

+

Verifies operator-supplied RFC6962-style public transparency inclusion proof material for a published entry.

+
+
Operation ID
verifyPublicTransparencyLogEntry
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
keys:admin
+
Idempotency
Idempotency-Key
+
Request
application/json: VerifyPublicTransparencyLogEntryRequest
+
Responses
200 application/json: PublicTransparencyLogEntryEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/public-transparency-logs +
+

Create public transparency log record

+

Creates tenant metadata for an optional public transparency log trust root.

+
+
Operation ID
createPublicTransparencyLog
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
keys:admin
+
Idempotency
Idempotency-Key
+
Request
application/json: CreatePublicTransparencyLogRequest
+
Responses
201 application/json: PublicTransparencyLogEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/questionnaire-answer-library +
+

List questionnaire answer library entries

+

Lists tenant-scoped questionnaire answer library entries with optional question, product, and release filters.

+
+
Operation ID
listQuestionnaireAnswerLibrary
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
package:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: QuestionnaireAnswerLibraryEntryListEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/questionnaire-answer-library +
+

Create questionnaire answer library entry

+

Creates a tenant-scoped reusable questionnaire answer draft linked to optional evidence, product, release, or control scope.

+
+
Operation ID
createQuestionnaireAnswerLibraryEntry
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
package:write
+
Idempotency
Idempotency-Key
+
Request
application/json: CreateQuestionnaireAnswerLibraryEntryRequest
+
Responses
201 application/json: QuestionnaireAnswerLibraryEntryEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/questionnaire-drafts +
+

Create evidence-backed questionnaire draft

+

Creates an evidence-backed questionnaire draft with limitations.

+
+
Operation ID
createQuestionnaireDraft
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
package:read
+
Idempotency
Idempotency-Key
+
Request
application/json: CreateQuestionnaireDraftRequest
+
Responses
201 application/json: QuestionnaireDraftEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/questionnaire-packages +
+

Create questionnaire package

+

Creates a questionnaire response package from a template and scoped evidence package.

+
+
Operation ID
createQuestionnairePackage
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
package:write
+
Idempotency
Idempotency-Key
+
Request
application/json: CreateQuestionnairePackageRequest
+
Responses
201 application/json: QuestionnairePackageEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/questionnaire-templates +
+

Create questionnaire template

+

Creates a tenant questionnaire template with explicit evidence/control mapping fields.

+
+
Operation ID
createQuestionnaireTemplate
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
package:write
+
Idempotency
Idempotency-Key
+
Request
application/json: CreateQuestionnaireTemplateRequest
+
Responses
201 application/json: QuestionnaireTemplateEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/ready +
+

Readiness

+

Returns low-detail process readiness without tenant evidence or secret material.

+
+
Operation ID
ready
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
none
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: ReadinessStatusEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/redaction-profiles +
+

Create redaction profile

+

Creates an explicit redaction profile for customer and report package generation.

+
+
Operation ID
createRedactionProfile
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
package:write
+
Idempotency
Idempotency-Key
+
Request
application/json: CreateRedactionProfileRequest
+
Responses
201 application/json: RedactionProfileEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/release-bundles +
+

Create release bundle

+

Creates an immutable signed release bundle for a release.

+
+
Operation ID
createReleaseBundle
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
bundle:write
+
Idempotency
Idempotency-Key
+
Request
application/json: CreateReleaseBundleRequest
+
Responses
201 application/json: ReleaseBundleEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/release-bundles/{id} +
+

Get release bundle

+

Returns a tenant-scoped immutable release bundle by id.

+
+
Operation ID
getReleaseBundle
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
bundle:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: ReleaseBundleEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/release-bundles/{id}/manifest +
+

Get release bundle manifest

+

Returns the deterministic release bundle manifest by bundle id.

+
+
Operation ID
getReleaseBundleManifest
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
bundle:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: ReleaseBundleManifestEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/release-bundles/{id}/verify +
+

Verify release bundle

+

Verifies a tenant-scoped release bundle and returns a deterministic verification result.

+
+
Operation ID
verifyReleaseBundle
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
verify:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: VerificationResultEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/release-candidates +
+

List release candidates

+

Lists tenant-scoped release candidates, optionally filtered by release.

+
+
Operation ID
listReleaseCandidates
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
release:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: ReleaseCandidateListEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/release-candidates +
+

Create release candidate

+

Creates an immutable release-candidate snapshot of selected release evidence references.

+
+
Operation ID
createReleaseCandidate
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
release:write
+
Idempotency
Idempotency-Key
+
Request
application/json: CreateReleaseCandidateRequest
+
Responses
201 application/json: ReleaseCandidateEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/release-candidates/{id} +
+

Get release candidate

+

Returns a tenant-scoped release candidate by id.

+
+
Operation ID
getReleaseCandidate
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
release:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: ReleaseCandidateEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/release-candidates/{id}/promote +
+

Promote release candidate

+

Records a release-candidate lifecycle transition without mutating the original snapshot.

+
+
Operation ID
promoteReleaseCandidate
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
release:write
+
Idempotency
Idempotency-Key
+
Request
application/json: ReleaseCandidateTransitionRequest
+
Responses
200 application/json: ReleaseCandidateEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/release-candidates/{id}/reject +
+

Reject release candidate

+

Records a release-candidate lifecycle transition without mutating the original snapshot.

+
+
Operation ID
rejectReleaseCandidate
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
release:write
+
Idempotency
Idempotency-Key
+
Request
application/json: ReleaseCandidateTransitionRequest
+
Responses
200 application/json: ReleaseCandidateEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/releases +
+

Create release

+

Creates an append-only release record under a product and optional project.

+
+
Operation ID
createRelease
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
release:write
+
Idempotency
Idempotency-Key
+
Request
application/json: CreateReleaseRequest
+
Responses
201 application/json: ReleaseEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/releases/{id} +
+

Get release

+

Returns a tenant-scoped release by id.

+
+
Operation ID
getRelease
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
release:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: ReleaseEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/releases/{id}/approve +
+

Approve release

+

Approves a release as an append-only transition.

+
+
Operation ID
approveRelease
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
release:write
+
Idempotency
Idempotency-Key
+
Request
application/json: EmptyObject
+
Responses
200 application/json: ReleaseEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/releases/{id}/evidence-flow/start +
+

Start release evidence flow

+

Returns a high-level release evidence workflow plan, current evidence counts, required scopes, assumptions, and limitations. This read-only convenience operation does not create evidence.

+
+
Operation ID
startReleaseEvidenceFlow
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
release:read
+
Idempotency
Idempotency-Key
+
Request
none
+
Responses
200 application/json: ReleaseEvidenceFlowEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/releases/{id}/freeze +
+

Freeze release

+

Freezes a release as an append-only transition.

+
+
Operation ID
freezeRelease
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
release:write
+
Idempotency
Idempotency-Key
+
Request
application/json: EmptyObject
+
Responses
200 application/json: ReleaseEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/releases/{id}/security-summary +
+

Release security summary

+

Returns a tenant-scoped release security summary for review surfaces without raw evidence payload bytes.

+
+
Operation ID
releaseSecuritySummary
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
report:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: ReleaseSecuritySummaryEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/remediation-tasks +
+

Create remediation task

+

Creates an incident or release remediation task linked to optional evidence.

+
+
Operation ID
createRemediationTask
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
incident:write
+
Idempotency
Idempotency-Key
+
Request
application/json: CreateRemediationTaskRequest
+
Responses
201 application/json: RemediationTaskEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/report-templates +
+

Create report template

+

Creates a tenant-defined deterministic report template with an explicit allowed-field list.

+
+
Operation ID
createReportTemplate
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
report:read
+
Idempotency
Idempotency-Key
+
Request
application/json: CreateReportTemplateRequest
+
Responses
201 application/json: CustomReportTemplateEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/report-templates/{id}/render +
+

Render report template

+

Renders a tenant report template for a scoped subject using allowed fields only.

+
+
Operation ID
renderReportTemplate
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
report:read
+
Idempotency
Idempotency-Key
+
Request
application/json: RenderReportTemplateRequest
+
Responses
201 application/json: RenderedCustomReportEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/reports/anomaly +
+

Generate deterministic anomaly report

+

Creates a deterministic anomaly report over existing tenant evidence and metrics with assumptions and limitations.

+
+
Operation ID
generateAnomalyReport
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
report:read
+
Idempotency
Idempotency-Key
+
Request
application/json: CreateAnomalyReportRequest
+
Responses
201 application/json: AnomalyReportEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/reports/control-coverage +
+

Control coverage report

+

Returns deterministic control coverage with linked evidence, missing evidence, assumptions, and limitations.

+
+
Operation ID
controlCoverageReport
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
report:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: ReadinessReportEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/reports/cra-readiness +
+

CRA readiness report

+

Returns a CRA-oriented readiness report without legal compliance or certification conclusions.

+
+
Operation ID
craReadinessReport
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
report:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: ReadinessReportEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/reports/cra-readiness-html +
+

CRA readiness HTML package

+

Creates a deterministic CRA-readiness HTML package without legal compliance or certification conclusions.

+
+
Operation ID
craReadinessHTMLPackage
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
report:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: HTMLReportPackageEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/reports/cra-vulnerability-handling +
+

CRA vulnerability handling report

+

Returns a CRA-oriented vulnerability handling evidence report without legal compliance, certification, scanner-authority, or release-security conclusions.

+
+
Operation ID
craVulnerabilityHandlingReport
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
report:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: CRAVulnerabilityHandlingReportEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/reports/custody-review +
+

Signing custody review report

+

Returns tenant signing-provider and object-lock verification metadata for deployment custody review. It is evidence metadata only, not legal compliance proof, certification, HSM custody proof, or a secure-deployment guarantee.

+
+
Operation ID
signingCustodyReviewReport
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
keys:admin
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: SigningCustodyReviewReportEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/reports/incident-package +
+

Incident package report

+

Returns a deterministic incident package report with timeline, remediation tasks, linked evidence, assumptions, and limitations.

+
+
Operation ID
incidentReport
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
incident:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: IncidentReportEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/reports/missing-evidence +
+

Missing evidence report

+

Returns a deterministic missing-evidence report for a release with assumptions and limitations.

+
+
Operation ID
missingEvidenceReport
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
verify:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: MissingEvidenceReportEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/reports/pdf +
+

Create reproducible PDF report package

+

Creates a deterministic PDF report package record and payload metadata.

+
+
Operation ID
createPDFReportPackage
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
report:read
+
Idempotency
Idempotency-Key
+
Request
application/json: CreatePDFReportPackageRequest
+
Responses
201 application/json: PDFReportPackageEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/reports/release-readiness +
+

Release readiness report

+

Returns a deterministic release-readiness report with gaps, assumptions, and limitations.

+
+
Operation ID
releaseReadinessReport
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
verify:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: ReadinessReportEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/reports/retention +
+

Retention report

+

Returns a retention report for tenant-scoped holds and overrides with storage verification limitations.

+
+
Operation ID
retentionReport
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
admin
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: RetentionReportEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/reports/security-review-package +
+

Security review package report

+

Returns a redaction-aware security-review package report with assumptions and limitations.

+
+
Operation ID
securityReviewPackageReport
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
package:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: SecurityReviewPackageReportEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/reports/security-update-evidence +
+

Security update evidence report

+

Returns a security update evidence report for release-scoped fixed decisions, incidents, remediation tasks, and linked evidence without legal or security conclusions.

+
+
Operation ID
securityUpdateEvidenceReport
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
report:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: SecurityUpdateEvidenceReportEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/reports/vulnerability-decision-summary +
+

Customer-safe vulnerability decision summary

+

Returns customer-safe active vulnerability decision summaries for a release with assumptions and limitations. Raw payloads and internal notes are excluded.

+
+
Operation ID
vulnerabilityDecisionSummaryReport
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
report:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: VulnerabilityDecisionSummaryReportEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/reports/vulnerability-posture +
+

Vulnerability posture report

+

Returns a vulnerability posture report derived from stored scan, decision, VEX, exception, and workflow records.

+
+
Operation ID
vulnerabilityPostureReport
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
security:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: VulnerabilityPostureReportEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/retention-overrides +
+

Create retention override

+

Creates an append-only retention override for a tenant-scoped retention subject.

+
+
Operation ID
createRetentionOverride
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
admin
+
Idempotency
Idempotency-Key
+
Request
application/json: CreateRetentionOverrideRequest
+
Responses
201 application/json: RetentionOverrideEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/role-bindings +
+

List role bindings

+

Lists tenant-scoped role bindings visible to the identity administrator.

+
+
Operation ID
listRoleBindings
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
identity:admin
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: RoleBindingListEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/role-bindings +
+

Create role binding

+

Creates a tenant-scoped role binding for a user or collector subject.

+
+
Operation ID
createRoleBinding
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
identity:admin
+
Idempotency
Idempotency-Key
+
Request
application/json: CreateRoleBindingRequest
+
Responses
201 application/json: RoleBindingEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/saas/profiles +
+

Create SaaS edition profile

+

Creates a SaaS edition profile record for future hosted deployment planning; it is not a production readiness claim.

+
+
Operation ID
createSaaSEditionProfile
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
instance:admin
+
Idempotency
Idempotency-Key
+
Request
application/json: CreateSaaSEditionProfileRequest
+
Responses
201 application/json: SaaSEditionProfileEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/sbom-components +
+

List SBOM components

+

Lists tenant-scoped SBOM components by SBOM, release, artifact, name/version/PURL query, or exact PURL.

+
+
Operation ID
listSBOMComponents
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
evidence:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: SBOMComponentRecordListEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/sbom-diffs +
+

Create SBOM diff

+

Creates a deterministic SBOM diff between two tenant-scoped SBOM records.

+
+
Operation ID
createSBOMDiff
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
evidence:read
+
Idempotency
Idempotency-Key
+
Request
application/json: CreateSBOMDiffRequest
+
Responses
201 application/json: SBOMDiffEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/sboms +
+

Upload CycloneDX SBOM

+

Uploads a CycloneDX SBOM payload, stores raw bytes in object storage, and records normalized SBOM metadata.

+
+
Operation ID
uploadSBOM
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
evidence:write
+
Idempotency
Idempotency-Key
+
Request
application/json: EvidenceUploadRequest
+
Responses
201 application/json: SBOMEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/sboms/spdx +
+

Upload SPDX SBOM

+

Uploads an SPDX JSON SBOM payload, stores raw bytes as evidence, and records normalized SBOM metadata.

+
+
Operation ID
uploadSPDXSBOM
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
evidence:write
+
Idempotency
Idempotency-Key
+
Request
application/json: UploadSPDXSBOMRequest
+
Responses
201 application/json: SBOMEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/sboms/{id} +
+

Get SBOM

+

Returns a tenant-scoped SBOM metadata record by id.

+
+
Operation ID
getSBOM
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
evidence:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: SBOMEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/security-documents +
+

Upload manual security document

+

Uploads sensitive manual security evidence such as threat model, security review, or penetration-test report metadata and raw payload reference.

+
+
Operation ID
uploadManualSecurityDocument
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
security:write
+
Idempotency
Idempotency-Key
+
Request
application/json: UploadManualSecurityDocumentRequest
+
Responses
201 application/json: ManualSecurityDocumentEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/security-scans +
+

Upload security scan

+

Uploads SAST, DAST, secret, license, or API security scan metadata and raw JSON payload evidence without exposing raw payload bytes in responses.

+
+
Operation ID
uploadSecurityScan
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
security:write
+
Idempotency
Idempotency-Key
+
Request
application/json: UploadSecurityScanRequest
+
Responses
201 application/json: SecurityScanEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/signing-keys +
+

List signing keys

+

Lists tenant signing public-key metadata without private key material.

+
+
Operation ID
listSigningKeys
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
verify:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: SigningKeyListEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/signing-keys/rotate +
+

Rotate signing key

+

Rotates the active tenant signing key and returns public-key metadata only.

+
+
Operation ID
rotateSigningKey
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
keys:admin
+
Idempotency
Idempotency-Key
+
Request
application/json: SigningKeyTransitionRequest
+
Responses
201 application/json: SigningKeyEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/signing-keys/{id}/revoke +
+

Revoke signing key

+

Revokes a tenant signing key as an audited lifecycle transition.

+
+
Operation ID
revokeSigningKey
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
keys:admin
+
Idempotency
Idempotency-Key
+
Request
application/json: SigningKeyTransitionRequest
+
Responses
200 application/json: SigningKeyEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/signing-operations +
+

Create signing provider operation receipt

+

Records an external signing operation receipt and checks payload/signature metadata without logging secrets. When the API is configured with a signing executor, external_signature may be omitted and the executor signs the payload hash.

+
+
Operation ID
createSigningOperation
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
keys:admin
+
Idempotency
Idempotency-Key
+
Request
application/json: CreateSigningOperationRequest
+
Responses
201 application/json: SigningOperationEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/signing-providers +
+

Create signing provider record

+

Creates signing provider metadata for external signing operations. Production private key material must not be supplied.

+
+
Operation ID
createSigningProvider
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
keys:admin
+
Idempotency
Idempotency-Key
+
Request
application/json: CreateSigningProviderRequest
+
Responses
201 application/json: SigningProviderEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/source/branches +
+

Record source branch

+

Records or updates source branch metadata and protected-branch snapshot hash.

+
+
Operation ID
upsertSourceBranch
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
source:write
+
Idempotency
Idempotency-Key
+
Request
application/json: UpsertSourceBranchRequest
+
Responses
201 application/json: SourceBranchEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/source/commits +
+

Record source commit

+

Records immutable source commit metadata and stores only a hash of the commit message.

+
+
Operation ID
recordSourceCommit
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
source:write
+
Idempotency
Idempotency-Key
+
Request
application/json: RecordSourceCommitRequest
+
Responses
201 application/json: SourceCommitEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/source/pull-requests +
+

Record pull request

+

Records pull-request review metadata linked to source repository evidence.

+
+
Operation ID
recordPullRequest
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
source:write
+
Idempotency
Idempotency-Key
+
Request
application/json: RecordPullRequestRequest
+
Responses
201 application/json: PullRequestEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/source/repositories +
+

List source repositories

+

Lists tenant-scoped source repositories, optionally filtered by project.

+
+
Operation ID
listSourceRepositories
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
source:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: SourceRepositoryListEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/source/repositories +
+

Create source repository

+

Creates a tenant-scoped source repository record.

+
+
Operation ID
createSourceRepository
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
source:write
+
Idempotency
Idempotency-Key
+
Request
application/json: CreateSourceRepositoryRequest
+
Responses
201 application/json: SourceRepositoryEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/sso/identity-links +
+

Link SSO identity

+

Links a verified provider subject to a tenant-scoped human user.

+
+
Operation ID
linkSSOIdentity
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
identity:admin
+
Idempotency
Idempotency-Key
+
Request
application/json: LinkSSOIdentityRequest
+
Responses
201 application/json: UserIdentityLinkEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/sso/logout +
+

Logout SSO session

+

Revokes the currently authenticated SSO session without requiring identity administrator privileges. API keys and collector keys cannot use this route.

+
+
Operation ID
logoutSSOSession
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
none
+
Idempotency
Idempotency-Key
+
Request
application/json: EmptyObject
+
Responses
200 application/json: SSOSessionEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/sso/providers +
+

Create SSO provider

+

Records tenant SSO provider metadata. Optional static JWKS public keys and SAML signing certificates can be supplied for local token/assertion verification without live provider calls.

+
+
Operation ID
createSSOProvider
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
identity:admin
+
Idempotency
Idempotency-Key
+
Request
application/json: CreateSSOProviderRequest
+
Responses
201 application/json: SSOProviderEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/sso/providers/{id}/discover-oidc +
+

Refresh SSO provider OIDC trust material

+

Fetches the OIDC discovery document and public JWKS for the tenant provider issuer, then stores refreshed public trust material. This does not authenticate users, store provider secrets, or synchronize groups.

+
+
Operation ID
refreshSSOProviderOIDCTrustMaterial
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
identity:admin
+
Idempotency
Idempotency-Key
+
Request
application/json: EmptyObject
+
Responses
200 application/json: SSOProviderEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/sso/providers/{id}/trust-material +
+

Update SSO provider trust material

+

Rotates tenant SSO provider public trust material for local OIDC ID-token or SAML assertion verification without storing provider secrets.

+
+
Operation ID
updateSSOProviderTrustMaterial
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
identity:admin
+
Idempotency
Idempotency-Key
+
Request
application/json: UpdateSSOProviderTrustMaterialRequest
+
Responses
200 application/json: SSOProviderEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/sso/session-exchanges +
+

Exchange SSO credential

+

Exchanges a locally verified OIDC ID token or SAML assertion for an SSO session and HttpOnly browser cookie using configured tenant trust material and verified identity links. No live provider API or group synchronization call is made.

+
+
Operation ID
exchangeSSOCredential
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
none
+
Idempotency
not required
+
Request
application/json: ExchangeSSOCredentialRequest
+
Responses
201 application/json: SSOCredentialExchangeEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/sso/sessions +
+

Create SSO session

+

Creates an admin-managed human SSO session record and returns a one-time bearer secret.

+
+
Operation ID
createSSOSession
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
identity:admin
+
Idempotency
Idempotency-Key
+
Request
application/json: CreateSSOSessionRequest
+
Responses
201 application/json: SSOSessionCreateEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/sso/sessions/{id}/revoke +
+

Revoke SSO session

+

Revokes a tenant-scoped SSO session as an audited lifecycle transition.

+
+
Operation ID
revokeSSOSession
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
identity:admin
+
Idempotency
Idempotency-Key
+
Request
application/json: EmptyObject
+
Responses
200 application/json: SSOSessionEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/transparency-checkpoints +
+

Record external transparency checkpoint

+

Records an external transparency or timestamp checkpoint reference for a Merkle batch.

+
+
Operation ID
createTransparencyCheckpoint
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
keys:admin
+
Idempotency
Idempotency-Key
+
Request
application/json: CreateTransparencyCheckpointRequest
+
Responses
201 application/json: TransparencyCheckpointEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/users +
+

Create user

+

Creates a tenant-scoped human user metadata record. Authentication is still controlled by API keys or configured SSO/session flows.

+
+
Operation ID
createUser
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
identity:admin
+
Idempotency
Idempotency-Key
+
Request
application/json: CreateUserRequest
+
Responses
201 application/json: HumanUserEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/users/{id}/deactivate +
+

Deactivate user

+

Deactivates a tenant-scoped human user as an audited lifecycle transition.

+
+
Operation ID
deactivateUser
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
identity:admin
+
Idempotency
Idempotency-Key
+
Request
application/json: EmptyObject
+
Responses
200 application/json: HumanUserEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/verify +
+

Verify subject

+

Verifies a supported tenant-scoped subject such as evidence, audit chain, release bundle, artifact signature, or related verification target.

+
+
Operation ID
verify
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
verify:read
+
Idempotency
Idempotency-Key
+
Request
application/json: VerifySubjectRequest
+
Responses
200 application/json: VerificationResultEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/version +
+

Version

+

Returns the API process version string.

+
+
Operation ID
version
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
none
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: VersionInfoEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/vex +
+

Upload OpenVEX document

+

Uploads VEX payload bytes, stores raw evidence in object storage, and records normalized VEX metadata and decisions where applicable.

+
+
Operation ID
uploadVEX
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
evidence:write
+
Idempotency
Idempotency-Key
+
Request
application/json: EvidenceUploadRequest
+
Responses
201 application/json: VEXDocumentEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/vex/cyclonedx +
+

Upload CycloneDX VEX document

+

Uploads VEX payload bytes, stores raw evidence in object storage, and records normalized VEX metadata and decisions where applicable.

+
+
Operation ID
uploadCycloneDXVEX
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
evidence:write
+
Idempotency
Idempotency-Key
+
Request
application/json: EvidenceUploadRequest
+
Responses
201 application/json: VEXDocumentEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/vex/cyclonedx/preview +
+

Preview CycloneDX VEX import

+

Validates a CycloneDX VEX payload and returns advisory mapping counts without storing raw payloads, creating evidence, creating decisions, or enqueueing parser jobs.

+
+
Operation ID
previewCycloneDXVEXImport
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
evidence:read
+
Idempotency
not required
+
Request
application/json: EvidenceUploadRequest
+
Responses
200 application/json: VEXImportPreviewEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/vex/preview +
+

Preview OpenVEX import

+

Validates an OpenVEX payload and returns advisory mapping counts without storing raw payloads, creating evidence, creating decisions, or enqueueing parser jobs.

+
+
Operation ID
previewVEXImport
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
evidence:read
+
Idempotency
not required
+
Request
application/json: EvidenceUploadRequest
+
Responses
200 application/json: VEXImportPreviewEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/vex/{id} +
+

Get VEX document

+

Returns a tenant-scoped VEX document metadata record by id.

+
+
Operation ID
getVEX
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
evidence:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: VEXDocumentEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/vex/{id}/import-report +
+

Get VEX import report

+

Returns the persisted parser report for a tenant-scoped VEX import, including counts, warnings, and mapping failures without raw payload bytes.

+
+
Operation ID
getVEXImportReport
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
evidence:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: VEXImportReportEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/vulnerability-decisions +
+

List vulnerability decisions

+

Lists append-only vulnerability decisions over time with tenant-scoped product, release, vulnerability, component, status, and active filters.

+
+
Operation ID
listVulnerabilityDecisions
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
evidence:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: VulnerabilityDecisionListEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/vulnerability-findings/{id}/decisions +
+

Create vulnerability decision

+

Creates an append-only vulnerability decision for a tenant-scoped scan finding.

+
+
Operation ID
createVulnerabilityDecision
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
evidence:write
+
Idempotency
Idempotency-Key
+
Request
application/json: CreateVulnerabilityDecisionRequest
+
Responses
201 application/json: VulnerabilityDecisionEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/vulnerability-findings/{id}/workflow +
+

Record vulnerability workflow event

+

Records an append-only vulnerability workflow event for a tenant-scoped finding.

+
+
Operation ID
recordVulnerabilityWorkflow
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
security:write
+
Idempotency
Idempotency-Key
+
Request
application/json: RecordVulnerabilityWorkflowRequest
+
Responses
201 application/json: VulnerabilityWorkflowRecordEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/vulnerability-scans +
+

Upload vulnerability scan

+

Uploads a generic vulnerability scan JSON payload and records normalized findings.

+
+
Operation ID
uploadVulnerabilityScan
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
evidence:write
+
Idempotency
Idempotency-Key
+
Request
application/json: UploadVulnerabilityScanRequest
+
Responses
201 application/json: VulnerabilityScanEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/vulnerability-scans/{id} +
+

Get vulnerability scan

+

Returns a tenant-scoped vulnerability scan by id.

+
+
Operation ID
getVulnerabilityScan
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
evidence:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: VulnerabilityScanEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/waivers +
+

Create waiver

+

Creates a first-class scoped waiver for controls or policies. Approval is a separate audited transition.

+
+
Operation ID
createWaiver
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
policy:write
+
Idempotency
Idempotency-Key
+
Request
application/json: CreateWaiverRequest
+
Responses
201 application/json: WaiverEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/waivers/{id}/approve +
+

Approve waiver

+

Approves an unexpired waiver as an audited transition.

+
+
Operation ID
approveWaiver
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
policy:write
+
Idempotency
Idempotency-Key
+
Request
application/json: EmptyObject
+
Responses
200 application/json: WaiverEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ +
+

Schemas

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameShapeRequired fields
APIKeyobjectid, tenant_id, name, prefix, scopes, created_at
APIKeyCreateEnvelopeobjectdata, meta
APIKeyCreateResponseobjectapi_key, secret
APIKeyListEnvelopeobjectdata, meta
AnomalyReportobjectid, tenant_id, subject_type, subject_id, result, assumptions, limitations, schema_version, created_at
AnomalyReportEnvelopeobjectdata, meta
AnomalySignalobjectname, severity, detail
ApprovalRecordobjectid, tenant_id, subject_type, subject_id, decision, reason, approver_id, schema_version, created_at
ApprovalRecordEnvelopeobjectdata, meta
Artifactobjectid, tenant_id, name, digest, schema_version, created_at
ArtifactEnvelopeobjectdata, meta
ArtifactSignatureobjectid, tenant_id, artifact_id, subject_digest, algorithm, signature, verification_status, schema_version, created_at
ArtifactSignatureEnvelopeobjectdata, meta
AuditChainEntryobjectid, tenant_id, sequence, entry_type, subject_type, subject_id, actor_type, actor_id, occurred_at, canonical_entry_hash, previous_entry_hash, entry_hash, schema_version
AuditChainEntryListEnvelopeobjectdata, meta
BackupManifestobjectid, tenant_id, state_hash, resource_counts, consistency_checks, limitations, schema_version, created_at
BackupManifestEnvelopeobjectdata, meta
BuildAttestationobjectid, tenant_id, build_id, evidence_id, payload_hash, payload_size, payload_type, predicate_type, subject_digests, signature_count, verification_status, schema_version, created_at
BuildAttestationEnvelopeobjectdata, meta
BuildRunobjectid, tenant_id, project_id, release_id, provider, commit_sha, status, schema_version, created_at
BuildRunEnvelopeobjectdata, meta
CRAVulnerabilityHandlingReportobjectreport_type, template_version, product_id, release_id, summary, assumptions, limitations, generated_at
CRAVulnerabilityHandlingReportEnvelopeobjectdata, meta
Collectorobjectid, tenant_id, name, type, version, api_key_id, status, allowed_scopes, schema_version, created_at
CollectorCreateEnvelopeobjectdata, meta
CollectorCreateResponseobjectcollector, api_key, secret
CollectorHealthReportobjectreport_type, collector_id, collector_status, supply_chain_status, checks, assumptions, limitations, generated_at
CollectorHealthReportEnvelopeobjectdata, meta
CollectorListEnvelopeobjectdata, meta
CollectorReleaseobjectid, tenant_id, collector_id, version, artifact_digest, pinned, verification_status, health_status, schema_version, created_at
CollectorReleaseEnvelopeobjectdata, meta
CommercialCollectorDefinitionobjectid, tenant_id, name, provider, version, manifest_hash, allowed_scopes, status, schema_version, created_at
CommercialCollectorDefinitionEnvelopeobjectdata, meta
CommercialCollectorDefinitionListEnvelopeobjectdata, meta
ContainerImageobjectid, tenant_id, repository, digest, schema_version, created_at
ContainerImageEnvelopeobjectdata, meta
ContractDiffobjectid, tenant_id, base_contract_id, target_contract_id, product_id, result, schema_version, created_at
ContractDiffEnvelopeobjectdata, meta
ControlEvidenceobjectid, tenant_id, control_id, evidence_type, subject_type, subject_id, confidence, schema_version, created_at
ControlEvidenceEnvelopeobjectdata, meta
ControlEvidenceListEnvelopeobjectdata, meta
ControlEvidenceRequirementobjecttype, required
ControlFrameworkobjectid, tenant_id, name, slug, version, status, schema_version, created_at
ControlFrameworkEnvelopeobjectdata, meta
ControlFrameworkListEnvelopeobjectdata, meta
ControlFrameworkTemplatePackobjectid, name, slug, version, controls, schema_version
ControlFrameworkTemplatePackListEnvelopeobjectdata, meta
CosignVerificationobjectid, tenant_id, artifact_signature_id, subject_digest, result, checks, schema_version, created_at
CosignVerificationEnvelopeobjectdata, meta
CreateAPIKeyRequestobjectname, scopes
CreateAnomalyReportRequestobjectsubject_type, subject_id
CreateApprovalRequestobjectsubject_type, subject_id, decision, reason
CreateArtifactSignatureRequestobjectartifact_id, algorithm, signature
CreateBuildRequestobjectproject_id, release_id, provider, commit_sha, status, started_at
CreateCollectorRequestobjectname, type, version
CreateCommercialCollectorRequestobjectname, provider, version, manifest_hash
CreateControlFrameworkRequestobjectname, version
CreateCustomPolicyRequestobjectname, version, rules
CreateCustomerPackageRequestobjectproduct_id, redaction_profile_id, title, expires_at
CreateCustomerPortalAccessRequestobjectpackage_id, customer_name, expires_at
CreateDSSETrustRootRequestobjectname, key_id, algorithm, public_key
CreateDeploymentEnvironmentRequestobjectproduct_id, name, kind
CreateEvidenceRequestobjecttype, title, payload_hash
CreateEvidenceSummaryRequestobjectsubject_type, subject_id, evidence_ids
CreateExceptionRequestobjectrelease_id, reason, owner, expires_at
CreateGraphSnapshotRequestobjectnone
CreateIncidentRequestobjectproduct_id, title, severity
CreateIncidentWebhookReceiverRequestobjectname, provider, public_key
CreateLegalHoldRequestobjectscope_type, scope_id, reason, owner
CreateMarketplaceCollectorRequestobjectname, provider, version, publisher, manifest_hash
CreateMerkleBatchRequestobjectnone
CreateObjectRetentionPolicyRequestobjectname, mode, retention_days
CreateOpenAPIDiffRequestobjectbase_contract_id, target_contract_id, release_id
CreateOrganizationRequestobjectname, slug
CreatePDFReportPackageRequestobjectreport_type, title
CreateProductRequestobjectname, slug
CreateProjectRequestobjectproduct_id, name, slug
CreatePublicTransparencyLogRequestobjectname, endpoint, public_key
CreateQuestionnaireAnswerLibraryEntryRequestobjectanswer
CreateQuestionnaireDraftRequestobjecttemplate_id
CreateQuestionnairePackageRequestobjecttemplate_id
CreateQuestionnaireTemplateRequestobjectname, version, questions
CreateRedactionProfileRequestobjectnone
CreateReleaseBundleRequestobjectrelease_id
CreateReleaseCandidateRequestobjectrelease_id, name
CreateReleaseRequestobjectproduct_id, version
CreateRemediationTaskRequestobjecttitle, owner
CreateReportTemplateRequestobjectname, version, report_type, allowed_fields, template
CreateRetentionOverrideRequestobjectscope_type, scope_id, retention_until, reason, owner
CreateRoleBindingRequestobjectsubject_type, subject_id, role
CreateSBOMDiffRequestobjectbase_sbom_id, target_sbom_id
CreateSSOProviderRequestobjectname, type, issuer, client_id
CreateSSOSessionRequestobjectuser_id, provider_id, expires_at
CreateSaaSEditionProfileRequestobjectname, region, admin_tenant_id, isolation_model
CreateSecurityControlRequestobjectframework_id, code, title, objective
CreateSigningOperationRequestobjectprovider_id, subject_type, subject_id, payload_hash
CreateSigningProviderRequestobjectname, type, key_ref
CreateSourceRepositoryRequestobjectprovider, full_name
CreateTransparencyCheckpointRequestobjectbatch_id, provider
CreateUserRequestobjectemail, display_name
CreateVulnerabilityDecisionRequestobjectstatus, justification
CreateWaiverRequestobjectscope_type, scope_id, owner, risk, reason, expires_at
CustomPolicyobjectid, tenant_id, name, version, rules, schema_version, created_at
CustomPolicyEnvelopeobjectdata, meta
CustomPolicyEvaluationobjectid, tenant_id, policy_id, release_id, result, checks, input_hash, schema_version, created_at
CustomPolicyEvaluationEnvelopeobjectdata, meta
CustomReportTemplateobjectid, tenant_id, name, version, report_type, allowed_fields, template, schema_version, created_at
CustomReportTemplateEnvelopeobjectdata, meta
CustomerPortalAccessobjectid, tenant_id, package_id, customer_name, prefix, expires_at, access_count, failed_access_count, schema_version, created_at
CustomerPortalAccessCreateEnvelopeobjectdata, meta
CustomerPortalAccessCreateResponseobjectaccess, secret
CustomerPortalAccessEnvelopeobjectdata, meta
CustomerPortalAccessListEnvelopeobjectdata, meta
CustomerPortalPackageRequestobjecttoken
CustomerSecurityPackageobjectid, tenant_id, product_id, redaction_profile_id, title, state, manifest, manifest_hash, expires_at, access_count, schema_version, created_at
CustomerSecurityPackageEnvelopeobjectdata, meta
DSSEEnvelopeobjectpayloadType, payload, signatures
DSSETrustRootobjectid, tenant_id, name, key_id, algorithm, public_key, status, schema_version, created_at
DSSETrustRootEnvelopeobjectdata, meta
DataEnvelopeobjectdata, meta
DependencyChangeobjectid, tenant_id, sbom_diff_id, change_type, component, schema_version, created_at
DeploymentEnvironmentobjectid, tenant_id, product_id, name, kind, schema_version, created_at
DeploymentEnvironmentEnvelopeobjectdata, meta
DeploymentEnvironmentListEnvelopeobjectdata, meta
DeploymentEventobjectid, tenant_id, environment_id, release_id, status, started_at, schema_version, created_at
DeploymentEventEnvelopeobjectdata, meta
DeploymentEventListEnvelopeobjectdata, meta
EmptyObjectobjectnone
EvaluatePolicyRequestobjectrelease_id
EvidenceBundleobjectid, tenant_id, evidence_ids, manifest, manifest_hash, verification_text, schema_version, created_at
EvidenceBundleEnvelopeobjectdata, meta
EvidenceBundleImportobjectid, tenant_id, bundle_hash, result, imported_count, schema_version, created_at
EvidenceBundleImportEnvelopeobjectdata, meta
EvidenceCitationobjectevidence_id, type, title, canonical_hash
EvidenceGraphEdgeobjectfrom, to, relationship
EvidenceGraphNodeobjectid, type, label
EvidenceGraphSnapshotobjectid, tenant_id, nodes, edges, graph_hash, limitations, schema_version, created_at
EvidenceGraphSnapshotEnvelopeobjectdata, meta
EvidenceItemobjectid, tenant_id, type, title, source_system, observed_at, evidence_version, schema_version, payload_hash, canonical_hash, canonicalization, trust_level, verification_status, chain_entry_id, created_at
EvidenceItemEnvelopeobjectdata, meta
EvidenceItemListEnvelopeobjectdata, meta
EvidenceLifecycleEventobjectid, tenant_id, evidence_id, action, reason, actor_id, schema_version, created_at
EvidenceLifecycleEventEnvelopeobjectdata, meta
EvidenceLifecycleEventListEnvelopeobjectdata, meta
EvidenceNoticeobjectcode, message
EvidenceRefobjecttype, id
EvidenceSearchEnvelopeobjectdata, meta
EvidenceSearchResponseobjectitems
EvidenceSummaryobjectid, tenant_id, subject_type, subject_id, evidence_ids, summary, citations, assumptions, limitations, schema_version, created_at
EvidenceSummaryEnvelopeobjectdata, meta
EvidenceUploadRequestobjectrelease_id, payload
Exceptionobjectid, tenant_id, release_id, reason, owner, expires_at, approved, created_at
ExceptionEnvelopeobjectdata, meta
ExceptionListEnvelopeobjectdata, meta
ExchangeSSOCredentialRequestobjectprovider_id, subject
ExportEvidenceBundleRequestobjectnone
HTMLReportPackageobjectid, tenant_id, report_type, product_id, html, hash, schema_version, created_at
HTMLReportPackageEnvelopeobjectdata, meta
HealthStatusobjectstatus
HealthStatusEnvelopeobjectdata, meta
HumanUserobjectid, tenant_id, email, display_name, status, schema_version, created_at
HumanUserEnvelopeobjectdata, meta
Incidentobjectid, tenant_id, product_id, title, severity, status, opened_at, schema_version, created_at
IncidentEnvelopeobjectdata, meta
IncidentReportobjectreport_type, template_version, incident_id, result, timeline, tasks, assumptions, limitations, generated_at
IncidentReportEnvelopeobjectdata, meta
IncidentTimelineEventobjectid, tenant_id, incident_id, event_type, summary, occurred_at, schema_version, created_at
IncidentTimelineEventEnvelopeobjectdata, meta
IncidentWebhookDeliveryobjectwebhook_event, timeline_event
IncidentWebhookDeliveryEnvelopeobjectdata, meta
IncidentWebhookEventobjectid, tenant_id, receiver_id, incident_id, provider, event_id, payload_hash, signature_hash, result, schema_version, created_at
IncidentWebhookReceiverobjectid, tenant_id, incident_id, name, provider, public_key, status, schema_version, created_at
IncidentWebhookReceiverEnvelopeobjectdata, meta
InstanceAdminSnapshotobjectreport_type, tenant_count, resource_counts, limitations, generated_at
InstanceAdminSnapshotEnvelopeobjectdata, meta
LegalHoldobjectid, tenant_id, scope_type, scope_id, reason, owner, schema_version, created_at
LegalHoldEnvelopeobjectdata, meta
LinkControlEvidenceRequestobjectevidence_type, subject_type, subject_id, confidence
LinkEvidenceRequestobjecttarget_type, target_id
LinkSSOIdentityRequestobjectuser_id, provider_id, subject, email, verified
ManualSecurityDocumentobjectid, tenant_id, document_type, title, sensitivity, evidence_id, payload_hash, schema_version, created_at
ManualSecurityDocumentEnvelopeobjectdata, meta
MarketplaceCollectorobjectid, tenant_id, name, provider, version, publisher, manifest_hash, state, schema_version, created_at
MarketplaceCollectorEnvelopeobjectdata, meta
MarketplaceCollectorHealthReportobjectreport_type, collector_id, name, provider, version, supply_chain_status, checks, collector, assumptions, limitations, generated_at
MarketplaceCollectorHealthReportEnvelopeobjectdata, meta
MarketplaceCollectorListEnvelopeobjectdata, meta
MerkleBatchobjectid, tenant_id, from_sequence, to_sequence, entry_count, leaf_hashes, root_hash, schema_version, created_at
MerkleBatchEnvelopeobjectdata, meta
MetricsSnapshotobjecttenant_id, resource_counts, customer_portal_failed_access_count, customer_portal_revoked_access_count
MetricsSnapshotEnvelopeobjectdata, meta
MissingEvidenceReportobjectreport_type, template_version, release_id, result, missing, assumptions, limitations
MissingEvidenceReportEnvelopeobjectdata, meta
ObjectRetentionPolicyobjectid, tenant_id, name, object_prefix, mode, retention_days, status, schema_version, created_at
ObjectRetentionPolicyEnvelopeobjectdata, meta
OpenAPIContractobjectid, tenant_id, product_id, version, hash, path_count, evidence_id, created_at
OpenAPIContractEnvelopeobjectdata, meta
OpenAPIDocumentobjectopenapi, info, paths
OpenAPIOperationRecordobjectpath, method
Organizationobjectid, tenant_id, name, slug, status, schema_version, created_at
OrganizationEnvelopeobjectdata, meta
PDFReportPackageobjectid, tenant_id, report_type, title, payload_hash, payload_size, limitations, schema_version, created_at
PDFReportPackageEnvelopeobjectdata, meta
PolicyCheckobjectname, result, severity, explanation
PolicyEvaluationobjectid, tenant_id, release_id, result, policy_set, checks, created_at
PolicyEvaluationEnvelopeobjectdata, meta
PolicyRuleobjectname, severity, required
Problemobjectnone
Productobjectid, tenant_id, name, slug, schema_version, created_at
ProductEnvelopeobjectdata, meta
ProductListEnvelopeobjectdata, meta
Projectobjectid, tenant_id, product_id, name, slug, schema_version, created_at
ProjectEnvelopeobjectdata, meta
ProviderVerificationobjectid, tenant_id, provider_type, provider_id, subject, result, checks, limitations, schema_version, created_at
ProviderVerificationEnvelopeobjectdata, meta
PublicTransparencyLogobjectid, tenant_id, name, endpoint, public_key, state, schema_version, created_at
PublicTransparencyLogEntryobjectid, tenant_id, log_id, checkpoint_id, merkle_batch_id, external_id, entry_hash, state, schema_version, created_at
PublicTransparencyLogEntryEnvelopeobjectdata, meta
PublicTransparencyLogEnvelopeobjectdata, meta
PublishPublicTransparencyLogEntryRequestobjectlog_id, checkpoint_id, external_id
PullRequestobjectid, tenant_id, repository_id, provider, provider_id, state, schema_version, created_at
PullRequestEnvelopeobjectdata, meta
QuestionnaireAnswerLibraryEntryobjectid, tenant_id, answer, schema_version, created_at
QuestionnaireAnswerLibraryEntryEnvelopeobjectdata, meta
QuestionnaireAnswerLibraryEntryListEnvelopeobjectdata, meta
QuestionnaireDraftobjectid, tenant_id, template_id, responses, manifest_hash, limitations, schema_version, created_at
QuestionnaireDraftEnvelopeobjectdata, meta
QuestionnairePackageobjectid, tenant_id, template_id, responses, manifest_hash, schema_version, created_at
QuestionnairePackageEnvelopeobjectdata, meta
QuestionnaireQuestionobjectid, prompt
QuestionnaireResponseobjectquestion_id, answer
QuestionnaireTemplateobjectid, tenant_id, name, version, questions, schema_version, created_at
QuestionnaireTemplateEnvelopeobjectdata, meta
ReadinessReportobjectreport_type, template_version, result, assumptions, limitations, generated_at
ReadinessReportEnvelopeobjectdata, meta
ReadinessStatusobjectstatus, checks
ReadinessStatusEnvelopeobjectdata, meta
RecordCollectorReleaseRequestobjectversion, artifact_digest
RecordDeploymentRequestobjectenvironment_id, release_id, status, started_at
RecordEvidenceLifecycleEventRequestobjectaction, reason
RecordIncidentTimelineRequestobjectevent_type, summary
RecordPullRequestRequestobjectrepository_id, provider, provider_id, title, state
RecordSourceCommitRequestobjectrepository_id, sha, committed_at
RecordVulnerabilityWorkflowRequestobjectaction, reason
RedactionProfileobjectid, tenant_id, name, schema_version, created_at
RedactionProfileEnvelopeobjectdata, meta
RegisterArtifactRequestobjectname, digest
RegisterContainerImageRequestobjectrepository, digest
Releaseobjectid, tenant_id, product_id, version, status, schema_version, created_at
ReleaseBundleobjectid, tenant_id, release_id, state, manifest, manifest_hash, signature_refs, created_at
ReleaseBundleEnvelopeobjectdata, meta
ReleaseBundleManifestobjectmanifest_version, bundle_id, tenant_id, release, evidence_ids, chain_checkpoint, generated_at, generator
ReleaseBundleManifestEnvelopeobjectdata, meta
ReleaseBundleVerificationobjectresult
ReleaseBundleVerificationEnvelopeobjectdata, meta
ReleaseCandidateobjectid, tenant_id, release_id, name, state, snapshot_hash, schema_version, created_at
ReleaseCandidateEnvelopeobjectdata, meta
ReleaseCandidateListEnvelopeobjectdata, meta
ReleaseCandidateTransitionRequestobjectnone
ReleaseEnvelopeobjectdata, meta
ReleaseEvidenceFlowobjectrelease_id, product_id, status, counts, steps, assumptions, limitations, schema_version, generated_at
ReleaseEvidenceFlowEnvelopeobjectdata, meta
ReleaseEvidenceFlowStepobjectid, title, status, required, method, path, required_scopes, idempotency_required, description
ReleaseSecurityApprovalSummaryobjecttotal, approved
ReleaseSecurityExceptionSummaryobjecttotal, approved_unexpired, unapproved, expired
ReleaseSecurityMissingDecisionobjectfinding_id, scan_id, vulnerability, severity, state
ReleaseSecurityProductSummaryobjectid, name, slug
ReleaseSecurityReleaseSummaryobjectid, version, state
ReleaseSecuritySummaryobjectproduct, release, artifact_count, sbom_status, vulnerability_scan_status, open_findings_by_severity, decisions_by_status, approval_summary, exception_summary, readiness_status, package_status, counts, assumptions, limitations, schema_version, generated_at
ReleaseSecuritySummaryEnvelopeobjectdata, meta
RemediationTaskobjectid, tenant_id, title, owner, status, schema_version, created_at
RemediationTaskEnvelopeobjectdata, meta
RenderReportTemplateRequestobjectsubject_type, subject_id
RenderedCustomReportobjectid, tenant_id, template_id, subject_type, subject_id, output, hash, schema_version, created_at
RenderedCustomReportEnvelopeobjectdata, meta
RetentionOverrideobjectid, tenant_id, scope_type, scope_id, retention_until, reason, owner, schema_version, created_at
RetentionOverrideEnvelopeobjectdata, meta
RetentionReportobjectreport_type, legal_holds, retention_overrides, limitations, generated_at
RetentionReportEnvelopeobjectdata, meta
RoleBindingobjectid, tenant_id, subject_type, subject_id, role, schema_version, created_at
RoleBindingEnvelopeobjectdata, meta
RoleBindingListEnvelopeobjectdata, meta
SBOMobjectid, tenant_id, evidence_id, release_id, format, component_count, created_at
SBOMComponentobjectname
SBOMComponentRecordobjectsbom_id, component
SBOMComponentRecordListEnvelopeobjectdata, meta
SBOMDiffobjectid, tenant_id, base_sbom_id, target_sbom_id, unchanged_count, schema_version, created_at
SBOMDiffEnvelopeobjectdata, meta
SBOMEnvelopeobjectdata, meta
SSOCredentialExchangeEnvelopeobjectdata, meta
SSOCredentialExchangeResponseobjectverification, session, secret
SSOProviderobjectid, tenant_id, name, type, issuer, client_id, status, schema_version, created_at
SSOProviderEnvelopeobjectdata, meta
SSOSessionobjectid, tenant_id, user_id, provider_id, prefix, expires_at, schema_version, created_at
SSOSessionCreateEnvelopeobjectdata, meta
SSOSessionCreateResponseobjectsession, secret
SSOSessionEnvelopeobjectdata, meta
SaaSEditionProfileobjectid, tenant_id, name, region, admin_tenant_id, isolation_model, status, config_hash, limitations, schema_version, created_at
SaaSEditionProfileEnvelopeobjectdata, meta
SecurityControlobjectid, tenant_id, framework_id, code, title, objective, schema_version, created_at
SecurityControlEnvelopeobjectdata, meta
SecurityReviewPackageReportobjectreport_type, template_version, package_id, product_id, evidence_ids, assumptions, limitations, generated_at
SecurityReviewPackageReportEnvelopeobjectdata, meta
SecurityScanobjectid, tenant_id, category, format, scanner, target_ref, evidence_id, payload_hash, finding_count, redacted, quarantined, schema_version, created_at
SecurityScanEnvelopeobjectdata, meta
SecurityUpdateEvidenceReportobjectreport_type, template_version, product_id, release_id, summary, assumptions, limitations, generated_at
SecurityUpdateEvidenceReportEnvelopeobjectdata, meta
SignedIncidentWebhookPayloadobjectevent_type, summary
SigningCustodyReviewReportobjectreport_type, tenant_id, checks, assumptions, limitations, generated_at
SigningCustodyReviewReportEnvelopeobjectdata, meta
SigningKeyobjectid, tenant_id, kid, algorithm, status, public_key, created_at
SigningKeyEnvelopeobjectdata, meta
SigningKeyListEnvelopeobjectdata, meta
SigningKeyTransitionRequestobjectnone
SigningOperationobjectid, tenant_id, provider_id, subject_type, subject_id, payload_hash, result, checks, schema_version, created_at
SigningOperationEnvelopeobjectdata, meta
SigningProviderobjectid, tenant_id, name, type, status, key_ref, encrypted, schema_version, created_at
SigningProviderEnvelopeobjectdata, meta
SourceBranchobjectid, tenant_id, repository_id, name, protected, schema_version, created_at
SourceBranchEnvelopeobjectdata, meta
SourceCommitobjectid, tenant_id, repository_id, sha, schema_version, created_at
SourceCommitEnvelopeobjectdata, meta
SourceRepositoryobjectid, tenant_id, provider, full_name, schema_version, created_at
SourceRepositoryEnvelopeobjectdata, meta
SourceRepositoryListEnvelopeobjectdata, meta
SourceSnapshotBranchInputobjectname
SourceSnapshotCommitInputobjectsha, committed_at
SourceSnapshotEnvelopeobjectdata, meta
SourceSnapshotPullRequestInputobjectprovider_id, state
SourceSnapshotRepositoryInputobjectfull_name
SourceSnapshotRequestobjectrepository
SourceSnapshotResultobjectrepository
SubjectRefobjecttype
SupersedeEvidenceRequestobjectreplacement_evidence_id, reason
TransparencyCheckpointobjectid, tenant_id, batch_id, provider, timestamp_hash, state, schema_version, created_at
TransparencyCheckpointEnvelopeobjectdata, meta
UpdateSSOProviderTrustMaterialRequestobjectnone
UploadManualSecurityDocumentRequestobjectdocument_type, title, sensitivity, payload
UploadOpenAPIContractRequestobjectproduct_id, release_id, version, spec
UploadSPDXSBOMRequestobjectrelease_id, payload
UploadSecurityScanRequestobjectcategory, format, scanner, target_ref, payload
UploadVulnerabilityScanRequestobjectscanner, target_ref, release_id, findings
UpsertSourceBranchRequestobjectrepository_id, name
UserIdentityLinkobjectid, tenant_id, user_id, provider_id, subject, email, verified, schema_version, created_at
UserIdentityLinkEnvelopeobjectdata, meta
VEXDocumentobjectid, tenant_id, evidence_id, release_id, format, statement_count, schema_version, created_at
VEXDocumentEnvelopeobjectdata, meta
VEXImportIssueobjectcode, detail
VEXImportPreviewobjecttenant_id, release_id, format, parser_version, advisory, statement_count, status_summary, decisions_would_create, decisions_would_supersede, assumptions, limitations, schema_version, generated_at
VEXImportPreviewEnvelopeobjectdata, meta
VEXImportReportobjectid, tenant_id, vex_document_id, evidence_id, parser_version, status, statement_count, decisions_created, decisions_superseded, schema_version, created_at, updated_at
VEXImportReportEnvelopeobjectdata, meta
VerificationResultobjectid, tenant_id, subject_type, subject_id, result, checks, verified_at
VerificationResultEnvelopeobjectdata, meta
VerifyCheckobjectname, result
VerifyCosignSignatureRequestobjectnone
VerifyProviderIdentityRequestobjectprovider_type, provider_id, subject
VerifyPublicTransparencyLogEntryRequestobjectroot_hash, leaf_index, tree_size, inclusion_proof
VerifySubjectRequestobjectsubject_type
VersionInfoobjectversion
VersionInfoEnvelopeobjectdata, meta
VulnerabilityDecisionobjectid, tenant_id, finding_id, scan_id, vulnerability, status, justification, source, schema_version, created_at
VulnerabilityDecisionCustomerSummaryobjectid, finding_id, scan_id, release_id, vulnerability, status, impact_statement, source, created_at
VulnerabilityDecisionEnvelopeobjectdata, meta
VulnerabilityDecisionListEnvelopeobjectdata, meta
VulnerabilityDecisionSummaryReportobjectreport_type, template_version, product_id, release_id, decisions, assumptions, limitations, generated_at
VulnerabilityDecisionSummaryReportEnvelopeobjectdata, meta
VulnerabilityPostureReportobjectreport_type, template_version, summary, open_critical, assumptions, limitations, generated_at
VulnerabilityPostureReportEnvelopeobjectdata, meta
VulnerabilityScanobjectid, tenant_id, release_id, scanner, target_ref, summary, findings, created_at
VulnerabilityScanEnvelopeobjectdata, meta
VulnerabilityWorkflowRecordobjectid, tenant_id, finding_id, action, reason, actor_id, schema_version, created_at
VulnerabilityWorkflowRecordEnvelopeobjectdata, meta
Waiverobjectid, tenant_id, scope_type, scope_id, owner, risk, reason, expires_at, approved, schema_version, created_at
WaiverEnvelopeobjectdata, meta
+
+
+
+ + + diff --git a/docs/operations.md b/docs/operations.md index 739b3ee..83bf5b8 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -1,108 +1,45 @@ # Operations -## Configuration - -Backend configuration is read from environment variables: - -- `EVYDENCE_ADDR`: API bind address, default `:8080`. -- `EVYDENCE_API_KEY_PEPPER`: HMAC pepper for API key hashes. Use a long random value. -- `EVYDENCE_DATABASE_URL`: PostgreSQL connection string. When set, the API uses durable PostgreSQL state and persisted outbox jobs. Required for production. -- `EVYDENCE_OBJECT_STORE`: `filesystem`, `s3`, or `minio`. Defaults to `filesystem`. -- `EVYDENCE_OBJECT_DIR`: filesystem object-store root for raw uploaded payload bytes, default `tmp/objects` when PostgreSQL is enabled. -- `EVYDENCE_S3_ENDPOINT`: S3/MinIO endpoint when `EVYDENCE_OBJECT_STORE=s3` or `minio`. -- `EVYDENCE_S3_BUCKET`: S3/MinIO bucket name. The bucket must already exist. -- `EVYDENCE_S3_ACCESS_KEY_ID`: S3/MinIO access key ID. -- `EVYDENCE_S3_SECRET_ACCESS_KEY`: S3/MinIO secret access key. -- `EVYDENCE_S3_REGION`: optional S3 region. -- `EVYDENCE_S3_USE_SSL`: set to `true` for HTTPS endpoints. -- `EVYDENCE_SKIP_MIGRATIONS`: set to `true` only when migrations are applied by an external release process. -- `EVYDENCE_MIGRATIONS_DIR`: migration directory, default `migrations`. -- `EVYDENCE_BOOTSTRAP_TENANT`: local bootstrap tenant name. -- `EVYDENCE_PRINT_BOOTSTRAP_SECRET`: set to `true` only for local development to print the bootstrap API key secret. -- `EVYDENCE_WORKER_POLL_INTERVAL`: worker outbox polling interval, default `1s`. -- `EVYDENCE_SIGNING_KEY_MODE`: production currently requires `external`; local plaintext signing keys are development-only. - -Do not commit real `.env`, `.api.env`, or `.test.env` files. - -## Local Dependencies - -```sh -make compose-up -make compose-down -``` - -The current API can run without these dependencies because it uses an in-process store. PostgreSQL and MinIO are included for the persistence/object-storage implementation slice. - -Run with durable PostgreSQL state: - -```sh -make compose-up -set -a; . ./.api.env; set +a -make migrate -EVYDENCE_PRINT_BOOTSTRAP_SECRET=true go run ./cmd/evydence-api -``` - -Run the worker in a separate shell with the same database environment: - -```sh -set -a; . ./.api.env; set +a -go run ./cmd/evydence-worker -``` - -The API can use filesystem or S3/MinIO-compatible object storage. Object keys are generated by the application under `tenants//...`; do not grant collectors direct bucket write access. - -Example MinIO configuration with Docker Compose: - -```sh -EVYDENCE_OBJECT_STORE=minio -EVYDENCE_S3_ENDPOINT=localhost:9000 -EVYDENCE_S3_BUCKET=evydence -EVYDENCE_S3_ACCESS_KEY_ID=minioadmin -EVYDENCE_S3_SECRET_ACCESS_KEY=minioadmin -EVYDENCE_S3_USE_SSL=false -``` - -## GitHub Actions Provenance Upload - -Create a `github_actions` collector with `POST /v1/collectors`, store the one-time returned secret in GitHub Actions secrets, and use the CLI from a workflow step: - -```sh -go run ./cmd/evydence github-actions upload-build \ - --url "$EVYDENCE_API_URL" \ - --api-key "$EVYDENCE_API_KEY" \ - --project-id "$EVYDENCE_PROJECT_ID" \ - --release-id "$EVYDENCE_RELEASE_ID" \ - --artifact-id "$EVYDENCE_ARTIFACT_ID" \ - --artifact-digest "$EVYDENCE_ARTIFACT_DIGEST" \ - --attestation-path provenance.dsse.json -``` - -The command reads `GITHUB_REPOSITORY`, `GITHUB_WORKFLOW_REF`, `GITHUB_RUN_ID`, `GITHUB_RUN_ATTEMPT`, `GITHUB_JOB`, `GITHUB_ACTOR`, `GITHUB_REF`, and `GITHUB_SHA` from the Actions environment. `EVYDENCE_GITHUB_OIDC_SUBJECT` or `--oidc-subject` can capture an OIDC subject string when a workflow already has it, but this slice does not request or verify GitHub OIDC tokens. - -End-to-end workflow examples are stored in `docs/github-actions/release-evidence-workflow.yml` and `docs/gitlab/evydence-release-evidence.gitlab-ci.yml`. - -## CLI Uploads - -Use `evydence upload manifest` for bulk upload manifests that contain explicit `/v1` paths, idempotency keys, and JSON payloads or local payload files. Use `evydence import-bundle upload` for air-gapped evidence bundle imports. - -Use `evydence release manifest`, `evydence release sign`, and `evydence release verify` to create and verify release artifact manifests before publishing binaries, images, or collector artifacts. +This operator index points to the canonical references for running Evydence. Keep command details in the linked pages so startup, configuration, and release validation guidance do not drift. + +## Operator Tasks + +| Task | Canonical Doc | Expected Outcome | +|------|---------------|------------------| +| Choose local or durable runtime mode | [Install and operate](how-to/install-and-operate.md) | API and worker run with either in-process state or PostgreSQL-backed state. | +| Rehearse production-like Compose | [Install and operate](how-to/install-and-operate.md) | API, worker, migrations, PostgreSQL, and MinIO start together with one API writer. | +| Configure environment variables | [Configuration](reference/configuration.md) | Runtime variables are set from local untracked files or deployment secrets. | +| Wire observability | [Observability](reference/observability.md) | Readiness, admin metrics, Prometheus rules, and dashboard starter assets are reviewed for the deployment. | +| Run release validation | [Release validation](reference/release-validation.md) | `tmp/release-check-summary.txt` records pass and explicit skip lines. | +| Check production readiness | [Production readiness](reference/production-readiness.md) | Live PostgreSQL, coverage, release validation, and signed release artifact smoke checks pass before production positioning. | +| Operate outbox workers | [Worker outbox contract](reference/worker-outbox.md) | Workers claim persisted jobs and fail safely on missing state, hash mismatch, or unsupported jobs. | +| Integrate CI evidence | [Integrate CI collectors](how-to/integrate-ci.md) | CI jobs upload build, attestation, source snapshot, or collector evidence with scoped secrets. | +| View packages locally | [View packages locally](how-to/view-packages.md) | Customer package and readiness JSON can be inspected without uploading data. | +| Deploy on Kubernetes | [Kubernetes deployment](kubernetes.md) | API and worker deploy with external PostgreSQL, object storage, and external signing mode. | +| Build an offline package | [Air-gapped installation](air-gapped.md) | Package manifests and signatures are verified before import. | +| Prepare a design-partner pilot | [Pilot deployment checklist](how-to/pilot-deployment-checklist.md) | One narrow pilot profile is reviewed for environment, database, object storage, access, signing, TLS, backups, migrations, redaction, and support. | +| Review production hardening | [Production hardening review](production-hardening.md) | Unsafe defaults, backup gaps, diagnostics exposure, and customer package handling are reviewed. | +| Rehearse backup and restore | [Backup and restore runbook](runbooks/backup-restore.md) | Database/object-store pairing and post-restore verification are recorded. | +| Upgrade a deployment | [Upgrade runbook](runbooks/upgrade.md) | Release artifacts, migrations, and post-upgrade verification are checked. | +| Respond to incidents | [Incident response runbook](runbooks/incident-response.md) | Secrets, tenant data, signing, provider, and package boundaries are handled without public leakage. | +| Review capacity and failure modes | [Capacity and failure modes](reference/capacity-and-failures.md) | Single-writer API limits, worker scaling, object-store failures, and retry behavior are understood. | +| Review benchmark evidence | [Benchmark results](reference/benchmark-results.md) | Local evidence-ingestion benchmark scope and limitations are clear. | +| Review release status | [Production exit review](reference/production-exit-review.md) | The controlled self-hosted candidate status and unresolved blockers are explicit. | ## Integrity Operations -Use `GET /v1/ready` for low-detail readiness checks. Use `GET /v1/metrics` only with an admin API key; it returns safe tenant resource counts, not raw evidence payloads or secrets. - -Use `POST /v1/backup-manifests` after database and object-store backups complete to record a hash and consistency checks for the ledger state. The manifest is not a backup by itself and intentionally excludes raw payload bytes and private signing-key material. - -Use `GET /v1/admin/instance` with an instance-admin scoped actor for low-detail tenant/resource counts. Do not expose this endpoint to customer verifier roles. - -Legal holds and retention overrides are ledger records. They help explain why evidence should be retained, but they do not configure external database backups, S3 lifecycle rules, or object-lock policies. - -Customer portal access tokens are returned once by `POST /v1/customer-portal/access`, expire, and are stored as hashes. Treat them like bearer secrets and do not place them in logs or long-lived documentation. - -Use `POST /v1/merkle-batches` and `POST /v1/transparency-checkpoints` to record signed local checkpoints and optional external anchoring metadata. These support tamper-evidence but do not make public transparency mandatory. +- Use `GET /v1/ready` for low-detail readiness checks. +- Use `GET /v1/metrics` only with an admin API key; it returns safe tenant resource counts as JSON or Prometheus text when `Accept: text/plain` is sent, not raw evidence payloads or secrets. +- Use `GET /v1/admin/instance` only with an actor explicitly granted `instance:admin`. Tenant admin and ordinary wildcard tenant keys do not satisfy this instance-wide scope by themselves. +- Use `POST /v1/backup-manifests` after database and object-store backups complete. The manifest intentionally excludes raw payload bytes and private signing-key material. +- Use `POST /v1/merkle-batches` and `POST /v1/transparency-checkpoints` to record signed local checkpoints and optional external anchoring metadata. +- Use `POST /v1/public-transparency-log-entries/{id}/verify` when an operator has public-log inclusion proof material. Evydence verifies the supplied RFC6962-style proof locally and records checks and limitations; it does not fetch proof material from the provider. -Object-retention policy APIs record and verify retention intent for tenant-prefixed object paths. Enforcing WORM/object-lock settings remains the responsibility of the configured object store and deployment policy. +## Token And Trust Boundaries -## Production Caveat +- API keys, collector keys, SSO session tokens, and customer portal access tokens are bearer secrets. Do not place them in logs, documentation, long-lived build output, or customer packages. +- Customer portal access tokens are returned once by `POST /v1/customer-portal/access`, expire, and are stored as hashes. Access can require explicit NDA acceptance, and portal ZIP downloads can include non-secret distribution watermarks. Successful manifest exchanges, ZIP downloads, NDA acceptance events, and failed known-prefix attempts write audit-chain entries under the portal access record and relevant package record without storing the supplied token. Deployments should still use reverse-proxy or API-gateway throttling for the unauthenticated portal endpoint. +- Human SSO session records are admin-managed, authenticated SSO sessions can revoke themselves through API-first logout, and SSO credential exchange can set an HttpOnly session cookie after locally verifying an OIDC ID token or SAML assertion against tenant-configured trust material. OIDC group claim values can be captured on the session and mapped to configured roles without creating permanent role bindings from token claims. Current endpoints can refresh OIDC public JWKS from discovery metadata and can call a configured OIDC UserInfo endpoint or provider validation gateway for verification checks, but they do not implement direct provider-specific management API clients or external group synchronization. +- Object-retention APIs record and verify retention intent for tenant-prefixed object paths. S3/MinIO verification can check bucket defaults plus sample-object retention and legal hold where configured. Enforcing WORM or object-lock settings remains the responsibility of the configured object store and deployment policy. -The API refuses `ENV=production` unless PostgreSQL is configured, a non-default API key pepper is set, and local plaintext signing-key mode is disabled. Signing-provider records are available, but production deployments should use an external signing mode and avoid storing private key material in the ledger snapshot. +Evydence operations support evidence organization, review, and tamper-evident records. Operational checks do not replace external audit review, secret management, backup testing, or provider verification. diff --git a/docs/operator-overview.md b/docs/operator-overview.md new file mode 100644 index 0000000..31cefc8 --- /dev/null +++ b/docs/operator-overview.md @@ -0,0 +1,56 @@ +# Operator Overview + +This overview is for maintainers and self-hosted operators who need to run, +upgrade, monitor, back up, and validate Evydence. + +Evydence is currently a controlled self-hosted production candidate. The +supported API deployment profile is one API writer replica with scalable worker +replicas through PostgreSQL outbox locking. See +[Production readiness](reference/production-readiness.md) and +[Production exit review](reference/production-exit-review.md) before using it +beyond evaluation. + +## Primary Operator Path + +1. Start with [Install and operate](how-to/install-and-operate.md) for local, + durable, and production-like runtime modes. +2. Use [Configuration](reference/configuration.md) for environment variables + and production-safety combinations. +3. Use [Kubernetes deployment](kubernetes.md) or + [Air-gapped installation](air-gapped.md) only after the package and release + evidence have been verified. +4. Review the [Hardened reference deployment](reference/hardened-reference-deployment.md) + before accepting pilot or internal production traffic. +5. Run the gates in [Release validation](reference/release-validation.md) and + [Production gate troubleshooting](reference/production-gate-troubleshooting.md) + with sanitized logs. +6. Rehearse [Backup and restore](runbooks/backup-restore.md), follow the + [Upgrade runbook](runbooks/upgrade.md) and + [Upgrade compatibility policy](reference/upgrade-compatibility-policy.md), keep + [Incident response](runbooks/incident-response.md) ready, and prepare + [Key rotation](runbooks/key-rotation.md) plus + [Object store recovery](runbooks/object-store-recovery.md). + +## Operating Boundaries + +- PostgreSQL is required for production-mode source of truth. +- Filesystem object storage is local/test oriented; S3-compatible object + storage is the production target. +- API writers stay at one replica for the current release line. +- Worker replicas may scale through PostgreSQL outbox locking. +- API keys, collector keys, SSO sessions, portal tokens, private keys, object + payloads, customer package contents, and raw evidence must not be logged or + placed in public support reports. +- Release evidence supports reproducibility and engineering review, not legal + compliance proof, certification, complete SBOM proof, authoritative scanner + results, or a secure-release guarantee. + +## Related References + +- [Observability](reference/observability.md) +- [Capacity and failure modes](reference/capacity-and-failures.md) +- [Hardened reference deployment](reference/hardened-reference-deployment.md) +- [HA strategy](reference/ha-strategy.md) +- [External controls matrix](reference/external-controls-matrix.md) +- [Stable v0.1.0 exit criteria](reference/stable-v0.1.0-exit-criteria.md) +- [Source of truth](reference/source-of-truth.md) diff --git a/docs/production-hardening.md b/docs/production-hardening.md index 9ee1167..61652be 100644 --- a/docs/production-hardening.md +++ b/docs/production-hardening.md @@ -2,17 +2,84 @@ This is an explanation and operator checklist for production-readiness review. -Required before labeling a deployment production: +## Configuration Gate + +Before labeling a deployment production, verify: + +- `ENV=production` is set. +- `EVYDENCE_DATABASE_URL` points to external PostgreSQL. +- `EVYDENCE_API_KEY_PEPPER` is a non-default random value. +- `EVYDENCE_SIGNING_KEY_MODE=external`, `aws-kms`, `gcp-kms`, + `azure-key-vault`, or gateway-backed `pkcs11-hsm` is set. +- `EVYDENCE_PRINT_BOOTSTRAP_SECRET` is unset or false. +- Only one API writer replica is deployed; production API startup also enforces + this with a PostgreSQL advisory writer lease. + +These checks are enforced by API startup. See [Configuration](reference/configuration.md). + +## Deployment Checklist + +Use [Hardened reference deployment](reference/hardened-reference-deployment.md) +for the controlled self-hosted architecture and +[Pilot deployment checklist](how-to/pilot-deployment-checklist.md) for the +copy-paste go/no-go record. - PostgreSQL is external, backed up, monitored, and restored in a test environment. - Object storage is external S3/MinIO-compatible storage with tenant-prefixed paths, encryption, lifecycle policy, and retention/object-lock policy where required. -- `ENV=production`, `EVYDENCE_DATABASE_URL`, non-default `EVYDENCE_API_KEY_PEPPER`, and `EVYDENCE_SIGNING_KEY_MODE=external` are set. -- API keys are scoped, rotated, and stored outside source control. +- S3/MinIO object-retention policy verification has been run for required tenant prefixes, and records show bucket versioning, default object-lock mode/duration, sample object retention, and sample object legal-hold checks where those controls are required, with documented limitations. - Ingress terminates TLS and does not expose internal diagnostics. -- `/v1/metrics` and `/v1/audit-log` require admin API keys and are not public. -- Backups include PostgreSQL, object storage, OpenAPI/migration version, and release artifact manifests. -- Restore tests verify database/object consistency and audit-chain verification after restore. -- Collector releases are pinned and have signature, SBOM, and vulnerability scan evidence when available. +- Edge rate limiting is configured at the reverse proxy or ingress. The optional `EVYDENCE_RATE_LIMIT_REQUESTS_PER_MINUTE` in-process limiter is a local safety net and keys by TCP remote address only. +- `/v1/metrics`, `/v1/audit-log`, and `/v1/admin/instance` are protected by server-side scopes and are not public. +- API keys and collector keys are scoped, rotated, and stored outside source control. +- OIDC UserInfo validation, when used, sends only the supplied access token to + the configured provider endpoint and stores verification checks, not bearer + token values. +- Customer portal access tokens are short-lived, scoped to one package, and handled as bearer secrets. - Generated customer packages use explicit redaction profiles and expiry. +- Collector releases are pinned and have signature, SBOM, and vulnerability scan evidence when available. +- Signing operations use either a tenant-controlled HTTPS signing gateway, AWS KMS, GCP Cloud KMS, Azure Key Vault, or gateway-backed PKCS#11/HSM profile; raw evidence payload bytes are not sent to any signing executor by Evydence. + +## Backup And Restore Checklist + +- Backups include PostgreSQL and object storage from the same recovery point. +- Backup evidence records the OpenAPI version, migration version, release artifact manifest, and chart or deployment version. +- Restore tests verify database/object consistency after restore. +- Audit-chain verification and release-bundle verification are run after restore. +- `POST /v1/backup-manifests` is recorded after the database and object-store backups complete. + +The repository includes local and live-PostgreSQL restore rehearsals. They save ledger state, copy object payloads, start a fresh ledger from the restored state or schema, verify a backup manifest, check payload digest availability, and verify a release bundle after restore. Deployment-specific restore tests still need to exercise the target backup tooling, S3/MinIO bucket policy, secrets, signing provider, and operator recovery procedure. + +Backup manifests help compare recorded ledger state. They intentionally exclude raw payload bytes and private signing-key material, so they cannot replace matched database and object-store backups. + +## Deployment Verification + +Compose: + +```sh +make compose-up +set -a; . ./.test.env; set +a +make live-postgres-check +make postgres-integration-test +``` + +Expected result: live PostgreSQL migration and integration checks pass, or the targets print explicit skip lines when `EVYDENCE_TEST_DATABASE_URL` is unset. + +Helm: + +```sh +helm upgrade --install evydence ./deploy/helm/evydence --dry-run +kubectl rollout status deploy/evydence-api +kubectl rollout status deploy/evydence-worker +``` + +Expected result: rendered manifests reference the configured secret and object store, and both deployments roll out. + +Air-gapped packages: + +```sh +./dist/evydence release verify --manifest evydence-release-manifest.json --signature evydence-release-manifest.sig.json +``` + +Expected result: manifest signature and referenced artifact hashes verify before import. -This checklist supports deployment hardening and evidence organization. It is not a certification, legal compliance determination, or secure-release conclusion. +This checklist supports deployment hardening and evidence organization. It is not a certification, legal compliance determination, or release-security guarantee. diff --git a/docs/reference/api-contract-matrix.md b/docs/reference/api-contract-matrix.md new file mode 100644 index 0000000..1672716 --- /dev/null +++ b/docs/reference/api-contract-matrix.md @@ -0,0 +1,196 @@ +# API Contract Matrix + +This generated reference inventories Evydence `/v1` route contract precision from `openapi.yaml`. +It is a planning aid for production contract hardening; `broad` means the route still uses a shared envelope, unspecified body, or generic schema where an endpoint-specific contract should be considered. + +Generated from 186 operations: 186 precise, 0 broad. + +| Method | Path | Operation | Auth | Scopes | Idempotency | Params | Request | 2xx Response | Precision | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| GET | /v1/admin/instance | instanceAdminSnapshot | Bearer | instance:admin | - | - | - | 200:application/json:InstanceAdminSnapshotEnvelope | precise | +| GET | /v1/api-keys | listAPIKeys | Bearer | admin | - | - | - | 200:application/json:APIKeyListEnvelope | precise | +| POST | /v1/api-keys | createAPIKey | Bearer | admin | required | - | application/json:CreateAPIKeyRequest | 201:application/json:APIKeyCreateEnvelope | precise | +| POST | /v1/api-security-scans | uploadAPISecurityScan | Bearer | security:write | required | - | application/json:UploadSecurityScanRequest | 201:application/json:SecurityScanEnvelope | precise | +| POST | /v1/approvals | createApproval | Bearer | release:write | required | - | application/json:CreateApprovalRequest | 201:application/json:ApprovalRecordEnvelope | precise | +| POST | /v1/artifact-signatures | createArtifactSignature | Bearer | evidence:write | required | - | application/json:CreateArtifactSignatureRequest | 201:application/json:ArtifactSignatureEnvelope | precise | +| GET | /v1/artifact-signatures/{id} | getArtifactSignature | Bearer | evidence:read | - | path:id | - | 200:application/json:ArtifactSignatureEnvelope | precise | +| POST | /v1/artifact-signatures/{id}/verify-cosign | verifyCosignSignature | Bearer | verify:read | required | path:id | application/json:VerifyCosignSignatureRequest | 200:application/json:CosignVerificationEnvelope | precise | +| POST | /v1/artifacts | registerArtifact | Bearer | evidence:write | required | - | application/json:RegisterArtifactRequest | 201:application/json:ArtifactEnvelope | precise | +| GET | /v1/artifacts/{id} | getArtifact | Bearer | evidence:read | - | path:id | - | 200:application/json:ArtifactEnvelope | precise | +| GET | /v1/audit-chain/verify | verifyAuditChain | Bearer | verify:read | - | - | - | 200:application/json:VerificationResultEnvelope | precise | +| GET | /v1/audit-log | listAuditLog | Bearer | admin | - | query:limit, query:since, query:subject_id, query:subject_type | - | 200:application/json:AuditChainEntryListEnvelope | precise | +| POST | /v1/backup-manifests | generateBackupManifest | Bearer | admin | required | - | application/json:EmptyObject | 201:application/json:BackupManifestEnvelope | precise | +| GET | /v1/backup-manifests/{id}/verify | verifyBackupManifest | Bearer | verify:read | - | path:id | - | 200:application/json:VerificationResultEnvelope | precise | +| POST | /v1/build-attestations/{id}/verify-signature | verifyBuildAttestationSignature | Bearer | verify:read | required | path:id | application/json:EmptyObject | 200:application/json:VerificationResultEnvelope | precise | +| POST | /v1/builds | createBuild | Bearer | build:write | required | - | application/json:CreateBuildRequest | 201:application/json:BuildRunEnvelope | precise | +| GET | /v1/builds/{id} | getBuild | Bearer | build:read | - | path:id | - | 200:application/json:BuildRunEnvelope | precise | +| POST | /v1/builds/{id}/attestations | uploadBuildAttestation | Bearer | build:write | required | path:id | application/json:DSSEEnvelope | 201:application/json:BuildAttestationEnvelope | precise | +| GET | /v1/collectors | listCollectors | Bearer | collector:read | - | - | - | 200:application/json:CollectorListEnvelope | precise | +| POST | /v1/collectors | createCollector | Bearer | collector:admin | required | - | application/json:CreateCollectorRequest | 201:application/json:CollectorCreateEnvelope | precise | +| POST | /v1/collectors/github/source-snapshots | uploadGitHubSourceSnapshot | Bearer | source:write | required | - | application/json:SourceSnapshotRequest | 201:application/json:SourceSnapshotEnvelope | precise | +| POST | /v1/collectors/gitlab/source-snapshots | uploadGitLabSourceSnapshot | Bearer | source:write | required | - | application/json:SourceSnapshotRequest | 201:application/json:SourceSnapshotEnvelope | precise | +| GET | /v1/collectors/{id}/health | collectorHealthReport | Bearer | collector:read | - | path:id | - | 200:application/json:CollectorHealthReportEnvelope | precise | +| POST | /v1/collectors/{id}/releases | recordCollectorRelease | Bearer | collector:admin | required | path:id | application/json:RecordCollectorReleaseRequest | 201:application/json:CollectorReleaseEnvelope | precise | +| GET | /v1/commercial-collectors | listCommercialCollectors | Bearer | collector:read | - | - | - | 200:application/json:CommercialCollectorDefinitionListEnvelope | precise | +| POST | /v1/commercial-collectors | createCommercialCollector | Bearer | collector:admin | required | - | application/json:CreateCommercialCollectorRequest | 201:application/json:CommercialCollectorDefinitionEnvelope | precise | +| POST | /v1/container-images | registerContainerImage | Bearer | evidence:write | required | - | application/json:RegisterContainerImageRequest | 201:application/json:ContainerImageEnvelope | precise | +| GET | /v1/control-evidence | listControlEvidence | Bearer | controls:read | - | query:control_id, query:product_id, query:release_id | - | 200:application/json:ControlEvidenceListEnvelope | precise | +| GET | /v1/control-framework-template-packs | listControlFrameworkTemplatePacks | Bearer | controls:read | - | - | - | 200:application/json:ControlFrameworkTemplatePackListEnvelope | precise | +| POST | /v1/control-framework-template-packs/{slug}/install | installControlFrameworkTemplatePack | Bearer | controls:admin | required | path:slug | application/json:EmptyObject | 201:application/json:ControlFrameworkEnvelope | precise | +| GET | /v1/control-frameworks | listControlFrameworks | Bearer | controls:read | - | - | - | 200:application/json:ControlFrameworkListEnvelope | precise | +| POST | /v1/control-frameworks | createControlFramework | Bearer | controls:admin | required | - | application/json:CreateControlFrameworkRequest | 201:application/json:ControlFrameworkEnvelope | precise | +| POST | /v1/controls | createSecurityControl | Bearer | controls:admin | required | - | application/json:CreateSecurityControlRequest | 201:application/json:SecurityControlEnvelope | precise | +| GET | /v1/controls/{id} | getSecurityControl | Bearer | controls:read | - | path:id | - | 200:application/json:SecurityControlEnvelope | precise | +| POST | /v1/controls/{id}/evidence | linkControlEvidence | Bearer | controls:write | required | path:id | application/json:LinkControlEvidenceRequest | 201:application/json:ControlEvidenceEnvelope | precise | +| POST | /v1/custom-policies | createCustomPolicy | Bearer | policy:write | required | - | application/json:CreateCustomPolicyRequest | 201:application/json:CustomPolicyEnvelope | precise | +| POST | /v1/custom-policies/{id}/evaluate | evaluateCustomPolicy | Bearer | policy:read | required | path:id | application/json:EvaluatePolicyRequest | 201:application/json:CustomPolicyEvaluationEnvelope | precise | +| POST | /v1/customer-packages | createCustomerPackage | Bearer | package:write | required | - | application/json:CreateCustomerPackageRequest | 201:application/json:CustomerSecurityPackageEnvelope | precise | +| GET | /v1/customer-packages/{id} | getCustomerPackage | Bearer | package:read | - | path:id | - | 200:application/json:CustomerSecurityPackageEnvelope | precise | +| GET | /v1/customer-packages/{id}/download | downloadCustomerPackage | Bearer | package:read | - | path:id | - | 200:application/zip:string/binary | precise | +| GET | /v1/customer-portal/access | listCustomerPortalAccess | Bearer | package:read | - | query:package_id | - | 200:application/json:CustomerPortalAccessListEnvelope | precise | +| POST | /v1/customer-portal/access | createCustomerPortalAccess | Bearer | package:write | required | - | application/json:CreateCustomerPortalAccessRequest | 201:application/json:CustomerPortalAccessCreateEnvelope | precise | +| POST | /v1/customer-portal/access/{id}/revoke | revokeCustomerPortalAccess | Bearer | package:write | required | path:id | application/json:EmptyObject | 200:application/json:CustomerPortalAccessEnvelope | precise | +| POST | /v1/customer-portal/package | accessCustomerPortalPackage | public | - | not required | - | application/json:CustomerPortalPackageRequest | 200:application/json:CustomerSecurityPackageEnvelope | precise | +| POST | /v1/customer-portal/package/download | downloadCustomerPortalPackage | public | - | not required | - | application/json:CustomerPortalPackageRequest | 200:application/zip:string/binary | precise | +| GET | /v1/customer-portal/package/view | customerPortalPackageViewForm | public | - | - | - | - | 200:text/html:string | precise | +| POST | /v1/customer-portal/package/view | customerPortalPackageView | public | - | not required | - | application/x-www-form-urlencoded:CustomerPortalPackageRequest | 200:text/html:string | precise | +| POST | /v1/customer-portal/package/view/download | downloadCustomerPortalPackageView | public | - | not required | - | application/x-www-form-urlencoded:CustomerPortalPackageRequest | 200:application/zip:string/binary | precise | +| GET | /v1/deployments | listDeployments | Bearer | deployment:read | - | query:environment_id, query:release_id | - | 200:application/json:DeploymentEventListEnvelope | precise | +| POST | /v1/deployments | recordDeployment | Bearer | deployment:write | required | - | application/json:RecordDeploymentRequest | 201:application/json:DeploymentEventEnvelope | precise | +| GET | /v1/deployments/{id} | getDeployment | Bearer | deployment:read | - | path:id | - | 200:application/json:DeploymentEventEnvelope | precise | +| POST | /v1/dsse-trust-roots | createDSSETrustRoot | Bearer | keys:admin | required | - | application/json:CreateDSSETrustRootRequest | 201:application/json:DSSETrustRootEnvelope | precise | +| GET | /v1/environments | listDeploymentEnvironments | Bearer | deployment:read | - | query:product_id | - | 200:application/json:DeploymentEnvironmentListEnvelope | precise | +| POST | /v1/environments | createDeploymentEnvironment | Bearer | deployment:write | required | - | application/json:CreateDeploymentEnvironmentRequest | 201:application/json:DeploymentEnvironmentEnvelope | precise | +| GET | /v1/evidence | listEvidence | Bearer | evidence:read | - | query:release_id, query:type | - | 200:application/json:EvidenceItemListEnvelope | precise | +| POST | /v1/evidence | createEvidence | Bearer | evidence:write | required | - | application/json:CreateEvidenceRequest | 201:application/json:EvidenceItemEnvelope | precise | +| POST | /v1/evidence-bundles | exportEvidenceBundle | Bearer | bundle:read | required | - | application/json:ExportEvidenceBundleRequest | 201:application/json:EvidenceBundleEnvelope | precise | +| POST | /v1/evidence-bundles/import | importEvidenceBundle | Bearer | bundle:write | required | - | application/json:EvidenceBundle | 201:application/json:EvidenceBundleImportEnvelope | precise | +| POST | /v1/evidence-graph-snapshots | createGraphSnapshot | Bearer | evidence:read | required | - | application/json:CreateGraphSnapshotRequest | 201:application/json:EvidenceGraphSnapshotEnvelope | precise | +| POST | /v1/evidence-summaries | createEvidenceSummary | Bearer | report:read | required | - | application/json:CreateEvidenceSummaryRequest | 201:application/json:EvidenceSummaryEnvelope | precise | +| GET | /v1/evidence/search | searchEvidence | Bearer | evidence:read | - | query:cursor, query:limit, query:product_id, query:project_id, query:release_id, query:source, query:tag, query:type | - | 200:application/json:EvidenceSearchEnvelope | precise | +| GET | /v1/evidence/{id} | getEvidence | Bearer | evidence:read | - | path:id | - | 200:application/json:EvidenceItemEnvelope | precise | +| GET | /v1/evidence/{id}/lifecycle-events | listEvidenceLifecycleEvents | Bearer | evidence:read | - | path:id | - | 200:application/json:EvidenceLifecycleEventListEnvelope | precise | +| POST | /v1/evidence/{id}/lifecycle-events | recordEvidenceLifecycleEvent | Bearer | evidence:write | required | path:id | application/json:RecordEvidenceLifecycleEventRequest | 201:application/json:EvidenceLifecycleEventEnvelope | precise | +| POST | /v1/evidence/{id}/link | linkEvidence | Bearer | evidence:write | required | path:id | application/json:LinkEvidenceRequest | 201:application/json:EvidenceItemEnvelope | precise | +| POST | /v1/evidence/{id}/supersede | supersedeEvidence | Bearer | evidence:write | required | path:id | application/json:SupersedeEvidenceRequest | 201:application/json:EvidenceItemEnvelope | precise | +| GET | /v1/exceptions | listExceptions | Bearer | verify:read | - | query:release_id | - | 200:application/json:ExceptionListEnvelope | precise | +| POST | /v1/exceptions | createException | Bearer | release:write | required | - | application/json:CreateExceptionRequest | 201:application/json:ExceptionEnvelope | precise | +| POST | /v1/exceptions/{id}/approve | approveException | Bearer | release:write | required | path:id | application/json:EmptyObject | 200:application/json:ExceptionEnvelope | precise | +| GET | /v1/health | health | public | - | - | - | - | 200:application/json:HealthStatusEnvelope | precise | +| POST | /v1/incident-webhooks/{receiver_id} | receiveIncidentWebhook | public | - | not required | header:X-Evydence-Webhook-Event-ID, header:X-Evydence-Webhook-Signature, header:X-Evydence-Webhook-Timestamp, path:receiver_id | application/json:SignedIncidentWebhookPayload | 201:application/json:IncidentWebhookDeliveryEnvelope | precise | +| POST | /v1/incidents | createIncident | Bearer | incident:write | required | - | application/json:CreateIncidentRequest | 201:application/json:IncidentEnvelope | precise | +| POST | /v1/incidents/{id}/timeline | recordIncidentTimeline | Bearer | incident:write | required | path:id | application/json:RecordIncidentTimelineRequest | 201:application/json:IncidentTimelineEventEnvelope | precise | +| POST | /v1/incidents/{id}/webhook-receivers | createIncidentWebhookReceiver | Bearer | incident:write | required | path:id | application/json:CreateIncidentWebhookReceiverRequest | 201:application/json:IncidentWebhookReceiverEnvelope | precise | +| POST | /v1/legal-holds | createLegalHold | Bearer | admin | required | - | application/json:CreateLegalHoldRequest | 201:application/json:LegalHoldEnvelope | precise | +| GET | /v1/marketplace-collectors | listMarketplaceCollectors | Bearer | collector:read | - | - | - | 200:application/json:MarketplaceCollectorListEnvelope | precise | +| POST | /v1/marketplace-collectors | createMarketplaceCollector | Bearer | collector:admin | required | - | application/json:CreateMarketplaceCollectorRequest | 201:application/json:MarketplaceCollectorEnvelope | precise | +| GET | /v1/marketplace-collectors/{id}/health | marketplaceCollectorHealth | Bearer | collector:read | - | path:id | - | 200:application/json:MarketplaceCollectorHealthReportEnvelope | precise | +| POST | /v1/merkle-batches | createMerkleBatch | Bearer | keys:admin | required | - | application/json:CreateMerkleBatchRequest | 201:application/json:MerkleBatchEnvelope | precise | +| GET | /v1/merkle-batches/{id}/verify | verifyMerkleBatch | Bearer | verify:read | - | path:id | - | 200:application/json:VerificationResultEnvelope | precise | +| GET | /v1/metrics | metrics | Bearer | admin | - | - | - | 200:application/json:MetricsSnapshotEnvelope,text/plain:string | precise | +| POST | /v1/object-retention-policies | createObjectRetentionPolicy | Bearer | admin | required | - | application/json:CreateObjectRetentionPolicyRequest | 201:application/json:ObjectRetentionPolicyEnvelope | precise | +| POST | /v1/object-retention-policies/{id}/verify | verifyObjectRetentionPolicy | Bearer | verify:read | required | path:id | application/json:EmptyObject | 200:application/json:ObjectRetentionPolicyEnvelope | precise | +| POST | /v1/openapi-contracts | uploadOpenAPIContract | Bearer | evidence:write | required | - | application/json:UploadOpenAPIContractRequest | 201:application/json:OpenAPIContractEnvelope | precise | +| GET | /v1/openapi-contracts/{id} | getOpenAPIContract | Bearer | evidence:read | - | path:id | - | 200:application/json:OpenAPIContractEnvelope | precise | +| POST | /v1/openapi-diffs | createOpenAPIDiff | Bearer | evidence:read | required | - | application/json:CreateOpenAPIDiffRequest | 201:application/json:ContractDiffEnvelope | precise | +| GET | /v1/openapi.json | openapi | public | - | - | - | - | 200:application/json:OpenAPIDocument | precise | +| POST | /v1/organizations | createOrganization | Bearer | identity:admin | required | - | application/json:CreateOrganizationRequest | 201:application/json:OrganizationEnvelope | precise | +| POST | /v1/policies/evaluate | evaluatePolicy | Bearer | verify:read | required | - | application/json:EvaluatePolicyRequest | 201:application/json:PolicyEvaluationEnvelope | precise | +| GET | /v1/products | listProducts | Bearer | product:read | - | - | - | 200:application/json:ProductListEnvelope | precise | +| POST | /v1/products | createProduct | Bearer | product:write | required | - | application/json:CreateProductRequest | 201:application/json:ProductEnvelope | precise | +| GET | /v1/products/{id} | getProduct | Bearer | product:read | - | path:id | - | 200:application/json:ProductEnvelope | precise | +| POST | /v1/projects | createProject | Bearer | project:write | required | - | application/json:CreateProjectRequest | 201:application/json:ProjectEnvelope | precise | +| GET | /v1/projects/{id} | getProject | Bearer | project:read | - | path:id | - | 200:application/json:ProjectEnvelope | precise | +| POST | /v1/provider-verifications | verifyProviderIdentity | Bearer | identity:admin | required | - | application/json:VerifyProviderIdentityRequest | 201:application/json:ProviderVerificationEnvelope | precise | +| POST | /v1/public-transparency-log-entries | publishPublicTransparencyLogEntry | Bearer | keys:admin | required | - | application/json:PublishPublicTransparencyLogEntryRequest | 201:application/json:PublicTransparencyLogEntryEnvelope | precise | +| POST | /v1/public-transparency-log-entries/{id}/fetch-proof | fetchPublicTransparencyLogEntryProof | Bearer | keys:admin | required | path:id | application/json:EmptyObject | 200:application/json:PublicTransparencyLogEntryEnvelope | precise | +| POST | /v1/public-transparency-log-entries/{id}/verify | verifyPublicTransparencyLogEntry | Bearer | keys:admin | required | path:id | application/json:VerifyPublicTransparencyLogEntryRequest | 200:application/json:PublicTransparencyLogEntryEnvelope | precise | +| POST | /v1/public-transparency-logs | createPublicTransparencyLog | Bearer | keys:admin | required | - | application/json:CreatePublicTransparencyLogRequest | 201:application/json:PublicTransparencyLogEnvelope | precise | +| GET | /v1/questionnaire-answer-library | listQuestionnaireAnswerLibrary | Bearer | package:read | - | query:product_id, query:question_id, query:release_id | - | 200:application/json:QuestionnaireAnswerLibraryEntryListEnvelope | precise | +| POST | /v1/questionnaire-answer-library | createQuestionnaireAnswerLibraryEntry | Bearer | package:write | required | - | application/json:CreateQuestionnaireAnswerLibraryEntryRequest | 201:application/json:QuestionnaireAnswerLibraryEntryEnvelope | precise | +| POST | /v1/questionnaire-drafts | createQuestionnaireDraft | Bearer | package:read | required | - | application/json:CreateQuestionnaireDraftRequest | 201:application/json:QuestionnaireDraftEnvelope | precise | +| POST | /v1/questionnaire-packages | createQuestionnairePackage | Bearer | package:write | required | - | application/json:CreateQuestionnairePackageRequest | 201:application/json:QuestionnairePackageEnvelope | precise | +| POST | /v1/questionnaire-templates | createQuestionnaireTemplate | Bearer | package:write | required | - | application/json:CreateQuestionnaireTemplateRequest | 201:application/json:QuestionnaireTemplateEnvelope | precise | +| GET | /v1/ready | ready | public | - | - | - | - | 200:application/json:ReadinessStatusEnvelope | precise | +| POST | /v1/redaction-profiles | createRedactionProfile | Bearer | package:write | required | - | application/json:CreateRedactionProfileRequest | 201:application/json:RedactionProfileEnvelope | precise | +| POST | /v1/release-bundles | createReleaseBundle | Bearer | bundle:write | required | - | application/json:CreateReleaseBundleRequest | 201:application/json:ReleaseBundleEnvelope | precise | +| GET | /v1/release-bundles/{id} | getReleaseBundle | Bearer | bundle:read | - | path:id | - | 200:application/json:ReleaseBundleEnvelope | precise | +| GET | /v1/release-bundles/{id}/manifest | getReleaseBundleManifest | Bearer | bundle:read | - | path:id | - | 200:application/json:ReleaseBundleManifestEnvelope | precise | +| GET | /v1/release-bundles/{id}/verify | verifyReleaseBundle | Bearer | verify:read | - | path:id | - | 200:application/json:VerificationResultEnvelope | precise | +| GET | /v1/release-candidates | listReleaseCandidates | Bearer | release:read | - | query:release_id | - | 200:application/json:ReleaseCandidateListEnvelope | precise | +| POST | /v1/release-candidates | createReleaseCandidate | Bearer | release:write | required | - | application/json:CreateReleaseCandidateRequest | 201:application/json:ReleaseCandidateEnvelope | precise | +| GET | /v1/release-candidates/{id} | getReleaseCandidate | Bearer | release:read | - | path:id | - | 200:application/json:ReleaseCandidateEnvelope | precise | +| POST | /v1/release-candidates/{id}/promote | promoteReleaseCandidate | Bearer | release:write | required | path:id | application/json:ReleaseCandidateTransitionRequest | 200:application/json:ReleaseCandidateEnvelope | precise | +| POST | /v1/release-candidates/{id}/reject | rejectReleaseCandidate | Bearer | release:write | required | path:id | application/json:ReleaseCandidateTransitionRequest | 200:application/json:ReleaseCandidateEnvelope | precise | +| POST | /v1/releases | createRelease | Bearer | release:write | required | - | application/json:CreateReleaseRequest | 201:application/json:ReleaseEnvelope | precise | +| GET | /v1/releases/{id} | getRelease | Bearer | release:read | - | path:id | - | 200:application/json:ReleaseEnvelope | precise | +| POST | /v1/releases/{id}/approve | approveRelease | Bearer | release:write | required | path:id | application/json:EmptyObject | 200:application/json:ReleaseEnvelope | precise | +| POST | /v1/releases/{id}/evidence-flow/start | startReleaseEvidenceFlow | Bearer | release:read | required | path:id | - | 200:application/json:ReleaseEvidenceFlowEnvelope | precise | +| POST | /v1/releases/{id}/freeze | freezeRelease | Bearer | release:write | required | path:id | application/json:EmptyObject | 200:application/json:ReleaseEnvelope | precise | +| GET | /v1/releases/{id}/security-summary | releaseSecuritySummary | Bearer | report:read | - | path:id | - | 200:application/json:ReleaseSecuritySummaryEnvelope | precise | +| POST | /v1/remediation-tasks | createRemediationTask | Bearer | incident:write | required | - | application/json:CreateRemediationTaskRequest | 201:application/json:RemediationTaskEnvelope | precise | +| POST | /v1/report-templates | createReportTemplate | Bearer | report:read | required | - | application/json:CreateReportTemplateRequest | 201:application/json:CustomReportTemplateEnvelope | precise | +| POST | /v1/report-templates/{id}/render | renderReportTemplate | Bearer | report:read | required | path:id | application/json:RenderReportTemplateRequest | 201:application/json:RenderedCustomReportEnvelope | precise | +| POST | /v1/reports/anomaly | generateAnomalyReport | Bearer | report:read | required | - | application/json:CreateAnomalyReportRequest | 201:application/json:AnomalyReportEnvelope | precise | +| GET | /v1/reports/control-coverage | controlCoverageReport | Bearer | report:read | - | query:framework_id, query:product_id, query:release_id | - | 200:application/json:ReadinessReportEnvelope | precise | +| GET | /v1/reports/cra-readiness | craReadinessReport | Bearer | report:read | - | query:product_id, query:release_id | - | 200:application/json:ReadinessReportEnvelope | precise | +| GET | /v1/reports/cra-readiness-html | craReadinessHTMLPackage | Bearer | report:read | - | query:product_id, query:release_id | - | 200:application/json:HTMLReportPackageEnvelope | precise | +| GET | /v1/reports/cra-vulnerability-handling | craVulnerabilityHandlingReport | Bearer | report:read | - | query:product_id, query:release_id | - | 200:application/json:CRAVulnerabilityHandlingReportEnvelope | precise | +| GET | /v1/reports/custody-review | signingCustodyReviewReport | Bearer | keys:admin | - | - | - | 200:application/json:SigningCustodyReviewReportEnvelope | precise | +| GET | /v1/reports/incident-package | incidentReport | Bearer | incident:read | - | query:incident_id | - | 200:application/json:IncidentReportEnvelope | precise | +| GET | /v1/reports/missing-evidence | missingEvidenceReport | Bearer | verify:read | - | query:release_id | - | 200:application/json:MissingEvidenceReportEnvelope | precise | +| POST | /v1/reports/pdf | createPDFReportPackage | Bearer | report:read | required | - | application/json:CreatePDFReportPackageRequest | 201:application/json:PDFReportPackageEnvelope | precise | +| GET | /v1/reports/release-readiness | releaseReadinessReport | Bearer | verify:read | - | query:release_id | - | 200:application/json:ReadinessReportEnvelope | precise | +| GET | /v1/reports/retention | retentionReport | Bearer | admin | - | query:scope_id, query:scope_type | - | 200:application/json:RetentionReportEnvelope | precise | +| GET | /v1/reports/security-review-package | securityReviewPackageReport | Bearer | package:read | - | query:package_id | - | 200:application/json:SecurityReviewPackageReportEnvelope | precise | +| GET | /v1/reports/security-update-evidence | securityUpdateEvidenceReport | Bearer | report:read | - | query:product_id, query:release_id | - | 200:application/json:SecurityUpdateEvidenceReportEnvelope | precise | +| GET | /v1/reports/vulnerability-decision-summary | vulnerabilityDecisionSummaryReport | Bearer | report:read | - | query:release_id | - | 200:application/json:VulnerabilityDecisionSummaryReportEnvelope | precise | +| GET | /v1/reports/vulnerability-posture | vulnerabilityPostureReport | Bearer | security:read | - | query:release_id | - | 200:application/json:VulnerabilityPostureReportEnvelope | precise | +| POST | /v1/retention-overrides | createRetentionOverride | Bearer | admin | required | - | application/json:CreateRetentionOverrideRequest | 201:application/json:RetentionOverrideEnvelope | precise | +| GET | /v1/role-bindings | listRoleBindings | Bearer | identity:admin | - | - | - | 200:application/json:RoleBindingListEnvelope | precise | +| POST | /v1/role-bindings | createRoleBinding | Bearer | identity:admin | required | - | application/json:CreateRoleBindingRequest | 201:application/json:RoleBindingEnvelope | precise | +| POST | /v1/saas/profiles | createSaaSEditionProfile | Bearer | instance:admin | required | - | application/json:CreateSaaSEditionProfileRequest | 201:application/json:SaaSEditionProfileEnvelope | precise | +| GET | /v1/sbom-components | listSBOMComponents | Bearer | evidence:read | - | query:artifact_id, query:limit, query:purl, query:query, query:release_id, query:sbom_id | - | 200:application/json:SBOMComponentRecordListEnvelope | precise | +| POST | /v1/sbom-diffs | createSBOMDiff | Bearer | evidence:read | required | - | application/json:CreateSBOMDiffRequest | 201:application/json:SBOMDiffEnvelope | precise | +| POST | /v1/sboms | uploadSBOM | Bearer | evidence:write | required | - | application/json:EvidenceUploadRequest | 201:application/json:SBOMEnvelope | precise | +| POST | /v1/sboms/spdx | uploadSPDXSBOM | Bearer | evidence:write | required | - | application/json:UploadSPDXSBOMRequest | 201:application/json:SBOMEnvelope | precise | +| GET | /v1/sboms/{id} | getSBOM | Bearer | evidence:read | - | path:id | - | 200:application/json:SBOMEnvelope | precise | +| POST | /v1/security-documents | uploadManualSecurityDocument | Bearer | security:write | required | - | application/json:UploadManualSecurityDocumentRequest | 201:application/json:ManualSecurityDocumentEnvelope | precise | +| POST | /v1/security-scans | uploadSecurityScan | Bearer | security:write | required | - | application/json:UploadSecurityScanRequest | 201:application/json:SecurityScanEnvelope | precise | +| GET | /v1/signing-keys | listSigningKeys | Bearer | verify:read | - | - | - | 200:application/json:SigningKeyListEnvelope | precise | +| POST | /v1/signing-keys/rotate | rotateSigningKey | Bearer | keys:admin | required | - | application/json:SigningKeyTransitionRequest | 201:application/json:SigningKeyEnvelope | precise | +| POST | /v1/signing-keys/{id}/revoke | revokeSigningKey | Bearer | keys:admin | required | path:id | application/json:SigningKeyTransitionRequest | 200:application/json:SigningKeyEnvelope | precise | +| POST | /v1/signing-operations | createSigningOperation | Bearer | keys:admin | required | - | application/json:CreateSigningOperationRequest | 201:application/json:SigningOperationEnvelope | precise | +| POST | /v1/signing-providers | createSigningProvider | Bearer | keys:admin | required | - | application/json:CreateSigningProviderRequest | 201:application/json:SigningProviderEnvelope | precise | +| POST | /v1/source/branches | upsertSourceBranch | Bearer | source:write | required | - | application/json:UpsertSourceBranchRequest | 201:application/json:SourceBranchEnvelope | precise | +| POST | /v1/source/commits | recordSourceCommit | Bearer | source:write | required | - | application/json:RecordSourceCommitRequest | 201:application/json:SourceCommitEnvelope | precise | +| POST | /v1/source/pull-requests | recordPullRequest | Bearer | source:write | required | - | application/json:RecordPullRequestRequest | 201:application/json:PullRequestEnvelope | precise | +| GET | /v1/source/repositories | listSourceRepositories | Bearer | source:read | - | query:project_id | - | 200:application/json:SourceRepositoryListEnvelope | precise | +| POST | /v1/source/repositories | createSourceRepository | Bearer | source:write | required | - | application/json:CreateSourceRepositoryRequest | 201:application/json:SourceRepositoryEnvelope | precise | +| POST | /v1/sso/identity-links | linkSSOIdentity | Bearer | identity:admin | required | - | application/json:LinkSSOIdentityRequest | 201:application/json:UserIdentityLinkEnvelope | precise | +| POST | /v1/sso/logout | logoutSSOSession | Bearer | - | required | - | application/json:EmptyObject | 200:application/json:SSOSessionEnvelope | precise | +| POST | /v1/sso/providers | createSSOProvider | Bearer | identity:admin | required | - | application/json:CreateSSOProviderRequest | 201:application/json:SSOProviderEnvelope | precise | +| POST | /v1/sso/providers/{id}/discover-oidc | refreshSSOProviderOIDCTrustMaterial | Bearer | identity:admin | required | path:id | application/json:EmptyObject | 200:application/json:SSOProviderEnvelope | precise | +| POST | /v1/sso/providers/{id}/trust-material | updateSSOProviderTrustMaterial | Bearer | identity:admin | required | path:id | application/json:UpdateSSOProviderTrustMaterialRequest | 200:application/json:SSOProviderEnvelope | precise | +| POST | /v1/sso/session-exchanges | exchangeSSOCredential | public | - | not required | - | application/json:ExchangeSSOCredentialRequest | 201:application/json:SSOCredentialExchangeEnvelope | precise | +| POST | /v1/sso/sessions | createSSOSession | Bearer | identity:admin | required | - | application/json:CreateSSOSessionRequest | 201:application/json:SSOSessionCreateEnvelope | precise | +| POST | /v1/sso/sessions/{id}/revoke | revokeSSOSession | Bearer | identity:admin | required | path:id | application/json:EmptyObject | 200:application/json:SSOSessionEnvelope | precise | +| POST | /v1/transparency-checkpoints | createTransparencyCheckpoint | Bearer | keys:admin | required | - | application/json:CreateTransparencyCheckpointRequest | 201:application/json:TransparencyCheckpointEnvelope | precise | +| POST | /v1/users | createUser | Bearer | identity:admin | required | - | application/json:CreateUserRequest | 201:application/json:HumanUserEnvelope | precise | +| POST | /v1/users/{id}/deactivate | deactivateUser | Bearer | identity:admin | required | path:id | application/json:EmptyObject | 200:application/json:HumanUserEnvelope | precise | +| POST | /v1/verify | verify | Bearer | verify:read | required | - | application/json:VerifySubjectRequest | 200:application/json:VerificationResultEnvelope | precise | +| GET | /v1/version | version | public | - | - | - | - | 200:application/json:VersionInfoEnvelope | precise | +| POST | /v1/vex | uploadVEX | Bearer | evidence:write | required | - | application/json:EvidenceUploadRequest | 201:application/json:VEXDocumentEnvelope | precise | +| POST | /v1/vex/cyclonedx | uploadCycloneDXVEX | Bearer | evidence:write | required | - | application/json:EvidenceUploadRequest | 201:application/json:VEXDocumentEnvelope | precise | +| POST | /v1/vex/cyclonedx/preview | previewCycloneDXVEXImport | Bearer | evidence:read | not required | - | application/json:EvidenceUploadRequest | 200:application/json:VEXImportPreviewEnvelope | precise | +| POST | /v1/vex/preview | previewVEXImport | Bearer | evidence:read | not required | - | application/json:EvidenceUploadRequest | 200:application/json:VEXImportPreviewEnvelope | precise | +| GET | /v1/vex/{id} | getVEX | Bearer | evidence:read | - | path:id | - | 200:application/json:VEXDocumentEnvelope | precise | +| GET | /v1/vex/{id}/import-report | getVEXImportReport | Bearer | evidence:read | - | path:id | - | 200:application/json:VEXImportReportEnvelope | precise | +| GET | /v1/vulnerability-decisions | listVulnerabilityDecisions | Bearer | evidence:read | - | query:active, query:component, query:product_id, query:release_id, query:status, query:vulnerability | - | 200:application/json:VulnerabilityDecisionListEnvelope | precise | +| POST | /v1/vulnerability-findings/{id}/decisions | createVulnerabilityDecision | Bearer | evidence:write | required | path:id | application/json:CreateVulnerabilityDecisionRequest | 201:application/json:VulnerabilityDecisionEnvelope | precise | +| POST | /v1/vulnerability-findings/{id}/workflow | recordVulnerabilityWorkflow | Bearer | security:write | required | path:id | application/json:RecordVulnerabilityWorkflowRequest | 201:application/json:VulnerabilityWorkflowRecordEnvelope | precise | +| POST | /v1/vulnerability-scans | uploadVulnerabilityScan | Bearer | evidence:write | required | - | application/json:UploadVulnerabilityScanRequest | 201:application/json:VulnerabilityScanEnvelope | precise | +| GET | /v1/vulnerability-scans/{id} | getVulnerabilityScan | Bearer | evidence:read | - | path:id | - | 200:application/json:VulnerabilityScanEnvelope | precise | +| POST | /v1/waivers | createWaiver | Bearer | policy:write | required | - | application/json:CreateWaiverRequest | 201:application/json:WaiverEnvelope | precise | +| POST | /v1/waivers/{id}/approve | approveWaiver | Bearer | policy:write | required | path:id | application/json:EmptyObject | 200:application/json:WaiverEnvelope | precise | + diff --git a/docs/reference/benchmark-results.md b/docs/reference/benchmark-results.md new file mode 100644 index 0000000..aad4766 --- /dev/null +++ b/docs/reference/benchmark-results.md @@ -0,0 +1,98 @@ +# Benchmark Results + +This reference records lightweight local benchmark evidence for the supported +single-writer profile. It is not a broad throughput claim and should not be used +as capacity planning without target-environment tests. + +## Benchmark Command + +```sh +go test ./internal/app -bench BenchmarkReleaseEvidenceIngestion -benchtime=100x -run '^$' +``` + +The project-owned gate is: + +```sh +make benchmark-check +``` + +`make benchmark-check` always runs the app-layer benchmark. When +`EVYDENCE_TEST_DATABASE_URL` is set, it also runs: + +```sh +scripts/production_benchmark_check.sh +``` + +The production-like benchmark writes regression-friendly output to: + +```text +tmp/production-benchmark/production-benchmark-summary.json +tmp/production-benchmark/production-benchmark-summary.txt +``` + +The JSON summary uses schema +`evydence-production-benchmark.v1.0.0`. `make production-check` runs this gate +before release-candidate packaging evidence is accepted, and production-check +requires `EVYDENCE_TEST_DATABASE_URL`. + +## Current Interpretation + +The benchmark exercises in-process release evidence creation through the app +service and audit-chain path. + +Use it to detect obvious local regressions in the core evidence path. For +production sizing, run API-level tests against the target PostgreSQL and object +store and record: + +- Evydence commit/tag; +- database and object-store versions; +- payload sizes; +- API writer count; +- worker count; +- request rate; +- outbox backlog age; +- failed verification or parser checks; +- assumptions and limitations. + +The production-like benchmark is based on +`examples/end-to-end-release-evidence/run-local-demo.sh`. It starts the API and +worker against a disposable PostgreSQL schema and filesystem object store, then +exercises HTTP API calls, persistence, API restart, release-readiness reporting, +customer package creation, and audit-chain verification. It records total +scenario wall-clock time, an estimated operation rate for the checked scenario, +demo JSON output count, object file count, object bytes, and explicit +limitations. + +This is still a local regression benchmark. It does not prove S3/MinIO network +latency, live KMS/HSM signing latency, identity-provider latency, registry +behavior, public transparency provider behavior, multi-writer API safety, or +target-environment capacity. + +## Latest Local Result + +Last recorded local run: + +```text +goos: linux +goarch: amd64 +pkg: github.com/aatuh/evydence/internal/app +cpu: AMD Ryzen 7 3700X 8-Core Processor +BenchmarkReleaseEvidenceIngestion-16 100 1392755 ns/op 499973 B/op 2543 allocs/op +``` + +Interpretation: this is a narrow app-layer benchmark for regression tracking. +It does not prove HTTP throughput, PostgreSQL throughput, object-store +performance, signing-provider latency, parser throughput, or multi-writer API +safety. + +Last recorded production-like benchmark evidence should be read from +`tmp/production-benchmark/production-benchmark-summary.json` after running +`EVYDENCE_TEST_DATABASE_URL=... make benchmark-check`. Commit or attach that +generated JSON only as release evidence for the exact environment that produced +it; do not treat the local result as a general throughput claim. + +## Current Known Limit + +The supported production profile remains one API writer replica. Worker replicas +may scale through PostgreSQL outbox locking, but this benchmark does not prove +multi-writer API safety. diff --git a/docs/reference/capability-map.md b/docs/reference/capability-map.md new file mode 100644 index 0000000..505e39f --- /dev/null +++ b/docs/reference/capability-map.md @@ -0,0 +1,121 @@ +# Capability Map + +This reference keeps the broad implementation inventory out of the README +first screen while preserving a discoverable map for operators, reviewers, and +contributors. + +The map describes repository capabilities that are backed by source code, +OpenAPI, tests, deployment files, examples, or checked documentation. It is not +legal compliance proof, certification, complete SBOM proof, authoritative +vulnerability coverage, or a secure-release guarantee. + +## API And Contracts + +- HTTP API under `/v1` using `github.com/aatuh/api-toolkit/v3` route contracts, + OpenAPI generation, response helpers, and Problem Details. +- Generated OpenAPI contract committed at `openapi.yaml` and served at + `/v1/openapi.json`. +- Request idempotency for create/action endpoints and tenant-scoped Problem + Details responses. + +## Identity And Tenant Boundaries + +- Multi-tenant scoped API keys with one-time secret output, HMAC-SHA256 + storage, and server-side scope checks. +- Organizations, users, role bindings, admin-managed SSO provider/session + records, collector keys, and customer portal package tokens. +- Instance diagnostics require explicit `instance:admin` scope. + +## Release Evidence Core + +- Products, projects, releases, release candidates, artifacts, container + images, artifact signatures, SBOM and VEX upload, vulnerability scans, + vulnerability decisions, exceptions, waivers, approvals, policies, release + bundles, evidence bundles, and customer packages. +- Immutable or append-only behavior for evidence core fields, release bundles, + approvals, exceptions, audit entries, chain entries, and related transition + records. +- Release-readiness, vulnerability-decision-summary, missing-evidence, + security-summary, package, retention, and backup-manifest reports with + assumptions and limitations. + +## Extended Evidence Families + +- Evidence search, evidence lifecycle events, controls, CRA/control coverage, + source records, deployment events, incidents, remediation tasks, audit log + querying, object retention records, transparency records, backup manifests, + and customer package access records. +- Control-coverage, CRA-readiness, vulnerability-posture, incident-package, + security-review-package, evidence-summary, questionnaire-draft, + graph-snapshot, PDF-package, anomaly, retention, and backup reports. +- Customer package manifests and static report exports are redaction-aware and + exclude raw payload bytes, bearer tokens, private keys, API key hashes, SSO or + session token hashes, and internal notes by default. + +## Persistence, Object Storage, And Workers + +- In-process store for local demos and unit-test execution when + `EVYDENCE_DATABASE_URL` is unset. +- PostgreSQL-backed durable ledger state, tenant-scoped relational projections, + migrations, and persisted outbox jobs when `EVYDENCE_DATABASE_URL` is set. +- Production API and worker processes default to relational-only PostgreSQL + loads and skip compatibility snapshot writes; the compatibility snapshot + remains for migration, recovery, and local workflows. +- Filesystem or S3/MinIO-compatible object storage for raw upload payload bytes + under tenant-prefixed paths. +- Polling `cmd/evydence-worker` process that claims persisted outbox jobs with + PostgreSQL row locking and records retry or terminal status. +- Optional worker-owned parser side effects through + `EVYDENCE_WORKER_OWNED_PARSER_SIDE_EFFECTS=true`, including VEX-derived + vulnerability decisions created idempotently by the `parse_vex` worker. + +## Tooling, Deployment, And Examples + +- `cmd/evydence` helper for hashing, one-shot release evidence upload, + manifest verification, GitHub Actions build provenance upload, release + artifact manifest signing/verification, bulk upload manifests, and air-gapped + evidence bundle import. +- Docker Compose dependencies for PostgreSQL and MinIO. +- Production-like Docker Compose rehearsal with API, worker, migrations, + PostgreSQL, and MinIO. +- Kubernetes Helm chart under `deploy/helm/evydence`. +- Air-gapped package manifest under `deploy/airgap/manifest.yaml`. +- Lightweight Go, TypeScript, and Python SDK wrappers. +- GitHub Actions and GitLab CI workflow examples. +- Documentation portal under `docs/`. +- AGPL license, commercial licensing, governance, contribution, security, + support, code of conduct, trademark, release-evidence, and changelog + metadata. + +## Implemented-But-Partial Areas + +- Signing-provider operation receipts can use an HTTPS signing gateway, built-in + AWS KMS, GCP Cloud KMS, or Azure Key Vault executors. +- `pkcs11-hsm` remains gateway-backed because native HSM modules are + deployment-specific. +- `native_pkcs11_hsm` provider records and custody-review reports capture + operator-supplied HSM profile evidence without loading modules or proving + custody. +- SSO credential exchange uses configured local OIDC/SAML trust material and + session-scoped OIDC group-role mappings. +- Provider verification can optionally call OIDC UserInfo or an + operator-controlled provider validation gateway when a caller supplies an + access token. The gateway receives only non-secret metadata and does not + replace direct provider-specific management API clients or external group + synchronization. +- Public transparency records can verify operator-supplied proof material, + fetch from configured endpoints, or use an operator-controlled transparency + proof gateway without replacing provider-specific trust review. +- Production API deployments remain intentionally single-writer until + multi-writer API concurrency is reviewed across every write family. Worker + replicas may scale through PostgreSQL outbox locking. +- [Persistence decomposition inventory](persistence-decomposition.md) tracks + focused mutation paths, remaining broad relational-state call sites, and the + next repository split order. + +## Where To Go Next + +- Start with [Evaluate Evydence in 10 minutes](../tutorials/evaluate-in-10-minutes.md). +- Run the local API path in [Getting started](../tutorials/getting-started.md). +- Review production limits in [Production readiness](production-readiness.md). +- Inspect public release artifacts in [Release evidence index](release-evidence-index.md). diff --git a/docs/reference/capacity-and-failures.md b/docs/reference/capacity-and-failures.md new file mode 100644 index 0000000..555c2d8 --- /dev/null +++ b/docs/reference/capacity-and-failures.md @@ -0,0 +1,93 @@ +# Capacity And Failure Modes + +This reference gives starting guidance for the controlled self-hosted +production candidate profile. It is not a benchmark claim. + +## Supported Concurrency Profile + +- API writer replicas: `1`. +- Worker replicas: may scale horizontally when PostgreSQL outbox locking is + enabled. +- Single API writer mode is an intentional supported profile for controlled + self-hosted deployments with reviewed recovery procedures and worker scaling. +- API multi-writer HA remains unsupported until a reviewed concurrency design + replaces or extends the current single-writer stance. It is a blocker for any + future broad production or hosted SaaS claim, not for the current controlled + self-hosted production candidate profile. + +## Single-Writer Decision + +Evydence keeps one API writer as the supported long-term profile for the +current release line. This is acceptable for small internal deployments and +design-partner pilots when the operator has: + +- a supervised API process managed by systemd, Kubernetes, or equivalent; +- a working PostgreSQL writer lease and startup refusal for extra writers; +- worker replicas scaled separately for parser, signing, and report backlog; +- backups and restore rehearsals for PostgreSQL and object storage; +- documented maintenance windows for API upgrades and migrations; +- monitoring on readiness, outbox backlog, database connectivity, and object + storage failures. + +The multi-writer backlog remains technical hardening work: finish focused +repository decomposition, add optimistic concurrency or transaction-scoped +resource locks where needed, test concurrent writes per resource family, and +re-run the production exit review before changing the supported HA claim. +The detailed decision record is [HA strategy](ha-strategy.md). + +## Practical Sizing Inputs + +Operators should measure these values in their own environment: + +- average and maximum uploaded payload size; +- object-store latency and error rate; +- PostgreSQL connection pool usage; +- outbox backlog age and retry counts; +- release bundle generation time; +- report generation time; +- customer package export size; +- audit-chain verification time for the largest tenant. + +## Starting Configuration + +- Keep API writer replicas at one. +- Set worker replicas based on outbox backlog and parser/signing/report load. +- Use external PostgreSQL with backups and tested restore. +- Use S3/MinIO-compatible object storage for shared deployments. +- Keep reverse-proxy request body limits aligned with Evydence upload limits. +- Monitor `/v1/ready`, admin metrics, outbox backlog, failed jobs, database + connection errors, and object-store errors. + +## Failure Modes + +| Failure | Expected Behavior | Operator Action | +| --- | --- | --- | +| Second API writer starts | Production startup fails when writer mode or PostgreSQL advisory lease rejects it. | Keep one API writer replica and investigate deployment drift. | +| Worker crashes | Persisted outbox jobs remain claimable by another worker after retry/backoff. | Check worker logs and outbox metrics; restart worker after fixing configuration. | +| Object payload missing | Parser, verification, or package generation fails safely. | Restore object payload from backup and re-run verification. | +| Object digest mismatch | Payload is not trusted and verification fails. | Investigate database/object backup pairing before retrying. | +| PostgreSQL unavailable | API/worker startup or operations fail rather than silently using in-process state in production. | Restore database service and verify migrations/readiness. | +| Signing provider unavailable | Signing operation records failure without logging raw payload bytes or secrets. | Restore provider, rotate credentials if needed, and retry through an append-only operation. | +| Provider validation unavailable | Provider verification records failed/limited checks. | Retry after provider/gateway recovery; do not treat metadata capture as provider truth. | + +## Benchmark Policy + +Do not publish broad throughput or scale claims without recording: + +- Evydence commit/tag and release manifest; +- database and object-store versions; +- machine sizes and network shape; +- payload sizes and evidence mix; +- API and worker replica counts; +- pass/fail criteria and limitations. + +For a repo-owned local regression benchmark that includes HTTP, PostgreSQL, +filesystem object storage, worker startup, release-readiness reporting, package +creation, and audit-chain verification, run: + +```sh +EVYDENCE_TEST_DATABASE_URL=... make benchmark-check +``` + +The output under `tmp/production-benchmark/` is benchmark evidence for that +disposable environment only. diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md new file mode 100644 index 0000000..072b937 --- /dev/null +++ b/docs/reference/configuration.md @@ -0,0 +1,218 @@ +# Configuration Reference + +This is the canonical reference for current environment files and runtime variables. + +## Environment Files + +| File | Used By | Purpose | Commit Real Values? | +|------|---------|---------|---------------------| +| `.env.example` | `docker-compose.yml` | Local PostgreSQL and MinIO container credentials. | No | +| `.api.env.example` | API, worker, migration command | Local API runtime settings, durable database URL, object storage mode, bootstrap tenant, and local secret printing. | No | +| `.test.env.example` | Make targets for live PostgreSQL tests | `EVYDENCE_TEST_DATABASE_URL` and test-only API key pepper. | No | +| `.production.env.example` | Operators translating config into deployment secrets | Production-mode variable checklist with empty secret fields, external object storage, single-writer API settings, rate limiting, signing profiles, and telemetry/diagnostic notes. | No | + +Copy examples to local untracked files when needed: + +```sh +cp .api.env.example .api.env +cp .test.env.example .test.env +``` + +The example secrets are placeholders. Replace them before using shared or production-like infrastructure. + +For production planning, read `.production.env.example` as a checklist. It is +not intended to be committed after filling values, and the empty required +secret fields are deliberate so production startup fails until an operator +supplies real values through a secret manager, Kubernetes Secret, sealed-secret +process, or equivalent deployment control. + +## Runtime Variables + +| Variable | Required | Default / Example | Notes | +|----------|----------|-------------------|-------| +| `ENV` | Production only | unset locally | Set `ENV=production` to enable production-safety checks. | +| `EVYDENCE_ADDR` | No | `:8080` | API bind address. | +| `EVYDENCE_API_KEY_PEPPER` | Production yes | `change-me-long-random-pepper` | HMAC pepper for API key, session, and portal-token hashes. Use a long random value. | +| `EVYDENCE_DATABASE_URL` | Production yes | `postgres://evydence:change-me@localhost:5432/evydence?sslmode=disable` | Enables PostgreSQL durable state, projections, migrations, and persisted outbox jobs. If unset, the API uses in-process state. | +| `EVYDENCE_POSTGRES_LOAD_MODE` | No | `snapshot_preferred` locally, `relational_only` when `ENV=production` | PostgreSQL state load mode. Supported values are `snapshot_preferred`, `relational_preferred`, and `relational_only`. Production defaults to relational-only startup reads, refuses snapshot fallback modes, and disables compatibility snapshot writes; snapshots remain available for local compatibility and non-production migration checks. | +| `EVYDENCE_API_WRITER_MODE` | No | `single` | API writer concurrency mode. Production supports only `single` or `single-writer` until multi-writer concurrency controls are implemented. | +| `EVYDENCE_API_WRITER_REPLICAS` | No | unset, chart sets `1` | Optional self-declared API writer replica count used by startup safety checks. Production rejects values other than `1`. | +| `EVYDENCE_OBJECT_STORE` | No | `filesystem` | Supported values are `filesystem`, `s3`, and `minio`. | +| `EVYDENCE_OBJECT_DIR` | Filesystem object store | `./tmp/objects` | Local raw payload storage root. | +| `EVYDENCE_S3_ENDPOINT` | S3/MinIO object store | `localhost:9000` | Endpoint for S3-compatible object storage. | +| `EVYDENCE_S3_BUCKET` | S3/MinIO object store | `evydence` | Bucket must already exist. | +| `EVYDENCE_S3_ACCESS_KEY_ID` | S3/MinIO object store | local example value | Store outside source control. | +| `EVYDENCE_S3_SECRET_ACCESS_KEY` | S3/MinIO object store | local example value | Store outside source control and logs. | +| `EVYDENCE_S3_REGION` | No | empty | Optional S3 region. | +| `EVYDENCE_S3_USE_SSL` | No | `false` locally, `true` in chart values | Use TLS for remote object storage. | +| `EVYDENCE_RATE_LIMIT_REQUESTS_PER_MINUTE` | No | `0` disabled | Optional in-process per-client request limit using the TCP remote address. Use reverse-proxy or ingress rate limiting for production edge controls. | +| `EVYDENCE_WORKER_OWNED_PARSER_SIDE_EFFECTS` | No | `false` | Optional hardening mode for parser-backed uploads. When set to `true`, the API stores accepted records and the outbox worker populates parser-derived fields from tenant-prefixed raw payloads after digest verification, including VEX-derived vulnerability decisions. | +| `EVYDENCE_SKIP_MIGRATIONS` | No | unset | Set to `true` only when migrations are applied by a separate release process. API and worker startup still verify that no committed migrations are pending and fail closed if the database is behind. | +| `EVYDENCE_MIGRATIONS_DIR` | No | `migrations` | Migration directory for API startup and `cmd/evydence-migrate`. | +| `EVYDENCE_BOOTSTRAP_TENANT` | No | `Local Tenant` | Tenant name used when bootstrapping an empty store. | +| `EVYDENCE_BOOTSTRAP_DISABLED` | No | unset | Set to `true` to prevent startup bootstrap on an empty store. | +| `EVYDENCE_PRINT_BOOTSTRAP_SECRET` | Local only | `true` in `.api.env.example` | Prints the one-time bootstrap secret. Rejected when `ENV=production`. | +| `EVYDENCE_WORKER_POLL_INTERVAL` | No | `1s` | Worker outbox polling interval. | +| `EVYDENCE_WORKER_BATCH_SIZE` | No | `10` | Maximum outbox jobs claimed per polling cycle. | +| `EVYDENCE_WORKER_MAX_PAYLOAD_BYTES` | No | `20971520` | Maximum raw object payload size replayed by a worker job. | +| `EVYDENCE_OIDC_USERINFO_TIMEOUT_SECONDS` | No | `10` | Timeout for optional live OIDC UserInfo validation when `POST /v1/provider-verifications` includes `access_token`. | +| `EVYDENCE_OIDC_USERINFO_ALLOW_INSECURE_LOCALHOST` | Local only | `false` | Allows HTTP OIDC issuer/UserInfo endpoints only for localhost tests. Do not use for production. | +| `EVYDENCE_PROVIDER_VALIDATION_GATEWAY_URL` | No | unset | Optional HTTPS operator-controlled provider validation gateway. When set, provider verification uses this gateway instead of direct OIDC UserInfo calls. | +| `EVYDENCE_PROVIDER_VALIDATION_GATEWAY_TOKEN` | Gateway | unset | Optional bearer token for the provider validation gateway. Store outside source control and logs. | +| `EVYDENCE_PROVIDER_VALIDATION_GATEWAY_TIMEOUT_SECONDS` | No | `10` | Timeout for provider validation gateway requests. | +| `EVYDENCE_PROVIDER_VALIDATION_GATEWAY_ALLOW_INSECURE_LOCALHOST` | Local only | `false` | Allows an HTTP localhost gateway for tests. Do not use for production. | +| `EVYDENCE_SIGNING_KEY_MODE` | Production yes | `external`, `aws-kms`, `gcp-kms`, `azure-key-vault`, or `pkcs11-hsm` for production | Production rejects local plaintext signing-key mode. `aws-kms`, `gcp-kms`, and `azure-key-vault` can use built-in provider executors. `pkcs11-hsm` remains an HTTPS signing-gateway profile. | +| `EVYDENCE_SIGNING_EXECUTOR_URL` | No | unset | Optional HTTPS signing gateway used by `POST /v1/signing-operations` when `external_signature` is omitted. The API sends subject metadata and `payload_hash`, not raw payload bytes. | +| `EVYDENCE_SIGNING_EXECUTOR_TOKEN` | Signing gateway | unset | Optional bearer token for the signing gateway. Store outside source control and logs. | +| `EVYDENCE_SIGNING_EXECUTOR_TIMEOUT_SECONDS` | No | `10` | Timeout for signing gateway requests. | +| `EVYDENCE_SIGNING_EXECUTOR_ALLOW_INSECURE_LOCALHOST` | Local only | `false` | Allows `http://localhost` or loopback signing gateway endpoints for local development and tests. Do not use for production. | +| `EVYDENCE_AWS_KMS_KEY_ID` | AWS KMS mode | unset | AWS KMS asymmetric signing key id, alias, or ARN. Store IAM credentials outside Evydence config and logs. | +| `EVYDENCE_AWS_REGION` / `AWS_REGION` | AWS KMS mode | unset | Region used by the AWS KMS executor. `EVYDENCE_AWS_REGION` takes precedence. | +| `EVYDENCE_AWS_KMS_ENDPOINT` | No | unset | Optional AWS KMS-compatible endpoint for tests or controlled private endpoints. | +| `EVYDENCE_AWS_KMS_SIGNING_ALGORITHM` | No | `ECDSA_SHA_256` | Supported values are `ECDSA_SHA_256`, `RSASSA_PSS_SHA_256`, and `RSASSA_PKCS1_V1_5_SHA_256` because Evydence signs stored SHA-256 payload hashes. | +| `EVYDENCE_AWS_KMS_TIMEOUT_SECONDS` | No | `10` | Timeout for AWS KMS signing requests. | +| `EVYDENCE_GCP_KMS_ACCESS_TOKEN` | GCP KMS mode | unset | Bearer token used by the direct GCP Cloud KMS executor. Store outside source control and logs. If unset, `gcp-kms` requires `EVYDENCE_SIGNING_EXECUTOR_URL`. | +| `EVYDENCE_GCP_KMS_KEY_NAME` | GCP KMS mode | unset | Default GCP Cloud KMS key version resource name. A signing provider `key_ref` can override it. | +| `EVYDENCE_GCP_KMS_ENDPOINT` | No | `https://cloudkms.googleapis.com` | Optional GCP KMS endpoint for tests or controlled private endpoints. | +| `EVYDENCE_GCP_KMS_TIMEOUT_SECONDS` | No | `10` | Timeout for GCP KMS signing requests. | +| `EVYDENCE_AZURE_KEY_VAULT_URL` | Azure Key Vault mode | unset | HTTPS Key Vault URL used by the direct Azure Key Vault executor. | +| `EVYDENCE_AZURE_KEY_VAULT_ACCESS_TOKEN` | Azure Key Vault mode | unset | Bearer token used by the direct Azure Key Vault executor. Store outside source control and logs. If unset, `azure-key-vault` requires `EVYDENCE_SIGNING_EXECUTOR_URL`. | +| `EVYDENCE_AZURE_KEY_VAULT_KEY_NAME` | Azure Key Vault mode | unset | Default Key Vault key name. A signing provider `key_ref` URL can override it. | +| `EVYDENCE_AZURE_KEY_VAULT_KEY_VERSION` | Azure Key Vault mode | unset | Default Key Vault key version. A signing provider `key_ref` URL can override it. | +| `EVYDENCE_AZURE_KEY_VAULT_ALGORITHM` | No | `ES256` | Azure Key Vault signing algorithm used for the SHA-256 digest. | +| `EVYDENCE_AZURE_KEY_VAULT_API_VERSION` | No | `7.4` | Azure Key Vault API version. | +| `EVYDENCE_AZURE_KEY_VAULT_TIMEOUT_SECONDS` | No | `10` | Timeout for Azure Key Vault signing requests. | +| `EVYDENCE_TRANSPARENCY_PROOF_GATEWAY_URL` | No | unset | Optional HTTPS operator-controlled gateway for transparency inclusion proof fetch/verification material. When unset, Evydence fetches from the configured public log endpoint. | +| `EVYDENCE_TRANSPARENCY_PROOF_GATEWAY_TOKEN` | Gateway | unset | Optional bearer token for the transparency proof gateway. Store outside source control and logs. | +| `EVYDENCE_TRANSPARENCY_PROOF_GATEWAY_TIMEOUT_SECONDS` | No | `10` | Timeout for transparency proof gateway requests. | +| `EVYDENCE_TRANSPARENCY_PROOF_GATEWAY_ALLOW_INSECURE_LOCALHOST` | Local only | `false` | Allows an HTTP localhost transparency proof gateway for tests. Do not use for production. | +| `EVYDENCE_TEST_DATABASE_URL` | Live tests | `.test.env.example` value | Used by `make live-postgres-check`, `make postgres-integration-test`, and `make release-check`. | + +## Production Rejection Checks + +When `ENV=production`, the API refuses to start unless: + +- `EVYDENCE_DATABASE_URL` is set. +- `EVYDENCE_API_KEY_PEPPER` is non-empty and not the local default. +- `EVYDENCE_SIGNING_KEY_MODE` is `external`, `aws-kms`, `gcp-kms`, + `azure-key-vault`, or `pkcs11-hsm`. `pkcs11-hsm` requires + `EVYDENCE_SIGNING_EXECUTOR_URL`; `gcp-kms` and `azure-key-vault` require + either their direct provider credentials or `EVYDENCE_SIGNING_EXECUTOR_URL`. +- `EVYDENCE_PRINT_BOOTSTRAP_SECRET` is not `true`. +- `EVYDENCE_POSTGRES_LOAD_MODE`, when set, is `relational_only`. +- `EVYDENCE_API_WRITER_MODE`, when set, is `single` or `single-writer`. +- `EVYDENCE_API_WRITER_REPLICAS`, when set, is `1`. + +These checks reduce unsafe runtime defaults. They do not replace secret management, network controls, backup validation, or external signing operations. + +When `ENV=production` and `EVYDENCE_POSTGRES_LOAD_MODE` is unset, API and worker +processes load only from tenant-scoped relational rows and do not write new +compatibility snapshots. Production refuses snapshot fallback modes. Local +development keeps `snapshot_preferred` and snapshot writes by default to +preserve existing workflows. Use `relational_preferred` only for controlled +non-production migration or recovery checks that intentionally fall back to the +snapshot. + +In production, API startup rejects non-single writer mode, rejects a declared +API writer replica count above one, takes a PostgreSQL advisory writer lease, +and fails if another API writer already holds it. Worker replicas are not +constrained by that lease because outbox jobs use row locking. + +When PostgreSQL is configured, critical runtime mutations use focused +transaction-backed writes for tenants, API-key hashes, SSO-session hashes, +customer-portal token hashes, idempotency records, audit-chain entries, +signing keys, signatures, release bundles, verification results, provider +verification receipts, vulnerability decisions, and outbox jobs. Remaining +resource families still depend on the broader ledger persistence path until +later repository decomposition work. + +## S3/MinIO Object-Retention Verification + +When `EVYDENCE_OBJECT_STORE=s3` or `minio`, object-retention policy verification +uses the same S3/MinIO client to check bucket versioning and default +object-lock settings. When the policy names a tenant-prefixed sample object, +the verifier also checks object-level retention; when `require_legal_hold` is +true, it checks that legal hold is enabled for that sample object. The resulting +policy record includes verification checks and limitations. These checks cover +the configured bucket and sample object only: operators still need to review +bucket creation mode, IAM policy, lifecycle rules, backups, and any +deployment-specific WORM requirements. + +## Signing Executors + +When `EVYDENCE_SIGNING_EXECUTOR_URL` is set, signing operations can omit +`external_signature`. Evydence sends a JSON request containing tenant id, +provider id/type, key reference, subject type/id, and `payload_hash`. The +gateway returns a signature, optional provider key id, and optional algorithm. +Evydence records the signature receipt and verification checks; it does not +store production private key material or send raw evidence payload bytes. + +`EVYDENCE_SIGNING_KEY_MODE=gcp-kms` and `azure-key-vault` can use direct +provider executors when their access-token and key configuration variables are +set. If direct credentials are absent, they fall back to requiring the HTTPS +signing gateway. `pkcs11-hsm` always uses the HTTPS signing gateway because +native HSM modules and slots are deployment-specific. Operators remain +responsible for provider credentials, IAM, key lifecycle, gateway operation +where used, and custody review. + +Tenant signing-provider records also accept `native_pkcs11_hsm` for deployments +that operate local PKCS#11 modules or slots outside Evydence. The provider +`key_ref` must be a `pkcs11:` URI and must not embed PIN values, PIN sources, +passwords, or secrets. This profile records custody evidence for review through +`GET /v1/reports/custody-review`; it does not load native HSM modules or prove +hardware custody by itself. + +When `EVYDENCE_SIGNING_KEY_MODE=aws-kms`, Evydence uses the AWS KMS `Sign` +operation against `EVYDENCE_AWS_KMS_KEY_ID`. The executor signs the decoded +SHA-256 digest with KMS `MessageType=DIGEST`; it does not send raw evidence +payload bytes to AWS KMS. Operators remain responsible for AWS IAM policy, +key lifecycle, CloudTrail review, regional availability, and external review +of whether the selected key custody profile satisfies their deployment needs. + +When `EVYDENCE_SIGNING_KEY_MODE=gcp-kms`, Evydence can call GCP Cloud KMS +`asymmetricSign` with a configured bearer token and key-version resource name. +The executor sends a SHA-256 digest, not raw evidence payload bytes. Operators +remain responsible for GCP IAM, token issuance, audit logs, key lifecycle, and +regional availability. + +When `EVYDENCE_SIGNING_KEY_MODE=azure-key-vault`, Evydence can call Azure Key +Vault `sign` with a configured bearer token, key name, key version, and +algorithm. The executor sends a SHA-256 digest encoded for Key Vault, not raw +evidence payload bytes. Operators remain responsible for Azure identity, Key +Vault access policy/RBAC, audit logs, key lifecycle, and regional availability. + +## Provider Validation Gateway + +When `EVYDENCE_PROVIDER_VALIDATION_GATEWAY_URL` is set, provider identity +verification can call an operator-controlled HTTPS gateway with tenant id, +provider id/type, issuer, subject, group-claim name, and whether the caller +supplied an access token. Evydence does not forward that access token to the +gateway. The gateway returns non-secret checks, groups, and limitations. +Evydence stores the checks and normalized groups, not the supplied token or raw +provider response. + +This gateway is an integration point for GitHub, GitLab, IdP, directory, or +other provider-specific validation logic that depends on deployment-owned +credentials and policies. It does not make external group synchronization +automatic, does not create permanent role bindings, and does not prove provider +truth beyond the gateway response and recorded limitations. + +## Transparency Proof Gateway + +When `EVYDENCE_TRANSPARENCY_PROOF_GATEWAY_URL` is set, public transparency +proof fetches call an operator-controlled HTTPS gateway instead of constructing +`/entries/{external_id}/inclusion-proof` requests directly against the log +endpoint. Evydence sends tenant id, log id, entry id, configured endpoint, +external entry id, and the expected Evydence entry hash. The gateway returns +RFC6962-style proof material plus optional non-secret checks and limitations. + +Evydence still verifies the returned proof material locally against the +published entry hash. The gateway records provider-specific proof retrieval or +timestamp semantics as evidence only; operators remain responsible for public +log trust, gateway operation, and provider availability. + +## Related Commands + +- Local operation: [Install and operate](../how-to/install-and-operate.md) +- Release validation: [Release validation](release-validation.md) +- Kubernetes secret wiring: [Kubernetes deployment](../kubernetes.md) diff --git a/docs/reference/customer-package-manifest.md b/docs/reference/customer-package-manifest.md new file mode 100644 index 0000000..69b0bee --- /dev/null +++ b/docs/reference/customer-package-manifest.md @@ -0,0 +1,222 @@ +# Customer Package Manifest + +Customer packages use `customer-security-package.v2.0.0` manifests. The +manifest is a scoped, redacted JSON summary for technical evidence review. It is +not legal compliance proof, certification, complete SBOM proof, an authoritative +vulnerability result, or a secure-release guarantee. + +## Required Top-Level Fields + +| Field | Purpose | +| --- | --- | +| `schema_version` / `package_version` | Stable manifest schema version. | +| `package_id` / `id` | Package identifier used by API responses and ZIP exports. | +| `title` | Operator-provided package title. | +| `generated_at` | UTC generation timestamp. | +| `tenant`, `organization`, `product`, `release` | Scoped metadata for the package subject. | +| `redaction_profile` | Redaction profile id, allowed types, excluded fields, and schema version. | +| `evidence_ids` | Included evidence metadata identifiers after redaction profile filtering. | +| `artifact_digests` | Artifact IDs, names, media types, sizes, and digests linked to the release. | +| `readiness_summary` | Deterministic readiness checks, gaps, and limitations. | +| `customer_decision_export` | Optional archive file metadata for the package-scoped customer-safe decision export. | +| `customer_safe_gaps` | Optional customer-visible gap records filtered through the package redaction profile. | +| `verification_material` | Hash algorithm, canonicalization profile, release bundle hashes, and audit-chain summary. | +| `reviewer_checklist` | Optional proof-path checklist covering package scope, included evidence, excluded evidence, verification, non-claims, and escalation path. | +| `limitations`, `non_claims` | Required package limitations and conservative product-language boundaries. | + +## Optional Sections + +Sections appear only when the redaction profile allows the relevant type and +matching release-scoped records exist: + +- `sboms`: SBOM metadata such as format, spec version, component count, and evidence ID. +- `vulnerability_scans`: scanner metadata, target reference, summary counts, and finding count. +- `vex_documents`: VEX metadata, statement counts, status summary, and evidence ID. +- `vulnerability_decisions`: active customer-visible decision summaries only, + including `reviewed_at` and optional `review_due_at` freshness metadata when + recorded, plus safe `sbom_id`, `sbom_component_purl`, and + `sbom_component_name` context when a same-release SBOM component matched the + finding, and optional `supporting_refs` with first-class same-release record + type/id pairs. +- `api_contracts`: OpenAPI contract metadata, normalized operation summaries, + deterministic contract diff results, and API-contract limitations. +- `approvals`: release or product approval records. +- `exceptions`: approved, unexpired release exceptions. +- `waivers`: approved, unexpired product or release waivers. +- `object_lock_proofs`: tenant object-retention verification records, included + only when the profile explicitly allows `object_lock_proof`. +- `provenance`: build runs and build attestation metadata. + +The `api_contracts` section contains `openapi_contracts` and `contract_diffs`. +OpenAPI contract entries include IDs, evidence IDs, product/release scope, +version, SHA-256 hash, path and operation counts, and normalized operation +labels, methods, paths, operation IDs, required-request indicators, required +request fields, and response statuses. Contract diff entries include the base +and target contract IDs, result, breaking and non-breaking change summaries, +schema version, and timestamp. The section deliberately omits raw OpenAPI +document bytes, object-store references, and private/internal document +extensions. + +When customer-visible vulnerability decisions are present, generated package +archives also include `vulnerability-decisions.json`. That file is scoped to the +package, repeats only customer-safe decision fields, records the source manifest +hash, and includes assumptions and limitations. The package verifier validates +the file when `verification.json` declares `decision_export_file`. + +## Exclusions + +Customer package manifests exclude raw tenant evidence payload bytes, +object-store payload references, bearer tokens, private keys, API key hashes, +SSO/session token hashes, and tenant-internal vulnerability decision notes. +Customer-visible vulnerability decisions require `impact_statement`; internal +notes are not copied into `vulnerability_decisions`. Decision `supporting_refs` +include only validated record type and id values. They do not copy approval +reason internals, exception payloads, incident timeline details, object-store +paths, raw payloads, or token/key material. + +`object_lock_proofs` entries include policy identifiers, object-scope presence +indicators, retention mode/days, verification checks, verification hash, and +limitations. They do not include raw object payloads, object-store paths, or +storage credentials, and they do not prove legal compliance, IAM correctness, +lifecycle policy completeness, or complete WORM enforcement. + +## Customer-Safe Gaps + +`customer_safe_gaps` contains factual missing-evidence records only when the +missing evidence type is allowed by the package redaction profile. For example, +an SBOM gap can appear in a `customer_safe` package because `sbom` is included, +while build-attestation or internal provenance gaps are omitted unless the +profile explicitly includes `build` or `build_attestation`. Internal-only gaps +remain outside the package manifest. + +## Reviewer Checklist + +`reviewer_checklist` is a customer-safe proof-path summary. The checked sample +package uses it to show: + +- package scope; +- included evidence; +- excluded evidence; +- hash and signature verification; +- non-claims; +- escalation path for missing or broader evidence. + +Checklist entries are summaries only. They must not include raw payloads, +object-store paths, bearer credentials, signing material, token hashes, tenant +internal notes, or customer data outside the package scope. + +## Presets + +`POST /v1/redaction-profiles` accepts either an explicit `allowed_types` list or +a server-defined `preset`. Presets reject caller-supplied `allowed_types` or +`excluded_fields` overrides so the package policy remains predictable. + +| Preset | Intended use | Default shape | +| --- | --- | --- | +| `customer_safe` | Customer release-evidence review. | Includes artifact, SBOM, vulnerability scan, VEX, active customer-visible decisions, release bundle, approval, exception, and waiver summaries. Excludes build provenance, internal URLs, raw payloads, token/key material, and internal notes. | +| `security_review` | Broader technical security review. | Includes the customer-safe families plus build provenance, build attestations, security-scan/manual-review categories, and API/security-review records where present. Still excludes raw payloads, object-store references, secrets, token material, and private keys. | + +Custom profiles must supply at least one `allowed_types` entry. An empty custom +profile is rejected because it would make package contents depend on implicit +defaults instead of explicit operator scope. + +## Viewer Compatibility + +The local package viewer at `site/package-viewer/index.html` loads v2 manifests +directly from disk. A non-sensitive sample is available at +`examples/end-to-end-release-evidence/sample-customer-package-manifest.json`; +the matching archive fixture is +`examples/end-to-end-release-evidence/sample-customer-package.zip`. +The viewer renders the optional `reviewer_checklist` with text-only DOM APIs so +operator-provided package text is displayed as text, not interpreted as HTML. + +Runtime ZIP exports also include `report.html`, a self-contained static HTML +rendering of the redacted manifest. It shows release summary, VEX and +vulnerability-decision tables, questionnaire answer-library entries when +included, object-lock proof records when included, API contract evidence when +included, readiness checks, verification material, limitations, and non-claims +without requiring a server or loading remote assets. Portal ZIP downloads can +also include `WATERMARK.txt` and a visible report watermark for +customer-specific distribution. The watermark is not a secret and does not +change the canonical package manifest hash. The HTML report is generated from +package-scoped data only and excludes raw evidence payload bytes, object-store +references, token material, private keys, and internal decision notes. + +Server-backed portal access records can be named for an external reviewer with +optional reviewer name and email labels. Those labels support access review, +watermarking, and audit triage only; the portal token remains the credential and +is returned once, stored only as a hash, and omitted from package manifests, +archives, logs, and list responses. + +## Reviewer UX Boundary + +The package review surface is deliberately narrow: + +- operators use the API and CLI to create packages, access records, downloads, + redaction profiles, and audit evidence; +- local reviewers may use `site/package-viewer/index.html` or archive + `report.html` after they already possess package files; +- browser-facing server review at `/v1/customer-portal/package/view` must reuse + the same customer portal token exchange, expiry, NDA, package scope, + watermark, and audit behavior as the JSON and ZIP endpoints. + +The local viewer and static reports are not authorization mechanisms. They do +not verify that a reviewer should receive a package and they do not grant access +to tenant data. Server-hosted review pages must accept portal tokens in request +bodies rather than URLs, disable caching of package responses, escape +operator-provided package text, and omit raw payloads, object-store references, +internal notes, token material, signing private material, and unrelated customer +data. + +## Redaction Leakage Guard + +Customer package generation is tested with canary values for API key secrets, +internal decision notes, raw scanner payload bytes, internal URLs, object-store +paths, signing private material, tenant secrets, and internal-only evidence +fields. Runtime package archives and the checked sample fixture must not contain +those canaries, raw payload references, object-store URLs, source identity +metadata, uploader identifiers, or canonical evidence hashes. + +The sample manifest is also protected by +`examples/end-to-end-release-evidence/sample-customer-package-manifest.sha256`. +Intentional fixture or manifest-shape changes must update the JSON fixture and +checksum together, and the fixture schema version must match the current +`customer-security-package.v2.0.0` domain version. + +## Offline Verification + +The CLI verifies a package manifest without contacting the API: + +```sh +go run ./cmd/evydence package verify \ + --manifest examples/end-to-end-release-evidence/sample-customer-package-manifest.json \ + --expected-product-id prod_example \ + --expected-release-id rel_example +``` + +For exported ZIP packages, verify archive metadata and the manifest hash: + +```sh +go run ./cmd/evydence package verify \ + --archive evydence-customer-package-csp_123.zip \ + --hash sha256: +``` + +The checked sample ZIP includes `vulnerability-decisions.json` for the +package-scoped decision export. The verifier checks the export scope and source +manifest hash alongside `manifest.json`, `package.json`, and +`verification.json`. + +When an evidence bundle is supplied, the verifier also checks the bundle +manifest hash, included signatures, and package evidence-id coverage: + +```sh +go run ./cmd/evydence package verify \ + --manifest package-manifest.json \ + --bundle evidence-bundle.json \ + --expected-signing-key-id sk_123 +``` + +Verification proves the local files match their recorded hashes and available +signatures. It does not prove legal compliance, certification, complete SBOM +coverage, vulnerability scanner authority, or release security status. diff --git a/docs/reference/external-controls-matrix.md b/docs/reference/external-controls-matrix.md new file mode 100644 index 0000000..3e68451 --- /dev/null +++ b/docs/reference/external-controls-matrix.md @@ -0,0 +1,36 @@ +# External Controls Matrix + +This reference separates what Evydence implements from what operators, +providers, and reviewers must configure or assess for regulated or high-trust +self-hosted deployments. It supports readiness review only. It is not legal +compliance proof, certification, complete SBOM proof, authoritative +vulnerability coverage, auditor acceptance, or a secure-release guarantee. + +Owner labels: + +- `repo-owned`: implemented, documented, or checked by this repository. +- `operator-owned`: deployment team must configure, run, or retain evidence. +- `provider-owned`: cloud, identity, registry, KMS, storage, or CI provider + supplies the authoritative control surface. +- `legal/review-owned`: counsel, auditor, customer, or internal risk owner must + review suitability. + +| Control area | Primary owner | Evydence repo evidence | External evidence required | +| --- | --- | --- | --- | +| PostgreSQL durability | operator-owned | Production startup requires `EVYDENCE_DATABASE_URL`; migrations and restore rehearsals are checked by project gates. | Backup schedule, retention, restore test logs, access controls, encryption settings, and target RPO/RTO acceptance. | +| Object storage durability | operator-owned/provider-owned | S3/MinIO adapter, tenant-prefixed object keys, object digest checks, and object-lock receipt records where configured. | Bucket policy, versioning, lifecycle, replication, encryption, retention, object-lock/WORM configuration, and provider audit output. | +| KMS/HSM signing custody | operator-owned/provider-owned | Signing executor ports, AWS KMS, GCP KMS, Azure Key Vault, HTTPS signing gateway, and custody review records. | Key policy, hardware or service custody proof, rotation procedure, break-glass procedure, provider logs, and role separation review. | +| TLS and network exposure | operator-owned/provider-owned | Helm/Compose docs require external TLS and protected diagnostics. | Ingress/TLS config, certificate lifecycle, firewall rules, private network boundaries, and authenticated metrics/admin access proof. | +| Identity provider and SSO | operator-owned/provider-owned | OIDC/SAML trust material records, local assertion/token verification, session records, and audit entries. | Provider tenant config, issuer/audience ownership, MFA policy, group lifecycle, disabled-user sync, and provider availability evidence. | +| CI provider provenance | operator-owned/provider-owned | GitHub Actions build metadata, structural DSSE/in-toto/SLSA parsing, collector keys, and CI upload examples. | Branch protection, required workflow policy, runner trust, OIDC token policy where used, protected environments, and provider audit logs. | +| Container registry trust | operator-owned/provider-owned | Optional maintainer GHCR workflow, image digest evidence, cosign verification output, and release evidence index. | Registry access policy, image admission policy, mirror/rebuild evidence, vulnerability scan evidence, and deployed immutable digest records. | +| Backup and restore operations | operator-owned | Backup/restore runbook and repository-owned restore rehearsal checks. | Deployment-specific restore rehearsal, credential recovery procedure, object/database consistency checks, and escalation ownership. | +| Monitoring and alerting | operator-owned/provider-owned | Safe metrics, readiness endpoints, starter Prometheus rules, and dashboard assets. | Scrape authentication, alert routing, on-call schedule, log retention, incident escalation, and dashboard review for the deployment. | +| Incident response | operator-owned/legal/review-owned | Incident resources, signed webhook-safe ingestion, incident package reports, and runbook guidance. | Incident commander ownership, disclosure policy, customer notification process, evidence retention, and post-incident review. | +| Retention, legal hold, and WORM | operator-owned/legal/review-owned/provider-owned | Retention markers, legal hold records, tombstones, object-lock receipts, and audit-chain entries. | Applicable retention schedule, legal-hold approval, provider-enforced retention proof, and deletion/exception review. | +| Customer package sharing | operator-owned/legal/review-owned | Redaction profiles, package manifests, package downloads, viewer, portal access, and package verification commands. | Recipient authorization, NDA or sharing policy, package expiry, customer-specific redaction approval, and legal/commercial review. | +| Audit/legal sufficiency | legal/review-owned | Tamper-evident records, release bundles, reports, limitations, assumptions, and verification receipts. | Auditor/customer acceptance criteria, legal interpretation, framework-specific control mapping review, and sign-off outside Evydence. | + +Evydence can record external evidence and verify the local records it generates. +It cannot independently prove provider-side configuration, legal sufficiency, +regulatory acceptance, scanner completeness, or that a release is secure. diff --git a/docs/reference/ha-strategy.md b/docs/reference/ha-strategy.md new file mode 100644 index 0000000..0dc438b --- /dev/null +++ b/docs/reference/ha-strategy.md @@ -0,0 +1,111 @@ +# HA Strategy + +This reference records the current high-availability decision for Evydence +release candidates. It supports deployment planning only. It is not an uptime +guarantee, legal compliance proof, certification, complete evidence proof, or a +secure-release guarantee. + +## Decision + +Evydence is positioned as a single-writer self-hosted appliance for the current +controlled production-candidate release line. + +The supported topology is: + +- one API writer replica; +- one or more worker replicas using PostgreSQL outbox row locking; +- external PostgreSQL with backup and restore rehearsal; +- external S3/MinIO-compatible object storage with database/object backup + pairing; +- supervised API restart through Kubernetes, systemd, or an equivalent process + manager; +- deployment-specific recovery objectives accepted by the operator. + +This is a deliberate product and engineering decision. Multi-writer API high availability is not supported +until the remaining concurrency work is +implemented, tested, and reviewed. + +## Why Not Multi-Writer API Yet + +The current code has focused relational write paths for high-risk resource +families, but it still keeps a large application ledger aggregate and some +cross-resource operations that are easier to reason about with a single API +writer. Scaling API writers before the remaining work is finished could create +hard-to-audit races around: + +- audit-chain ordering and tenant chain continuity; +- release bundle generation and signature receipts; +- idempotency replay and conflict behavior under concurrent requests; +- evidence lifecycle append ordering; +- package/report generation from a moving release state; +- object-store metadata and database row pairing; +- remaining aggregate state synchronization for resource families not yet split + into focused services. + +The current supported stance favors clear recovery behavior over an unfinished +multi-writer design. +Use the [Persistence decomposition inventory](persistence-decomposition.md) to +inspect the current focused mutation paths, remaining broad relational-state +call sites, and next repository split order. + +## Supported Recovery Boundaries + +Evydence does not publish a generic SLO for all self-hosted deployments. The +operator owns the concrete target for recovery time, recovery point, support +coverage, alert routing, and maintenance windows. + +For controlled self-hosted use, the expected recovery model is: + +- the API process is restarted by the deployment platform if it exits; +- a second API writer should fail closed in production because startup rejects + replica counts above one and the PostgreSQL advisory writer lease prevents a + competing writer from taking over silently; +- worker replicas can continue or be scaled while the API is unavailable, + subject to database and object-store availability; +- database and object-store restore must be rehearsed together before relying + on the deployment for customer evidence workflows; +- any rollback or forward-fix procedure must preserve append-only evidence, + audit-chain entries, signatures, packages, and object payload hashes. + +## Before Multi-Writer API Is Supported + +Do not change Helm defaults or production docs to advertise multi-writer API +support until all of these are true: + +- all production write families have focused relational repository paths or an + equivalent reviewed transaction design; +- tenant-scoped audit-chain appends are protected by reviewed transaction or + resource-lock behavior; +- idempotency records are protected by transaction-backed uniqueness and replay + tests under concurrent requests; +- release bundle, package, report, evidence lifecycle, exception, approval, and + signing operations have concurrent write tests; +- object-store writes and database metadata writes have failure and retry tests + that preserve digest pairing; +- `make production-check`, `make test-race`, live PostgreSQL concurrency tests, + black-box restart tests, and restore rehearsals pass under the proposed + topology; +- Kubernetes values, production docs, release notes, and the pilot checklist + are updated to state the new supported topology and limitations. + +## Operator Checklist + +- Keep `api.replicas=1` and `api.writerMode=single`. +- Keep `EVYDENCE_API_WRITER_MODE=single` and + `EVYDENCE_API_WRITER_REPLICAS=1`. +- Scale workers based on outbox backlog and parser/signing/report latency. +- Monitor `/v1/ready`, API process restarts, outbox backlog age, worker + failures, database availability, object-store failures, and signing-provider + failures. +- Rehearse paired database/object-store restore and verify audit chains, + release bundles, and customer packages after restore. +- Record the deployment's recovery assumptions in the + [pilot deployment checklist](../how-to/pilot-deployment-checklist.md). + +## Related References + +- [Production readiness](production-readiness.md) +- [Capacity and failure modes](capacity-and-failures.md) +- [Hardened reference deployment](hardened-reference-deployment.md) +- [Kubernetes deployment](../kubernetes.md) +- [Backup and restore](../runbooks/backup-restore.md) diff --git a/docs/reference/hardened-reference-deployment.md b/docs/reference/hardened-reference-deployment.md new file mode 100644 index 0000000..fa29bf1 --- /dev/null +++ b/docs/reference/hardened-reference-deployment.md @@ -0,0 +1,152 @@ +# Hardened Reference Deployment + +This reference describes the supported controlled self-hosted deployment shape +for Evydence release candidates. Use it to review a deployment before pilot or +internal production use. It is not legal compliance proof, certification, +complete SBOM proof, authoritative scanner coverage, or a secure-release +guarantee. + +The narrow supported topology is: + +- one API writer replica; +- scalable worker replicas using PostgreSQL outbox locking; +- external PostgreSQL as the durable source of truth; +- external S3/MinIO-compatible object storage for raw payloads and generated + packages; +- TLS terminated at a reverse proxy or Kubernetes ingress; +- deployment secrets supplied by an operator-controlled secret manager; +- external signing mode or an operator-controlled signing provider; +- paired database and object-store backups with restore rehearsal evidence; +- authenticated metrics, admin, audit, and package-access surfaces. + +Use the [pilot deployment checklist](../how-to/pilot-deployment-checklist.md) +for the copy-paste go/no-go record and the +[external controls matrix](external-controls-matrix.md) to assign operator, +provider, repo, and review responsibilities. +Use [HA strategy](ha-strategy.md) for the single-writer API decision, +worker-scaling stance, recovery boundaries, and multi-writer prerequisites. + +## Reference Topology + +| Layer | Required Shape | Operator Evidence To Keep | +| --- | --- | --- | +| API | One API writer replica with `ENV=production`, `EVYDENCE_API_WRITER_MODE=single`, and `EVYDENCE_API_WRITER_REPLICAS=1`. | Helm values or deployment manifest, startup log showing production mode, and `/v1/ready` result. | +| Worker | One or more worker replicas with the same database and object-store config as the API. | Worker rollout status, outbox backlog metrics, retry/failure counters, and parser/signing/report job evidence. | +| PostgreSQL | External PostgreSQL, not an application sidecar, with migrations applied before traffic. | Database version, migration version, backup schedule, restore rehearsal output, access policy, and encryption setting. | +| Object storage | External S3/MinIO-compatible object storage with tenant-prefixed keys. | Bucket policy, versioning or retention setting, encryption setting, lifecycle policy, object-lock receipt where required, and object/database backup pairing. | +| Ingress/TLS | TLS at the edge with body-size limits matched to expected uploads. | Ingress or reverse-proxy config, certificate owner, request body limit, timeout, redaction, and rate-limit evidence. | +| Secrets | Database URL, API-key pepper, object-store credentials, signing credentials, SSO secrets, and portal secrets from deployment secrets. | Secret-manager path or sealed-secret reference, rotation owner, and proof that secrets are not rendered into logs or manifests. | +| Signing | `EVYDENCE_SIGNING_KEY_MODE=external`, `aws-kms`, `gcp-kms`, `azure-key-vault`, or gateway-backed `pkcs11-hsm`. | Provider key ID, signing receipt, rotation plan, custody review, and limitations for the selected provider. | +| Monitoring | Authenticated metrics, readiness checks, safe logs, and alert routing. | Prometheus scrape config, dashboard link, alert route, and sanitized incident runbook. | + +## Helm Values + +For Kubernetes, start from `deploy/helm/evydence/values.yaml` and set an +explicit image tag or digest. The chart intentionally defaults +`api.replicas` to `1` and `api.writerMode` to `single`. +Use `.production.env.example` as the environment-variable checklist when +translating settings into Kubernetes Secrets or another secret manager; keep +the empty secret fields empty in git and fill them only in the deployment +system. + +```sh +helm upgrade --install evydence ./deploy/helm/evydence \ + --set image.repository=ghcr.io/aatuh/evydence \ + --set image.tag='@sha256:' \ + --set api.replicas=1 \ + --set api.writerMode=single \ + --set worker.replicas=2 \ + --set env.objectStore=s3 \ + --set env.s3Endpoint=s3.example.com \ + --set env.s3Bucket=evydence \ + --set ingress.enabled=true \ + --set ingress.host=evydence.example.com \ + --set ingress.tlsSecretName=evydence-tls +``` + +Expected result: + +- Helm renders both API and worker deployments. +- The API deployment uses one writer replica. +- Worker replicas can be scaled to match parser/signing/report load. +- Pods read sensitive values from `existingSecret`, not literal values in the + chart. +- Startup fails closed if production mode uses unsafe defaults. + +## Ingress, TLS, And Upload Limits + +Put the public API behind a reverse proxy or Kubernetes ingress that terminates +TLS. Configure: + +- request body limits for expected evidence, SBOM, VEX, scan, attestation, + package, and bundle uploads; +- connection and response timeouts that account for upload size and object-store + latency; +- edge rate limits for unauthenticated, token-heavy, and upload endpoints; +- header redaction for `Authorization`, cookies, database URLs, object-store + credentials, provider tokens, and API-key material; +- no unauthenticated public access to `/v1/metrics`, `/v1/audit-log`, or + `/v1/admin/instance`. + +Evydence verifies uploaded payload digests and records object references, but +the ingress or reverse proxy remains responsible for TLS certificates, +client-facing rate limits, and raw request-size enforcement. + +## Backup Pairing + +Back up PostgreSQL and object storage from the same recovery point. A database +backup without matching object payloads cannot verify raw evidence hashes after +restore. Object payload backups without matching database rows cannot +reconstruct release, report, and package relationships. + +Keep these records with every deployment backup: + +- Evydence release tag, image digest or binary checksum, OpenAPI checksum, and + migration checksum; +- database backup identifier and completion time; +- object-store backup, replication, or version marker from the same recovery + window; +- signing-provider custody and recovery note; +- restore rehearsal output with audit-chain, release-bundle, package, and + object-digest verification. + +Use [Backup and restore](../runbooks/backup-restore.md) and +[Object store recovery](../runbooks/object-store-recovery.md) for the operator +runbooks. + +## Validation Sequence + +Before accepting pilot or internal production traffic: + +```sh +make deploy-check +make release-acceptance +make production-check +``` + +Expected result: + +- `make deploy-check` validates repository-owned deployment assets and the + single-writer Helm defaults. +- `make release-acceptance` validates release metadata, legal/governance files, + and release-evidence language. +- `make production-check` runs the strict live-PostgreSQL gate with coverage, + migration compatibility, restore rehearsal, release signing smoke test, and + production-check summary output. + +Complete the [pilot deployment checklist](../how-to/pilot-deployment-checklist.md) +for the target environment. Record any skipped provider checks, missing object +lock, missing KMS/HSM proof, missing SSO provider evidence, or untested restore +procedure as deployment limitations. + +## Boundaries + +Repo-owned checks can verify Evydence code, schemas, release artifacts, Helm +defaults, local restore rehearsals, and generated evidence. They cannot verify +that an operator's cloud account, identity provider, object-store policy, +network, backup tooling, signing provider, or legal/review process is suitable. + +Do not broaden this reference into a multi-writer API HA claim. The supported +self-hosted profile remains a single API writer replica with scalable worker +replicas until multi-writer API concurrency is implemented, tested, and +documented. See [HA strategy](ha-strategy.md). diff --git a/docs/reference/maintainer-review-policy.md b/docs/reference/maintainer-review-policy.md new file mode 100644 index 0000000..73ef20d --- /dev/null +++ b/docs/reference/maintainer-review-policy.md @@ -0,0 +1,118 @@ +# Maintainer Review Policy + +This reference documents the repository review expectations for Evydence +changes that affect high-trust release evidence, tenant isolation, credentials, +signing, storage, deployment, or public product claims. + +The policy supports engineering review. It is not legal compliance proof, +certification, complete vulnerability coverage, or a secure-release guarantee. + +## Current Enforcement Status + +`CODEOWNERS` is committed at the repository root and names the expected +maintainer owner for high-risk paths. GitHub branch protection or repository +rules must enforce CODEOWNERS and required checks before it becomes a merge +gate. Until those GitHub settings are enabled, this file is a public review +policy and not a technical enforcement guarantee. + +## Maintainer Bypass Posture + +The repository may allow maintainers or administrators to bypass branch +protection depending on the live GitHub repository settings. Evydence therefore +does not claim that every change is technically forced through pull-request +review, even though pull-request review is the expected path for high-risk +changes. + +When a maintainer bypass is used for an urgent or operational change, the +maintainer should record the reason in the pull request, release evidence, or a +follow-up audit note as appropriate. The compensating controls are: + +- required `Production Check` and `CodeQL Analyze` CI jobs on normal push and + pull-request paths; +- project-owned `make production-check`, `make release-acceptance`, and + release-candidate validation gates for release-affecting changes; +- signed release artifacts, checksums, OpenAPI checksums, migration checksums, + and release evidence manifests before candidate tags are treated as operator + evaluation inputs; +- `CODEOWNERS` coverage for high-risk paths, which states expected maintainer + ownership even when repository settings are not public proof of enforcement; +- public release evidence and audit visibility for candidate releases. + +These controls support review and reproducibility. They do not prove that a +specific live GitHub setting prevented maintainer bypass, and they are not legal +compliance proof, certification, complete vulnerability coverage, or a +secure-release guarantee. + +## High-Risk Paths + +Changes in these areas require maintainer review before merge or release: + +- `internal/app/`, `internal/domain/`, and `internal/adapters/httpapi/` for + tenant isolation, authorization, idempotency, evidence immutability, report + wording, and API behavior; +- `internal/adapters/postgres/`, `internal/adapters/objectstore/`, and + `migrations/` for persistence, object payload references, transaction + behavior, durability, and recovery; +- `cmd/`, `scripts/`, `.github/workflows/`, and `Makefile` for CLI/operator + behavior, release packaging, CI gates, and secret-handling surfaces; +- `deploy/`, `Dockerfile`, `docker-compose.yml`, and + `compose.production-like.yml` for production startup, probes, resources, + ingress, object storage, and database wiring; +- `openapi.yaml`, `docs/api.md`, and `docs/reference/api-contract-matrix.md` + for public API contract changes; +- `SECURITY.md`, `RELEASE_EVIDENCE.md`, `README.md`, and production/release + references for public security intake, release evidence, production status, + and product claim boundaries. + +## Required Review Questions + +Reviewers should explicitly check: + +- tenant-scoped resources cannot cross tenant boundaries; +- evidence core fields, audit-chain entries, release bundles, decisions, + approvals, exceptions, and lifecycle records remain append-only where + required; +- create/action endpoints preserve idempotency replay and conflict behavior; +- uploaded objects, release artifacts, and imported bundles are checked by + digest before trust is assigned; +- errors, logs, reports, release evidence, and examples do not expose API keys, + bearer tokens, session tokens, portal tokens, private keys, database URLs, + provider credentials, raw evidence payloads, or customer data; +- public wording stays limited to compliance readiness, technical evidence + organization, reproducibility, gaps, assumptions, exceptions, and + limitations. + +## Required Checks + +For routine changes, run the narrowest affected tests plus the relevant +project-owned gate. For release, production, deployment, API, persistence, or +security-sensitive changes, reviewers should require evidence from: + +```sh +make docs-check +make fast-check +``` + +Release-candidate and production-readiness changes should also require: + +```sh +make production-check +make release-candidate-check TAG= +``` + +Those stronger commands require live PostgreSQL and release signing material; +skips or unavailable external dependencies must be recorded in release notes or +the release evidence index. + +Repository CI and SAST workflows should keep third-party Actions pinned by +commit SHA and service/container images pinned by digest. If an Action or image +pin is updated, reviewers should verify the upstream tag or digest source and +record the reason in the pull request or release evidence. + +Branch protection requires the live `Production Check` and `CodeQL Analyze` +checks because they run on pushes and pull requests and directly gate runtime +correctness and security analysis. OpenSSF Scorecard and Scorecard SARIF remain +scheduled/manual advisory checks for the current release-candidate line. Do not +make them required branch-protection checks unless their workflows also run on +the same pull request events without introducing circular or non-deterministic +merge blocking. diff --git a/docs/reference/observability.md b/docs/reference/observability.md new file mode 100644 index 0000000..5a7742a --- /dev/null +++ b/docs/reference/observability.md @@ -0,0 +1,35 @@ +# Observability + +Evydence exposes low-detail runtime checks for operators. These signals support operational review and incident response; they do not prove legal compliance, complete evidence coverage, or release security. + +## Endpoints + +`GET /v1/ready` returns unauthenticated readiness JSON with only component status names such as `ledger`, `store`, and `object_store`. + +`GET /v1/metrics` requires an admin API key. By default it returns JSON. When the request includes `Accept: text/plain`, it returns Prometheus exposition text for safe tenant-scoped counters and gauges: + +```sh +curl -sS \ + -H "Authorization: Bearer $EVYDENCE_API_KEY" \ + -H "Accept: text/plain" \ + "$EVYDENCE_API_URL/v1/metrics" +``` + +Expected result: metric names such as `evydence_resource_count`, `evydence_customer_portal_failed_access_count`, and `evydence_customer_portal_revoked_access_count`. The response omits API keys, portal tokens, raw evidence payloads, signing-key private material, customer names, and email addresses. + +## Deployment Artifacts + +The repository includes starter observability assets: + +```text +deploy/observability/prometheus-rules.yaml +deploy/observability/grafana-dashboard.json +``` + +The Prometheus rules assume a scrape job named `evydence-api` and an authenticated scrape configuration for `/v1/metrics`. The dashboard assumes a Prometheus datasource. Adjust labels, scrape authentication, and routing policies for the deployment environment. + +## Limitations + +- `/v1/metrics` is tenant-scoped to the authenticating admin actor; instance-wide metrics require separate operator aggregation. +- The starter alert rules are examples and need production routing, silence, and escalation policy review. +- OpenTelemetry tracing/exporter wiring is deployment-specific and not required for the local self-hosted runtime. diff --git a/docs/reference/openapi.md b/docs/reference/openapi.md index d410310..43eaeab 100644 --- a/docs/reference/openapi.md +++ b/docs/reference/openapi.md @@ -1,17 +1,93 @@ # OpenAPI Contract -The committed API contract is `openapi.yaml`. +The committed API contract is `openapi.yaml`. It is generated from the HTTP adapter route registry and route-contract metadata. -Generate it with: +## Generate And Check + +Generate the contract: ```sh go run ./cmd/openapi > openapi.yaml ``` -Check drift with: +Check drift: ```sh make openapi-check ``` -The API also serves the generated contract at `/v1/openapi.json`. Route registration and OpenAPI generation use the same HTTP adapter registry so tests can catch missing routes or stale operation metadata. +`make openapi-check` regenerates the contract, compares it with the committed file, and runs route contract tests for the HTTP adapter. +It also checks that the rendered static API docs in [`docs/openapi/index.html`](../openapi/index.html) and `site/marketing/public/api/index.html` match the committed contract. + +Check precision regression: + +```sh +make openapi-precision-check +``` + +`make openapi-precision-check` enforces endpoint-specific contracts for all +registered public routes and fails if any operation falls back to a broad +request or response shape. + +Render the human-readable static API docs after changing route metadata: + +```sh +scripts/render_openapi_docs.py --write +``` + +Check the rendered docs without modifying files: + +```sh +make rendered-openapi-check +``` + +`make docs-check` compares the paths in `openapi.yaml` with the endpoint catalog in [API reference](../api.md). Add every generated path to that catalog, or the docs check fails. + +## Review The Contract + +The committed file is compact. For human review, pretty-print it without editing the generated source: + +```sh +jq . openapi.yaml > /tmp/evydence-openapi.pretty.json +jq '.paths | keys[]' openapi.yaml +jq '.components.schemas | keys[]' openapi.yaml +``` + +Inspect one operation: + +```sh +jq '.paths["/v1/evidence"].post' openapi.yaml +``` + +For a browser-friendly route and schema index, open: + +```text +docs/openapi/index.html +``` + +The marketing-site copy is generated at: + +```text +site/marketing/public/api/index.html +``` + +The rendered page is static, uses no remote scripts, and is regenerated from +the committed public OpenAPI contract. It must not contain runtime secrets, +tenant data, raw evidence payloads, bearer tokens, private keys, or customer +package contents. + +The API also serves the generated contract at: + +```http +GET /v1/openapi.json +``` + +## Current Limitations + +- `openapi.yaml` is generated in a compact JSON-compatible representation. +- `docs/openapi/index.html` is generated from `openapi.yaml` for human review. +- Registered public routes have endpoint-specific request and response schemas. +- New routes must update operation metadata, component schemas, `openapi.yaml`, the generated [API contract matrix](api-contract-matrix.md), and the rendered [OpenAPI docs](../openapi/index.html). +- Do not hand-edit `openapi.yaml`; update route metadata or the generator, then run `make openapi-check`. + +Route registration and OpenAPI generation use the same HTTP adapter registry so tests can catch missing routes or stale operation metadata. diff --git a/docs/reference/persistence-decomposition.md b/docs/reference/persistence-decomposition.md new file mode 100644 index 0000000..e6174fe --- /dev/null +++ b/docs/reference/persistence-decomposition.md @@ -0,0 +1,168 @@ +# Persistence Decomposition Inventory + +This reference is generated by `scripts/persistence_decomposition_inventory.py`. +Update it with `scripts/persistence_decomposition_inventory.py --write` after persistence call sites change. + +Purpose: keep the production persistence story inspectable while Evydence continues splitting the large application ledger into focused repository paths. + +## Current Production Contract + +- Production PostgreSQL startup uses relational-only reconstruction. +- Production writes do not create or update the compatibility `ledger_state` snapshot row. +- `persistCriticalLocked` writes high-risk identity, key, idempotency, signing, verification, bundle, provider-verification, decision, audit-chain, and outbox state through focused PostgreSQL transactions when the store supports them. +- `persistReleaseLedgerLocked` and `persistReleaseLedgerWithOutboxLocked` write release-ledger and evidence-core state through focused PostgreSQL transactions when the store supports them. +- Remaining direct `persistLocked` call sites use `SaveRelationalState` with the PostgreSQL store, not `SaveState`; compatibility `SaveState` remains for memory/local/demo/import paths. +- One API writer replica remains the supported production candidate topology until every write family has focused repository paths or a reviewed optimistic-concurrency design. + +## Focused Critical Mutations + +| Family | File | Function | Call | +| --- | --- | --- | --- | +| VEX and vulnerability decisions | `internal/app/vex.go` | `CreateVulnerabilityDecision` | `persistCriticalLocked` | +| enterprise identity and retention | `internal/app/enterprise.go` | `CreateCustomerPortalAccess` | `persistCriticalLocked` | +| enterprise identity and retention | `internal/app/enterprise.go` | `CreateSSOSession` | `persistCriticalLocked` | +| enterprise identity and retention | `internal/app/enterprise.go` | `ExchangeSSOCredential` | `persistCriticalLocked` | +| enterprise identity and retention | `internal/app/enterprise.go` | `RevokeCurrentSSOSession` | `persistCriticalLocked` | +| enterprise identity and retention | `internal/app/enterprise.go` | `RevokeCustomerPortalAccess` | `persistCriticalLocked` | +| enterprise identity and retention | `internal/app/enterprise.go` | `RevokeSSOSession` | `persistCriticalLocked` | +| enterprise identity and retention | `internal/app/enterprise.go` | `accessCustomerPortalPackage` | `persistCriticalLocked` | +| enterprise identity and retention | `internal/app/enterprise.go` | `accessCustomerPortalPackage` | `persistCriticalLocked` | +| enterprise identity and retention | `internal/app/enterprise.go` | `accessCustomerPortalPackage` | `persistCriticalLocked` | +| identity | `internal/app/identity_service.go` | `Authenticate` | `persistCriticalLocked` | +| identity | `internal/app/identity_service.go` | `Authenticate` | `persistCriticalLocked` | +| identity | `internal/app/identity_service.go` | `BootstrapTenant` | `persistCriticalLocked` | +| identity | `internal/app/identity_service.go` | `CreateAPIKey` | `persistCriticalLocked` | +| release ledger and signing | `internal/app/ledger.go` | `CreateReleaseBundle` | `persistCriticalLocked` | +| release ledger and signing | `internal/app/ledger.go` | `VerifySubject` | `persistCriticalLocked` | +| release ledger and signing | `internal/app/ledger.go` | `WithIdempotency` | `persistCriticalLocked` | + +## Focused Release And Evidence Mutations + +| Family | File | Function | Call | +| --- | --- | --- | --- | +| VEX and vulnerability decisions | `internal/app/vex.go` | `UploadVEX` | `persistReleaseLedgerWithOutboxLocked` | +| release extensions, source, and deployment | `internal/app/implementation_increments.go` | `RecordEvidenceLifecycleEvent` | `persistReleaseLedgerLocked` | +| release ledger and signing | `internal/app/ledger.go` | `ApproveRelease` | `persistReleaseLedgerLocked` | +| release ledger and signing | `internal/app/ledger.go` | `CreateEvidence` | `persistReleaseLedgerLocked` | +| release ledger and signing | `internal/app/ledger.go` | `CreateProduct` | `persistReleaseLedgerLocked` | +| release ledger and signing | `internal/app/ledger.go` | `CreateProject` | `persistReleaseLedgerLocked` | +| release ledger and signing | `internal/app/ledger.go` | `CreateRelease` | `persistReleaseLedgerLocked` | +| release ledger and signing | `internal/app/ledger.go` | `FreezeRelease` | `persistReleaseLedgerLocked` | +| release ledger and signing | `internal/app/ledger.go` | `LinkEvidence` | `persistReleaseLedgerLocked` | +| release ledger and signing | `internal/app/ledger.go` | `RegisterArtifact` | `persistReleaseLedgerLocked` | +| release ledger and signing | `internal/app/ledger.go` | `SupersedeEvidence` | `persistReleaseLedgerLocked` | +| release ledger and signing | `internal/app/ledger.go` | `UploadOpenAPIContract` | `persistReleaseLedgerWithOutboxLocked` | +| release ledger and signing | `internal/app/ledger.go` | `UploadSBOM` | `persistReleaseLedgerWithOutboxLocked` | +| release ledger and signing | `internal/app/ledger.go` | `UploadVulnerabilityScan` | `persistReleaseLedgerWithOutboxLocked` | +| risk and security workflows | `internal/app/risk_workflows.go` | `UploadCycloneDXVEX` | `persistReleaseLedgerWithOutboxLocked` | +| risk and security workflows | `internal/app/risk_workflows.go` | `UploadSPDXSBOM` | `persistReleaseLedgerLocked` | + +## Remaining Broad Relational-State Mutations + +| Family | File | Function | Call | +| --- | --- | --- | --- | +| VEX and vulnerability decisions | `internal/app/vex.go` | `ApproveException` | `persistLocked` | +| VEX and vulnerability decisions | `internal/app/vex.go` | `CreateException` | `persistLocked` | +| build provenance | `internal/app/builds.go` | `CreateBuildRun` | `persistLocked` | +| build provenance | `internal/app/builds.go` | `CreateCollector` | `persistLocked` | +| build provenance | `internal/app/builds.go` | `RecordCollectorRelease` | `persistLocked` | +| build provenance | `internal/app/builds.go` | `UploadBuildAttestation` | `persistLocked` | +| controls | `internal/app/controls.go` | `CreateControlFramework` | `persistLocked` | +| controls | `internal/app/controls.go` | `CreateSecurityControl` | `persistLocked` | +| controls | `internal/app/controls.go` | `LinkControlEvidence` | `persistLocked` | +| enterprise identity and retention | `internal/app/enterprise.go` | `CreateCommercialCollectorDefinition` | `persistLocked` | +| enterprise identity and retention | `internal/app/enterprise.go` | `CreateLegalHold` | `persistLocked` | +| enterprise identity and retention | `internal/app/enterprise.go` | `CreateOrganization` | `persistLocked` | +| enterprise identity and retention | `internal/app/enterprise.go` | `CreateQuestionnaireAnswerLibraryEntry` | `persistLocked` | +| enterprise identity and retention | `internal/app/enterprise.go` | `CreateQuestionnairePackage` | `persistLocked` | +| enterprise identity and retention | `internal/app/enterprise.go` | `CreateQuestionnaireTemplate` | `persistLocked` | +| enterprise identity and retention | `internal/app/enterprise.go` | `CreateRetentionOverride` | `persistLocked` | +| enterprise identity and retention | `internal/app/enterprise.go` | `CreateRoleBinding` | `persistLocked` | +| enterprise identity and retention | `internal/app/enterprise.go` | `CreateSSOProvider` | `persistLocked` | +| enterprise identity and retention | `internal/app/enterprise.go` | `CreateUser` | `persistLocked` | +| enterprise identity and retention | `internal/app/enterprise.go` | `DeactivateUser` | `persistLocked` | +| enterprise identity and retention | `internal/app/enterprise.go` | `ExchangeSSOCredential` | `persistLocked` | +| enterprise identity and retention | `internal/app/enterprise.go` | `ExchangeSSOCredential` | `persistLocked` | +| enterprise identity and retention | `internal/app/enterprise.go` | `ExchangeSSOCredential` | `persistLocked` | +| enterprise identity and retention | `internal/app/enterprise.go` | `LinkSSOIdentity` | `persistLocked` | +| enterprise identity and retention | `internal/app/enterprise.go` | `RefreshSSOProviderOIDCTrustMaterial` | `persistLocked` | +| enterprise identity and retention | `internal/app/enterprise.go` | `UpdateSSOProviderTrustMaterial` | `persistLocked` | +| future extensions and generated reports | `internal/app/future_extensions.go` | `CreateEvidenceSummary` | `persistLocked` | +| future extensions and generated reports | `internal/app/future_extensions.go` | `CreateGraphSnapshot` | `persistLocked` | +| future extensions and generated reports | `internal/app/future_extensions.go` | `CreateMarketplaceCollector` | `persistLocked` | +| future extensions and generated reports | `internal/app/future_extensions.go` | `CreatePDFReportPackage` | `persistLocked` | +| future extensions and generated reports | `internal/app/future_extensions.go` | `CreatePublicTransparencyLog` | `persistLocked` | +| future extensions and generated reports | `internal/app/future_extensions.go` | `CreateQuestionnaireDraft` | `persistLocked` | +| future extensions and generated reports | `internal/app/future_extensions.go` | `CreateSaaSEditionProfile` | `persistLocked` | +| future extensions and generated reports | `internal/app/future_extensions.go` | `CreateSigningOperation` | `persistLocked` | +| future extensions and generated reports | `internal/app/future_extensions.go` | `GenerateAnomalyReport` | `persistLocked` | +| future extensions and generated reports | `internal/app/future_extensions.go` | `PublishPublicTransparencyLogEntry` | `persistLocked` | +| future extensions and generated reports | `internal/app/future_extensions.go` | `VerifyProviderIdentity` | `persistLocked` | +| future extensions and generated reports | `internal/app/future_extensions.go` | `VerifyPublicTransparencyLogEntry` | `persistLocked` | +| governance, packages, and package reports | `internal/app/governance_packages.go` | `AccessCustomerSecurityPackage` | `persistLocked` | +| governance, packages, and package reports | `internal/app/governance_packages.go` | `ApproveWaiver` | `persistLocked` | +| governance, packages, and package reports | `internal/app/governance_packages.go` | `CRAReadinessHTMLPackage` | `persistLocked` | +| governance, packages, and package reports | `internal/app/governance_packages.go` | `CreateApprovalRecord` | `persistLocked` | +| governance, packages, and package reports | `internal/app/governance_packages.go` | `CreateCustomReportTemplate` | `persistLocked` | +| governance, packages, and package reports | `internal/app/governance_packages.go` | `CreateCustomerSecurityPackage` | `persistLocked` | +| governance, packages, and package reports | `internal/app/governance_packages.go` | `CreateDSSETrustRoot` | `persistLocked` | +| governance, packages, and package reports | `internal/app/governance_packages.go` | `CreateRedactionProfile` | `persistLocked` | +| governance, packages, and package reports | `internal/app/governance_packages.go` | `CreateWaiver` | `persistLocked` | +| governance, packages, and package reports | `internal/app/governance_packages.go` | `ExportEvidenceBundle` | `persistLocked` | +| governance, packages, and package reports | `internal/app/governance_packages.go` | `ImportEvidenceBundle` | `persistLocked` | +| governance, packages, and package reports | `internal/app/governance_packages.go` | `InstallControlFrameworkTemplatePack` | `persistLocked` | +| governance, packages, and package reports | `internal/app/governance_packages.go` | `RenderCustomReport` | `persistLocked` | +| governance, packages, and package reports | `internal/app/governance_packages.go` | `VerifyDSSEAttestationSignature` | `persistLocked` | +| integrity and operations | `internal/app/integrity_runtime.go` | `CreateMerkleBatch` | `persistLocked` | +| integrity and operations | `internal/app/integrity_runtime.go` | `CreateObjectRetentionPolicy` | `persistLocked` | +| integrity and operations | `internal/app/integrity_runtime.go` | `CreateSigningProvider` | `persistLocked` | +| integrity and operations | `internal/app/integrity_runtime.go` | `CreateTransparencyCheckpoint` | `persistLocked` | +| integrity and operations | `internal/app/integrity_runtime.go` | `GenerateBackupManifest` | `persistLocked` | +| integrity and operations | `internal/app/integrity_runtime.go` | `RevokeSigningKey` | `persistLocked` | +| integrity and operations | `internal/app/integrity_runtime.go` | `VerifyBackupManifest` | `persistLocked` | +| integrity and operations | `internal/app/integrity_runtime.go` | `VerifyCosignSignature` | `persistLocked` | +| integrity and operations | `internal/app/integrity_runtime.go` | `VerifyMerkleBatch` | `persistLocked` | +| integrity and operations | `internal/app/integrity_runtime.go` | `VerifyObjectRetentionPolicy` | `persistLocked` | +| release extensions, source, and deployment | `internal/app/implementation_increments.go` | `CreateArtifactSignature` | `persistLocked` | +| release extensions, source, and deployment | `internal/app/implementation_increments.go` | `CreateDeploymentEnvironment` | `persistLocked` | +| release extensions, source, and deployment | `internal/app/implementation_increments.go` | `CreateReleaseCandidate` | `persistLocked` | +| release extensions, source, and deployment | `internal/app/implementation_increments.go` | `CreateSourceRepository` | `persistLocked` | +| release extensions, source, and deployment | `internal/app/implementation_increments.go` | `RecordDeployment` | `persistLocked` | +| release extensions, source, and deployment | `internal/app/implementation_increments.go` | `RecordPullRequest` | `persistLocked` | +| release extensions, source, and deployment | `internal/app/implementation_increments.go` | `RecordSourceCommit` | `persistLocked` | +| release extensions, source, and deployment | `internal/app/implementation_increments.go` | `RegisterContainerImage` | `persistLocked` | +| release extensions, source, and deployment | `internal/app/implementation_increments.go` | `UpdateReleaseCandidateState` | `persistLocked` | +| release extensions, source, and deployment | `internal/app/implementation_increments.go` | `UpsertSourceBranch` | `persistLocked` | +| release extensions, source, and deployment | `internal/app/implementation_increments.go` | `UpsertSourceBranch` | `persistLocked` | +| release ledger and signing | `internal/app/ledger.go` | `EvaluateRelease` | `persistLocked` | +| release ledger and signing | `internal/app/ledger.go` | `RotateSigningKey` | `persistLocked` | +| risk and security workflows | `internal/app/risk_workflows.go` | `CreateContractDiff` | `persistLocked` | +| risk and security workflows | `internal/app/risk_workflows.go` | `CreateCustomPolicy` | `persistLocked` | +| risk and security workflows | `internal/app/risk_workflows.go` | `CreateIncident` | `persistLocked` | +| risk and security workflows | `internal/app/risk_workflows.go` | `CreateIncidentWebhookReceiver` | `persistLocked` | +| risk and security workflows | `internal/app/risk_workflows.go` | `CreateRemediationTask` | `persistLocked` | +| risk and security workflows | `internal/app/risk_workflows.go` | `CreateSBOMDiff` | `persistLocked` | +| risk and security workflows | `internal/app/risk_workflows.go` | `EvaluateCustomPolicy` | `persistLocked` | +| risk and security workflows | `internal/app/risk_workflows.go` | `HandleIncidentWebhook` | `persistLocked` | +| risk and security workflows | `internal/app/risk_workflows.go` | `RecordIncidentTimelineEvent` | `persistLocked` | +| risk and security workflows | `internal/app/risk_workflows.go` | `RecordVulnerabilityWorkflow` | `persistLocked` | +| risk and security workflows | `internal/app/risk_workflows.go` | `UploadManualSecurityDocument` | `persistLocked` | +| risk and security workflows | `internal/app/risk_workflows.go` | `uploadSecurityScan` | `persistLocked` | + +## Next Decomposition Order + +1. Package and report writes: customer package access counters, generated HTML/PDF/report rows, evidence bundles, questionnaire outputs, and customer-facing access records. +2. Controls and governance writes: frameworks, controls, control evidence, waivers, approvals, and custom policies. +3. Build/source/deployment writes: build runs, attestations, source snapshots, release candidates, image/signature records, and deployment events. +4. Integrity and operations writes: signing providers, signing operations, Merkle batches, transparency checkpoints, retention policies, backup manifests, and verification receipts. +5. Future-extension rows: marketplace collectors, public transparency items, graph snapshots, SaaS profile records, PDF/anomaly reports, and AI/questionnaire draft artifacts. + +## Regression Checks + +- `go test ./internal/app -run TestRelationalStateStore` proves representative broad relational-state families avoid aggregate `SaveState` when `SaveRelationalState` is available. +- `go test ./internal/adapters/postgres -run 'TestApply(Critical|ReleaseLedger)Mutation'` proves focused mutations write relational tables and avoid the compatibility snapshot row. +- `make persistence-decomposition-check` regenerates this inventory and fails when the documented call-site map drifts. + +## Limitations + +This inventory is a repository-local decomposition map. It is not evidence of legal compliance, certification, complete operational hardening, or multi-writer API safety. diff --git a/docs/reference/production-exit-review.md b/docs/reference/production-exit-review.md new file mode 100644 index 0000000..c0015b9 --- /dev/null +++ b/docs/reference/production-exit-review.md @@ -0,0 +1,82 @@ +# Production Exit Review + +This review records the current release-positioning decision. It is a product +and operations review, not legal compliance proof, certification, complete SBOM +proof, an authoritative vulnerability result, or a secure-release guarantee. + +## Verdict + +Evydence remains a controlled self-hosted production candidate. Do not change +the public status to broad production-ready, regulated production-ready, hosted +SaaS-ready, legally compliant, certified, or secure-release-guaranteed. + +## Repo-Verified Strengths + +- API contract has 186 precise `/v1` operations and zero broad operations. +- `make production-check` is available and requires live PostgreSQL, coverage, + migration compatibility, release validation, race tests, security scans, and + release-signing smoke checks. +- `make coverage-check` now fails early without `EVYDENCE_TEST_DATABASE_URL` so + local no-database coverage cannot be mistaken for production release + evidence. +- Production API startup rejects in-process state, default pepper, unsupported + writer modes, writer replicas above one, local plaintext signing-key mode, + and bootstrap secret printing. +- Release-candidate packaging generates checksums, OpenAPI checksum, migration + checksum, release SBOM metadata, release provenance metadata, release notes, + signed manifest, and manifest signature. +- Public prerelease `v0.1.0-rc.7` is published with signed release archives, + checksums, OpenAPI and migration checksums, coverage output, + production-check summary, SBOM/provenance metadata, release notes, and signed + release manifest. +- PostgreSQL relational-only production loading and focused critical plus + release-ledger write paths are implemented for the current candidate profile. +- Security, support, governance, contribution, code of conduct, issue + templates, Dependabot, and Scorecard workflow files exist. +- CODEOWNERS and the maintainer review policy document expected review + ownership for high-risk code, release, deployment, and public-claim surfaces. +- The release-artifacts workflow keeps `GITHUB_TOKEN` read-only and separates + signed package generation from draft GitHub release publication. Draft + publication uses `EVYDENCE_RELEASE_PUBLISH_TOKEN` only in the publication job. +- The container-image workflow keeps `GITHUB_TOKEN` read-only, uses + `EVYDENCE_GHCR_PUBLISH_TOKEN` for GHCR publication, and scopes GitHub OIDC + `id-token: write` to the keyless cosign signing job. +- `make release-acceptance` and + `make public-release-verify TAG=v0.1.0-rc.7` have passed against the current + repository state. + +## Remaining Exit Blockers + +- Project-owned container images count as release evidence only after the + maintainer Container Image workflow has run for the tag and produced digest + plus cosign evidence. Operators who mirror or rebuild images for Helm or + air-gapped workflows must record their own image digest and verification + evidence. +- GitHub private vulnerability reporting, secret scanning, push protection, + Dependabot security updates, public CI, CodeQL, Scorecard, Scorecard SARIF, + and zero open code-scanning alerts have been verified for the current public + `master` state. Branch protection admin bypass state, repository secret + values, and workflow publication credentials remain provider/account checks. +- Native PKCS#11/HSM module custody requires operator hardware, drivers, and + provider-specific validation. +- Broad WORM/object-lock proof requires object-store policy, IAM, lifecycle, + backup, and provider audit evidence. +- Direct provider management APIs and external group synchronization remain + outside the current bring-your-own-evidence posture. +- Multi-writer API HA remains unsupported. The current reviewed stance is one + API writer replica. +- Target-environment backup/restore, incident-response, monitoring, and + capacity tests must be run by each operator. + +## Decision + +The current repository can support controlled self-hosted evaluation, pilots, +and internal production after operator review. The tracked +[production readiness audit closeout](production-readiness-audit-closeout.md) +and [highest achievable internal score](production-internal-score.md) support +keeping this controlled candidate status, not strengthening it. It should not +be marketed as broad production-ready for most uses until the external controls +above are closed, the +[Stable v0.1.0 exit criteria](stable-v0.1.0-exit-criteria.md) pass for a +concrete candidate, and a fresh product, codebase, security, documentation, and +test audit confirms the change. diff --git a/docs/reference/production-gate-troubleshooting.md b/docs/reference/production-gate-troubleshooting.md new file mode 100644 index 0000000..397e497 --- /dev/null +++ b/docs/reference/production-gate-troubleshooting.md @@ -0,0 +1,137 @@ +# Production Gate Troubleshooting + +This reference helps diagnose `make production-check` failures in disposable +readiness environments. Do not run these commands against production databases +or object stores unless your deployment runbook explicitly approves that action. + +`make production-check` is stricter than local development gates. It expects a +throwaway PostgreSQL database, signing smoke-test material generated by the +repository scripts, coverage at the configured threshold, migration +compatibility, release validation, black-box demo proof, and package +verification. + +## PostgreSQL URL + +Symptom: the gate exits early with `EVYDENCE_TEST_DATABASE_URL is required`. + +Use a disposable database URL: + +```sh +export EVYDENCE_TEST_DATABASE_URL='postgres://evydence:change-me@localhost:5432/evydence_test?sslmode=disable' +make live-postgres-check +``` + +Expected result: the target connects and verifies the schema can be used for +tests. If it fails, check network reachability, credentials, TLS mode, and that +the database is not a production database. + +For the local Compose profile: + +```sh +make release-check-local-postgres +``` + +This starts the repository Compose PostgreSQL service and runs the release +check with local test settings. + +## Migration Compatibility + +Symptom: migration compatibility fails for a migration prefix. + +Run the migration check against a disposable database: + +```sh +EVYDENCE_TEST_DATABASE_URL="$EVYDENCE_TEST_DATABASE_URL" make migration-compatibility-check +``` + +Expected result: every committed migration prefix can migrate forward to the +current schema. If a prefix fails, inspect the named migration and add a forward +migration rather than editing shipped migration history. + +## Coverage + +Symptom: `coverage-check` fails before running tests or reports coverage below +the threshold. + +The production coverage gate requires live PostgreSQL: + +```sh +EVYDENCE_TEST_DATABASE_URL="$EVYDENCE_TEST_DATABASE_URL" make coverage-check +``` + +Expected result: total statement coverage is at or above the default 80 percent +threshold. For local no-database exploration only: + +```sh +make coverage +``` + +Do not treat no-database `make coverage` output as production release evidence. + +## Black-Box Demo + +Symptom: `black-box-demo-check` fails during API startup, worker startup, +readiness, package generation, audit-chain verification, or restart checks. + +Run the demo directly with preserved artifacts: + +```sh +EVYDENCE_BLACK_BOX_KEEP_ARTIFACTS=1 \ +EVYDENCE_TEST_DATABASE_URL="$EVYDENCE_TEST_DATABASE_URL" \ +make black-box-demo-check +``` + +Expected result: `tmp/black-box-demo-check/` contains API logs, worker logs, and +demo JSON outputs for inspection. These files are local diagnostic artifacts and +must not contain bearer secrets or raw production evidence. + +## Release Signing Smoke + +Symptom: release packaging or signing smoke tests fail because signing material +is missing or invalid. + +Generate temporary local signing material for a disposable check: + +```sh +mkdir -p tmp/release-signing-smoke +go run ./cmd/evydence release keygen \ + --private-out tmp/release-signing-smoke/private.key \ + --public-out tmp/release-signing-smoke/public.key +``` + +Expected result: the private key is used only for local smoke testing and is not +committed. Real release signing material belongs in the release workflow secret +`EVYDENCE_RELEASE_SIGNING_PRIVATE_KEY_B64` or an approved signing environment. + +## Package Verification + +Symptom: package or public release verification fails. + +Verify the current public release-candidate package: + +```sh +make public-release-verify TAG=v0.1.0-rc.7 +``` + +Verify the checked local package fixture: + +```sh +go run ./cmd/evydence package verify \ + --archive examples/end-to-end-release-evidence/sample-customer-package.zip \ + --expected-package-id csp_example \ + --expected-product-id prod_example \ + --expected-release-id rel_example +``` + +Expected result: checksums, signed manifest material, package manifest fields, +and limitations validate. A passing package verification proves the exported +bytes match their manifest and verification material; it does not prove legal +compliance, certification, complete SBOM coverage, authoritative vulnerability +coverage, or release security. + +## Safe Escalation + +When reporting failures, include command names, exit codes, sanitized logs, +release tag, commit SHA, and whether the database was disposable. Do not include +API keys, bearer tokens, private keys, database URLs with credentials, customer +data, raw evidence payloads, object-store credentials, or provider secrets. diff --git a/docs/reference/production-internal-exit-checklist.md b/docs/reference/production-internal-exit-checklist.md new file mode 100644 index 0000000..f2e50cc --- /dev/null +++ b/docs/reference/production-internal-exit-checklist.md @@ -0,0 +1,70 @@ +# Internal Production-Readiness Exit Checklist + +This checklist defines what must be true before Evydence can say the +repository-local production-readiness and productization work from the current +analysis is complete. It does not make Evydence legally compliant, certified, +complete-SBOM-proven, scanner-authoritative, auditor-ready without review, or +secure-release-guaranteed. + +## Required Internal State + +- [ ] Every repository-local backlog ticket from the current + production/productization analysis is marked complete only after + implementation, documentation, validation, and commit. +- [ ] Every completed ticket has a Conventional Commit in Git history. +- [ ] Every behavior change updates relevant tests, docs, examples, OpenAPI, + migrations, release evidence, SDKs, or deployment assets when applicable. +- [ ] No known repo-local production-readiness finding is unmapped in + [Production readiness traceability](production-readiness-traceability.md). +- [ ] Ignored/local audit files are not required for public documentation to be + understandable. + +## Required Gates + +- [ ] `make finalize` passes. +- [ ] `make docs-check` passes. +- [ ] `make release-acceptance` passes. +- [ ] `make marketing-site-check` passes. +- [ ] `make package-viewer-check` passes. +- [ ] `make public-release-verify TAG=v0.1.0-rc.7` passes when public release + access is available. +- [ ] `make production-check` passes when `EVYDENCE_TEST_DATABASE_URL` and + release signing material are available. + +If live PostgreSQL, GitHub release access, provider credentials, or external +signing/verification services are unavailable, record that as an external +evidence gap. Do not silently count a skipped live dependency as an internal +pass. + +## Product-Language Checks + +- [ ] Public docs avoid claims that Evydence provides legal compliance, + certification, complete SBOM proof, authoritative scanner results, + regulator/auditor acceptance without review, or secure-release guarantees. +- [ ] Release evidence is described as reproducibility, integrity, + traceability, assumptions, gaps, exceptions, and limitations. +- [ ] Commercial pages state deliverables, support boundaries, exclusions, and + non-claims without public pricing unless intentionally added later. + +## External-Item Status + +Before assigning a maximum public production-readiness score, every external item must have a current owner and status: + +- [ ] public release state and release-asset availability; +- [ ] design-partner, pilot, or adoption proof; +- [ ] target production environment validation; +- [ ] live provider integration validation; +- [ ] hosted rendered API-doc and GitHub Pages/domain health; +- [ ] GitHub private vulnerability reporting or equivalent private intake; +- [ ] branch protection, required checks, Scorecard, code-scanning, secret + scanning, push protection, and Dependabot security settings; +- [ ] external security review; +- [ ] analytics privacy/account setting verification. + +## Closeout Rule + +The repository-local work can be called internally complete only after the +checklist above is satisfied, a fresh production-readiness audit finds no new repo-local remediation, +and any remaining blockers are explicitly external. +Public status must remain conservative until external evidence also supports a +stronger claim. diff --git a/docs/reference/production-internal-score.md b/docs/reference/production-internal-score.md new file mode 100644 index 0000000..8192e38 --- /dev/null +++ b/docs/reference/production-internal-score.md @@ -0,0 +1,78 @@ +# Highest Achievable Internal Production-Readiness Score + +This page summarizes the highest production-readiness score supported by +repository-local evidence after the internal backlog pass. It separates +repo-local completion from external public trust proof. It is not release +marketing, legal compliance proof, certification, complete SBOM proof, +authoritative vulnerability coverage, or a secure-release guarantee. + +## Internal Result + +The repository-local production/productization backlog is internally complete +for the current analysis: every known repo-local ticket has a tracked +implementation, documentation update, validation gate, and Conventional Commit. +The fresh audit closeout found no new repo-local remediation. + +The highest honest repository-local score is: + +| Dimension | Score | Reason | +| --- | ---: | --- | +| Production readiness | 8.2/10 | Controlled self-hosted candidate with strong repo gates, release evidence, deployment docs, and runtime hardening. | +| Feature completeness | 8.6/10 | Core release-evidence, package, verifier, readiness, and review workflows are implemented for controlled pilots. | +| Security and open-source trust | 8.0/10 | Strong repo hygiene and release integrity; provider/account security settings remain external. | +| Marketability and positioning | 8.3/10 | Clear buyer path, demo, package viewer, readiness offer, and adjacent-tool comparison; external adoption proof is absent. | +| Differentiation | 8.2/10 | Release-level evidence packaging and verification is meaningfully distinct from scanners, SBOM inventory, broad GRC, trust centers, and scripts. | +| Repository evidence quality | 8.8/10 | Extensive docs, gates, release assets, traceability, and local/public verification evidence. | +| Overall | 8.3/10 | Production-usable with caveats for controlled self-hosted use after operator review. | + +## Completed Internal Epics + +- Release truth, README status, and drift checks. +- Golden customer CVE demo, package viewer proof, and first-screen marketing + demo path. +- Buyer/operator docs, runbooks, upgrade policy, license decision table, and + first-contribution path. +- Production-like deployment docs, compose/Helm hardening, environment example, + capacity evidence, black-box release-artifact test, HA strategy, and + persistence decomposition evidence. +- Rendered OpenAPI docs, SDK quickstarts, GitHub Actions guide, and + tool-specific integration templates. +- CI/coverage/release evidence exposure, release asset smoke checks, and stable + `v0.1.0` exit criteria. +- Minimal reviewer UI, package proof summary, paid readiness offer, and + category differentiation content. +- Traceability matrix, internal exit checklist, fresh audit closeout, and this + score summary. + +## Remaining External Blockers + +These prevent a full public production-readiness score but are not repository +implementation tasks: + +- public release state and release-asset availability must be reverified after + every push/tag; +- current local commits must be pushed and public CI/Pages/CodeQL must be green + for the same SHA; +- GitHub private vulnerability reporting or an equivalent private intake channel + must be verified; +- branch protection, required checks, Scorecard, code-scanning, secret scanning, + push protection, Dependabot security settings, and repository credentials must + be verified in provider/account settings; +- a real design partner, pilot, or adoption proof must be obtained before using + adoption claims; +- live provider integrations and hosted rendered API docs must be validated in + their deployed environments; +- a hardened target production environment must be validated with operator-owned + PostgreSQL, object storage, TLS, backups, monitoring, signing custody, and + recovery controls; +- an external security review remains outside repository-local evidence; +- analytics privacy/account settings and custom-domain health remain external + website operations checks. + +## Status Boundary + +The correct public status remains controlled self-hosted production candidate. +Do not describe Evydence as broadly production-ready for most uses, regulated +production-ready, hosted SaaS-ready, legally compliant, certified, complete-SBOM +proven, scanner-authoritative, auditor-ready without review, or +secure-release-guaranteed based on repository-local evidence alone. diff --git a/docs/reference/production-readiness-audit-closeout.md b/docs/reference/production-readiness-audit-closeout.md new file mode 100644 index 0000000..bdc3f73 --- /dev/null +++ b/docs/reference/production-readiness-audit-closeout.md @@ -0,0 +1,177 @@ +# Production Readiness Audit Closeout + +This closeout records a fresh repository-local production-readiness audit after +the internal backlog pass. It is not release marketing, legal compliance proof, +certification, complete SBOM proof, authoritative vulnerability coverage, or a +secure-release guarantee. + +## Latest Visible State Inspected + +| Item | Evidence | +| --- | --- | +| Local repository | `/home/aatu/projects/evydence` | +| Local branch and commit | `master` at `041465bc48f7a8ea3d0536db423c617d9c8e026d` (`docs(production): add internal exit checklist`) | +| Public repository | `https://github.com/aatuh/evydence`, public, default branch `master`, AGPL-3.0 | +| Public branch state | GitHub reported latest pushed public `master` behind local by 32 commits at audit time. | +| Latest release checked | `v0.1.0-rc.7`, public prerelease published June 1, 2026, with archives, checksums, OpenAPI checksum, migration checksum, coverage, release summary, SBOM/provenance metadata, signed manifest, and manifest signature. | +| Public CI checked | Recent public CI, Marketing Site Pages, and CodeQL runs were visible as passing for the latest pushed public `master` state. | +| Verification run during audit | `make public-release-verify TAG=v0.1.0-rc.7` passed. | +| Not inspectable from repo files | GitHub private vulnerability reporting enablement, branch-protection admin-bypass settings, repository secret values, security setting toggles, analytics account settings, real buyer validation, external security review, and target-operator infrastructure. | + +Confidence is high for the local repository evidence inspected here and medium +for public production-readiness claims until the current local commits are +pushed and external settings are reverified. + +## What The Project Appears To Be + +Evydence is a self-hosted release evidence ledger for software teams that need +to organize SBOMs, vulnerability scans, VEX decisions, build/source evidence, +approvals, exceptions, audit-chain records, release bundles, and customer-safe +packages around a specific product release. + +The README now communicates the buyer question quickly: customers ask what is +inside a release, whether a CVE affects it, who reviewed the decision, and what +proof can be shared. The project presents as an API server, CLI, worker, +release-verification toolchain, package viewer, and self-hosted deployment +profile. It explicitly avoids claims of legal compliance, certification, +complete SBOMs, authoritative scanner results, regulator/auditor acceptance +without review, or secure releases. + +## Production Readiness + +Evydence has credible controlled self-hosted production-candidate evidence: + +- precise `/v1` OpenAPI contract and generated route matrix; +- API/worker/CLI tests, package viewer checks, release acceptance, and public + release verification; +- production startup validation, single-writer API stance, scalable worker + stance, PostgreSQL-backed persistence, migration compatibility, backup/restore + rehearsal, object-store checks, release signing, and release asset smoke + checks; +- Helm, Compose, Kubernetes, air-gap, configuration, capacity, observability, + runbook, and release-validation documentation; +- signed public prerelease artifacts and verifiable release assets. + +The strongest honest classification remains controlled self-hosted production +candidate. Broad production for most uses, regulated production, and hosted +SaaS production still require external environment proof, verified repository +security settings, external security review, and operator-specific controls. + +Score: 8.2/10. + +## Feature Completeness + +The core product path is implemented deeply enough for a real controlled pilot: +release evidence ingestion, SBOM/scan/VEX/decision handling, release readiness, +bundles, package generation, package viewer, source/build/deployment/security +evidence families, control reports, package verification, and release evidence +workflows. + +Remaining gaps are mostly external adoption/proof or higher-trust deployment +dependencies rather than missing repository-local product surfaces. The product +is not a broad GRC suite, scanner, trust center, hosted SaaS, or legal +compliance engine, and the docs say so. + +Score: 8.6/10. + +## Security And Open-Source Trust + +Strong repo signals include AGPL licensing, governance, contributing, support, +security policy, issue templates, CODEOWNERS, pinned workflows, Dependabot, +CodeQL, Scorecard workflow files, conservative release permissions, release +signing, package verification, redaction leakage checks, and no public claim +that release evidence proves compliance or security. + +External trust blockers remain: GitHub private vulnerability reporting must be +verified, branch protection/security settings are account-level checks, public +CI only proves pushed commits, and an independent security review is not present +in repository evidence. + +Score: 8.0/10. + +## Marketability And Positioning + +The product story is now sharp for self-hosted software vendors that face +customer release-security reviews. The demo path, package viewer, commercial +readiness offer, category comparison, and bilingual marketing site make the +value easier to evaluate. The dual-license/support path is plausible without +public pricing or unsupported compliance promises. + +The main marketability gap is external validation: no public adoption, customer +story, design-partner quote, or third-party review has been verified. + +Score: 8.3/10. + +## Differentiation + +The repository now explains its boundary next to Dependency-Track, GUAC, +OpenVEX tooling, Vanta, Drata, scanners, trust centers, and internal scripts. +The meaningful differentiation is release-level evidence packaging and +verification: tenant-scoped API records, append-only evidence behavior, package +redaction, signed bundles, audit-chain material, verifier commands, and +customer-safe review surfaces. + +The weak point is proof of adoption, not clarity of positioning. + +Score: 8.2/10. + +## Repository Quality Checklist + +| Area | Status | Evidence | Severity | Recommended fix | +| --- | --- | --- | --- | --- | +| README | Strong | Buyer question, demo path, status, release verification, comparisons, non-claims. | Low | Keep status synchronized with release truth. | +| Quickstart | Strong | Tutorials, local demo, GitHub/GitLab examples, SDK quickstarts. | Low | Add real pilot story when available. | +| Examples/demo | Strong | Customer CVE demo, package viewer, local CI simulation, sample package verification. | Low | Keep fixtures in sync with package schema. | +| Tests | Strong | `make finalize`, package, docs, OpenAPI, SDK, release, demo, and worker/app tests. | Low | Continue adding tests for any future public behavior. | +| CI | Adequate public, strong local | Public CI green for pushed state; local branch ahead. | Medium | Push current commits and confirm CI green for current HEAD. | +| Releases/provenance | Strong | `v0.1.0-rc.7` assets verified with `make public-release-verify`. | Low | Re-run after every release candidate. | +| Docs/API/config/ops | Strong | Reference docs, OpenAPI docs, operations/runbooks, production gate, traceability. | Low | Avoid drift from ignored audit files. | +| Docker/deployment | Strong for candidate | Compose, Helm, air-gap, hardened reference deployment, single-writer stance. | Low | Validate target operator environments externally. | +| Security policy | Adequate repo-local | `SECURITY.md` has safe intake rules and non-goals. | Medium | Verify private vulnerability reporting externally. | +| License/contribution/governance | Strong | AGPL, commercial model, governance, contributing, support, CODEOWNERS. | Low | Keep commercial claims conservative. | +| Changelog/versioning | Strong | Release notes, release truth checks, stable exit criteria. | Low | Update on each release-candidate tag. | + +## Scorecard + +| Dimension | Score | Notes | +| --- | ---: | --- | +| Production readiness | 8.2/10 | Controlled self-hosted candidate with strong repo evidence; not broad production. | +| Feature completeness | 8.6/10 | Core release evidence workflow is complete enough for controlled pilots. | +| Security and open-source trust | 8.0/10 | Strong repo hygiene; provider/account security settings remain external. | +| Marketability and positioning | 8.3/10 | Clear target user, demo, offer, and differentiation; adoption proof absent. | +| Differentiation | 8.2/10 | Release package verification is meaningfully distinct from adjacent tools. | +| Repository evidence quality | 8.8/10 | Extensive checks, docs, release evidence, and traceability. | +| Overall | 8.3/10 | Production-usable with caveats for controlled self-hosted use after operator review. | + +Confidence: medium-high for the local repository; medium for public state until +the current local commits are pushed and external settings are verified. + +Verdict: Production-usable with caveats. + +## Highest-Leverage Next Steps + +Next 1-2 days: + +- Push the current local commits and confirm public CI, Pages, CodeQL, and + marketing workflows are green for the same SHA. +- Verify GitHub private vulnerability reporting and branch protection settings. + +Next 1-2 weeks: + +- Run the paid readiness offer with a real design partner or buyer, then add + only sanitized, permissioned proof. +- Commission or perform an independent security review and publish a sanitized + summary. + +Next 1-2 months: + +- Validate a hardened target environment with external PostgreSQL, object + storage, backup/restore, monitoring, and signing custody. +- Re-run the stable `v0.1.0` exit criteria against a concrete candidate. + +## Fresh Audit Result + +The fresh audit found no new repo-local remediation that is not already covered +by completed tickets or existing external blockers. If a later audit finds a +new repository-local issue, add it to the traceability matrix and backlog before +claiming internal closure. diff --git a/docs/reference/production-readiness-traceability.md b/docs/reference/production-readiness-traceability.md new file mode 100644 index 0000000..9fb492f --- /dev/null +++ b/docs/reference/production-readiness-traceability.md @@ -0,0 +1,53 @@ +# Production Readiness Traceability + +This matrix maps the production-readiness and productization findings tracked +for Evydence to repository-local evidence or external blockers. It is an +engineering closeout artifact, not release marketing, legal compliance proof, +certification, complete SBOM proof, authoritative vulnerability coverage, or a +secure-release guarantee. + +## Source Scope + +The matrix is based on repository-owned files, current Makefile gates, committed +docs, release metadata checks, and the local production/productization backlog. +Provider settings, public adoption, real customer validation, live hosted +domain state, and third-party security review remain external evidence sources. + +## Traceability Matrix + +| Finding area | Repo-local ticket coverage | Current status | Repository evidence | +| --- | --- | --- | --- | +| Release truth and public status | E1-T1, E1-T2, E1-T3 | Complete locally; published release verification remains external. | `docs/reference/release-candidate.md`, `docs/reference/release-evidence-index.md`, `scripts/check_release_truth.py`, `make release-truth-check`. | +| Golden customer CVE review proof | E2-T1, E2-T2, E2-T3 | Complete locally; real design-partner story and adoption proof remain external. | `examples/customer-cve-review-demo/`, `docs/tutorials/customer-cve-review-demo.md`, `site/package-viewer/index.html`, `make customer-cve-review-demo-check`, `make package-viewer-check`. | +| Documentation and positioning clarity | E3-T1 through E3-T6 | Complete locally. | `README.md`, `docs/buyer-overview.md`, `docs/operator-overview.md`, `docs/runbooks/`, `COMMERCIAL.md`, `CONTRIBUTING.md`, `make docs-check`. | +| Deployment, runtime, and capacity proof | E4-T1 through E4-T7 | Complete locally for the controlled candidate profile; hardened target environment validation remains external. | `compose.production-like.yml`, `docs/reference/hardened-reference-deployment.md`, `.production.env.example`, `docs/reference/benchmark-results.md`, `scripts/black_box_release_artifact_check.sh`, `docs/reference/ha-strategy.md`, `docs/reference/persistence-decomposition.md`. | +| Integrations and API inspection | E5-T1 through E5-T4 | Complete locally; live provider validation and hosted API-doc publication remain external. | `docs/openapi/index.html`, `site/marketing/public/api/index.html`, `docs/sdk/quickstarts.md`, `docs/github-actions/end-to-end-release-evidence.md`, `docs/integrations/tool-templates.md`, `make openapi-check`, `make sdk-check`. | +| Security intake and public trust | E6-T4, E6-T5, E6-T7 | Repo-local release evidence and exit criteria are complete; private vulnerability reporting, branch protection, external review, and account settings remain external. | `SECURITY.md`, `docs/reference/stable-v0.1.0-exit-criteria.md`, `scripts/release_asset_smoke_check.sh`, `make release-acceptance`, `make public-release-verify TAG=v0.1.0-rc.7`. | +| Reviewer UX and commercial packaging | E7-T1, E7-T2, E7-T3, E7-T5 | Complete locally; real buyer validation remains external. | `site/package-viewer/index.html`, `docs/how-to/view-packages.md`, `COMMERCIAL.md`, `docs/commercial/product-landing-copy.md`, `docs/commercial/category-comparison.md`, `site/marketing/src/data/site.ts`, `make package-viewer-check`, `make marketing-site-check`. | +| External trust and adoption tracking | E8-T1 through E8-T4 | External. | Tracked as provider/customer/reviewer work only; repository files cannot prove public adoption, GitHub Pages health, analytics account settings, or third-party review completion. | +| Perfect-score internal closure | E9-T1 through E9-T4 | Complete locally; external blockers remain visible. | This file, `docs/reference/production-internal-exit-checklist.md`, `docs/reference/production-readiness-audit-closeout.md`, `docs/reference/production-internal-score.md`, and final docs/finalize gates. | + +## External Blockers + +These items are not hidden repo work. They require provider account access, +public repository state, a real buyer/reviewer, or third-party participation: + +- verified public release state and release-asset availability; +- real design-partner or pilot story and adoption proof; +- hardened target production environment validation; +- live GitHub/GitLab/provider integration validation; +- hosted rendered API-doc deployment health; +- GitHub private vulnerability reporting or equivalent private intake + verification; +- branch protection, required checks, Scorecard, and repository-security setting + verification; +- external security review; +- real buyer validation of the paid readiness offer; +- GitHub Pages/domain health and analytics privacy setting verification. + +## Unmapped Internal Findings + +At the time this matrix was written, no known repository-local finding from the +current production/productization backlog remains unmapped. A fresh audit can +invalidate that statement. If it finds new repo-local work, add a new tracked +ticket before claiming internal closure. diff --git a/docs/reference/production-readiness.md b/docs/reference/production-readiness.md new file mode 100644 index 0000000..089bb42 --- /dev/null +++ b/docs/reference/production-readiness.md @@ -0,0 +1,276 @@ +# Production Readiness + +This reference defines the self-hosted production bar for Evydence. It is a +gate for engineering and operator review. It does not prove legal compliance, +certification, complete vulnerability detection, complete SBOM coverage, or +release security. + +## Current Status + +Evydence is in controlled self-hosted production candidate hardening. It is +suitable for evaluation, pilots, and controlled internal production after +operator review. Broad production for most uses, regulated production, and +hosted SaaS production remain out of scope until the production gate, +release-candidate checklist, and exit review pass with the required deployment +controls. + +Known hardening work remains: + +- canonical production persistence still needs hand-tuned relational repository + paths for all resource families. PostgreSQL now maintains + relational identity, idempotency, customer portal token, release-ledger core, + build provenance, source/deployment, incident, security evidence, SBOM diff, + vulnerability workflow, contract diff, custom policy, waiver, approval, DSSE + trust-root, collector release, Cosign verification, signing provider, Merkle + batch, transparency checkpoint, evidence lifecycle, release candidate, + VEX/risk decision, control, audit-chain, signing, bundle, policy, + verification, package, report, retention, provider verification, signing + operation, and future-extension rows alongside the compatibility snapshot. + When `ENV=production` and `EVYDENCE_POSTGRES_LOAD_MODE` is unset, API and + worker startup load from relational reconstruction only. Production refuses + snapshot fallback modes and disables compatibility snapshot writes; local + development still defaults to snapshot-preferred loading and snapshot writes. + Critical runtime mutations for tenants, API-key hashes, SSO-session hashes, + customer-portal token hashes, idempotency records, audit-chain entries, + signing keys, signatures, release bundles, verification results, provider + verification receipts, vulnerability decisions, and outbox jobs now use + focused PostgreSQL transactions when that store is configured. Release-ledger + and evidence-core mutations for products, projects, releases, artifacts, + evidence items, evidence lifecycle events, SBOMs, vulnerability scans, + OpenAPI contracts, VEX documents, audit-chain entries, and parser outbox jobs + also use focused PostgreSQL transactions when that store is configured. + Remaining aggregate persistence calls now use PostgreSQL relational + synchronization without writing the compatibility snapshot when that store is + configured. If the snapshot row is absent, the store can rebuild identity, + SSO session, + customer portal token, release-ledger core, + build provenance, source/deployment, incident, security evidence, SBOM diff, + vulnerability workflow, contract diff, custom policy, waiver, approval, DSSE + trust-root, collector release, Cosign verification, signing provider, Merkle + batch, transparency checkpoint, evidence lifecycle, release candidate, + VEX/risk decision, control, package, report, retention, provider verification, + signing operation, and future-extension state from relational rows; + the generated + [Persistence decomposition inventory](persistence-decomposition.md) is the + repository-owned map of focused mutation paths, remaining broad + relational-state call sites, and the next split order; +- worker parser jobs re-read raw object-store payloads for key formats, + verify digests, validate durable state, and persist missing parser-derived + normalized fields. CycloneDX SBOM, generic vulnerability-scan, OpenAPI + contract, DSSE build-attestation, OpenVEX, and CycloneDX VEX uploads can run with + worker-owned parser side effects by setting + `EVYDENCE_WORKER_OWNED_PARSER_SIDE_EFFECTS=true`; VEX-derived + vulnerability decisions are created idempotently by the `parse_vex` worker in + that mode; +- OpenAPI precision is enforced across the registered public API. The generated + matrix remains the source of truth for operation ids, scopes, idempotency, + parameters, and request/response schemas; +- production signing can use the HTTPS signing gateway executor, built-in AWS + KMS, GCP Cloud KMS, Azure Key Vault signing executors, or gateway-backed + `pkcs11-hsm`. Tenant records can also capture a `native_pkcs11_hsm` custody + profile and a custody-review report, but native module loading/execution, + direct provider-specific management API clients/group synchronization, and + broad object-lock enforcement proof beyond recorded bucket plus sample-object + retention/legal-hold metadata remain provider- and deployment-dependent + hardening areas. SSO credential exchange can issue bearer sessions and + HttpOnly cookies after local OIDC/SAML verification against configured trust + material, OIDC group claim values can map to session-scoped roles without + creating permanent role bindings, provider verification can optionally call a + discovered OIDC UserInfo endpoint or an operator-controlled provider + validation gateway when a caller supplies an access token. The gateway + receives only non-secret metadata and an access-token-present flag. Public + transparency proof material can be fetched from a configured endpoint or an + operator-controlled transparency proof gateway and verified locally, but + provider-specific trust semantics and availability remain deployment + responsibilities; +- the broader production exit review remains incomplete. + +## Production Profiles + +| Profile | Status | Required controls | +| --- | --- | --- | +| Small internal self-hosted production | Candidate after gate passes | PostgreSQL, object storage, TLS, non-default API-key pepper, externalized secrets, backups, restore test, monitoring, `make production-check`, and operator review. | +| Regulated self-hosted production | Requires extra review | All small-production controls plus KMS/HSM or equivalent signing custody, retention policy review, SSO review, object-lock review where required, incident runbook, and documented control limitations. | +| Air-gapped production | Requires transfer controls | All small-production controls plus signed offline artifacts, import/export verification, local registry or package mirror, offline docs, and explicit backup/restore procedure. | +| Hosted SaaS production | Out of scope for this profile | Requires separate hosted tenancy, SLO, abuse, billing, privacy, support, and cloud operations controls before any SaaS production claim. | + +Use the [external controls matrix](external-controls-matrix.md) to separate +repo-owned checks from operator, provider, and legal/review responsibilities +before applying these profiles to regulated or high-trust deployments. +Use [Hardened reference deployment](hardened-reference-deployment.md) for the +controlled self-hosted topology that these profiles build from. + +## HA And Concurrency Contract + +The current self-hosted production profile supports one API writer replica. +This is the supported stance for the current release line, not an accidental +default. The application still uses a large in-process ledger aggregate and +some resource families still use aggregate persistence. Focused critical and +release-ledger mutations reduce the riskiest `SaveState` dependence, but they +are not a full multi-writer concurrency design. When `ENV=production`, API +startup rejects unsupported `EVYDENCE_API_WRITER_MODE` values and rejects +`EVYDENCE_API_WRITER_REPLICAS` values above `1`. When PostgreSQL is configured, +startup also takes a PostgreSQL advisory writer lease and fails if another API +writer already holds it. Do not scale API writer replicas above one for +production use until focused relational repository writes cover all write +families or another reviewed concurrency-control design is implemented and +documented. + +Worker replicas may be scaled because persisted outbox jobs are claimed with +PostgreSQL row locking. Scaling workers increases parser/signing/report +throughput; it does not make API writes multi-writer safe. + +The current decision is that one API writer is acceptable for controlled +self-hosted deployments with monitored process restart, backup/restore +rehearsal, and maintenance-window expectations. Multi-writer API HA is required +before any future broad production or hosted SaaS claim. The roadmap records +that backlog; see [HA strategy](ha-strategy.md), +[Roadmap and release cadence](roadmap.md), and [Capacity and failure modes](capacity-and-failures.md). + +## Machine Gate + +Run the production gate from the repository root: + +```sh +make production-check +``` + +The gate requires: + +- `EVYDENCE_TEST_DATABASE_URL` set to a disposable PostgreSQL database; +- `make release-check` passing without skipped live PostgreSQL checks; +- `make coverage-check` passing at the configured threshold; +- migration compatibility from every committed migration prefix to the current + schema passing in temporary PostgreSQL schemas; +- live PostgreSQL backup/restore rehearsal preserving ledger state, object + payload digests, backup-manifest verification, and release-bundle + verification after restore; +- release artifact signing smoke test passing with local temporary keys; +- generated release evidence summary available under `tmp/`. + +Release-candidate tagging additionally requires the evidence set in +[Release candidate checklist](release-candidate.md), including OpenAPI and +migration checksums, signed artifact manifests, artifact checksums, and release +notes with limitations. Moving from the release-candidate line to stable +`v0.1.0` additionally requires +[Stable v0.1.0 exit criteria](stable-v0.1.0-exit-criteria.md). + +The default production coverage threshold is 80 percent and the gate requires +`EVYDENCE_TEST_DATABASE_URL` so live PostgreSQL adapter tests are included: + +```sh +make coverage-check +EVYDENCE_COVERAGE_THRESHOLD=85 make coverage-check +``` + +For local no-PostgreSQL exploration, use `make coverage`; do not treat that +output as production release evidence. + +`make production-check` is intentionally stricter than `make finalize`. Use +`make finalize` for routine local development. Use `make production-check` for +self-hosted production readiness evidence. + +If the gate fails, use +[Production gate troubleshooting](production-gate-troubleshooting.md) with a +disposable database and sanitized logs. + +## Exit Criteria + +Do not describe an Evydence build as broadly self-hosted production-ready until: + +- `make production-check` passes in CI with live PostgreSQL; +- `make black-box-demo-check` passes against a disposable PostgreSQL schema, + exercising API, worker, restart persistence, readiness, package, and + audit-chain verification paths; +- `make black-box-release-artifact-check` passes against release-style local + binaries, disposable PostgreSQL, filesystem object storage, API restart, and + package/readiness/audit-chain verification paths; +- coverage is at or above the configured threshold; +- release artifacts have signed manifests and published checksums; +- committed migrations have passed compatibility checks from every migration + prefix to the current schema; +- the built-in local restore rehearsal passes and backup/restore have been + tested for the target deployment profile; +- the backup/restore, upgrade, incident-response, and capacity runbooks have + been reviewed against the target deployment; +- OpenAPI, OpenAPI precision, route-contract, and SDK drift checks pass; +- the HA story for the target profile is documented and reviewed; +- production hardening review is current; +- production exit review is current; +- stable `v0.1.0` exit criteria pass when moving out of the release-candidate + line; +- unresolved limitations are documented in release notes. + +## Remaining Production Maturity Backlog + +These items are tracked here and in [Roadmap and release cadence](roadmap.md) +because they are hardening work on already implemented capabilities: + +- Continue splitting the large application ledger into focused services while + preserving the dependency-ordered relational persistence paths now in place. + Focused transaction-backed writes now cover tenants, API-key hashes, + SSO-session hashes, customer-portal token hashes, idempotency records, + audit-chain entries, signing keys, signatures, release bundles, verification + results, provider verification receipts, vulnerability decisions, and outbox + jobs, plus release-ledger and evidence-core writes for products, projects, + releases, artifacts, evidence items, evidence lifecycle events, SBOMs, + vulnerability scans, OpenAPI contracts, VEX documents, audit-chain entries, + and parser outbox jobs. Relational row synchronization also covers identity, + idempotency, customer portal token, release-ledger core, build provenance, + source/deployment, incident, security evidence, SBOM diff, vulnerability + workflow, contract diff, custom policy, waiver, approval, DSSE trust-root, + collector release, Cosign verification, signing provider, Merkle batch, + transparency checkpoint, evidence lifecycle, release candidate, VEX/risk + decision, control, audit-chain, signing, bundle, policy, verification, + package, report, retention, provider verification, signing operation, and + future-extension rows are synchronized into relational tables. Production + startup defaults to relational-only loading, production writes skip the + compatibility snapshot, and missing-snapshot recovery can rebuild identity, + SSO session, customer portal token, + release-ledger core, build provenance, source/deployment, incident, security + evidence, SBOM diff, vulnerability workflow, contract diff, custom policy, + waiver, approval, DSSE trust-root, collector release, Cosign verification, + signing provider, Merkle batch, transparency checkpoint, evidence lifecycle, + release candidate, VEX/risk decision, control, package, report, retention, + provider verification, signing operation, and future-extension families from + relational rows. Snapshots remain for local compatibility, export/import, and + non-production migration checks. +- Split the large application ledger aggregate into focused services once the + next service-boundary review is complete, preserving tenant isolation and + append-only behavior throughout. +- Keep worker-owned parser side effects covered as parser formats evolve. + CycloneDX SBOM, generic vulnerability scan, OpenAPI contract, DSSE + build-attestation, OpenVEX document metadata, CycloneDX VEX document metadata, + and VEX-derived vulnerability decisions can be worker-owned behind + `EVYDENCE_WORKER_OWNED_PARSER_SIDE_EFFECTS=true`; SBOM, scan, OpenAPI, and + VEX replay side effects use focused release-ledger mutations when the + PostgreSQL store supports them. +- Keep OpenAPI precision at zero broad operations as routes are added or + changed, and expand generated SDK coverage from the committed contract. +- Add native PKCS#11/HSM module execution where required by the deployment + profile. The current HTTPS signing gateway executor covers deployments that + put HSM custody behind a tenant-controlled signing service and do not send raw + payload bytes, while `native_pkcs11_hsm` records capture review metadata only. + AWS KMS, GCP Cloud KMS, and Azure Key Vault direct executors sign stored + SHA-256 payload hashes through their provider APIs. +- Complete direct provider-specific management API clients and external group + synchronization where those profiles are enabled. OIDC discovery/JWKS refresh + is implemented for public trust-material updates, manual JWKS and SAML + signing-certificate rotation is implemented through the SSO provider + trust-material endpoint, provider verification can optionally call OIDC + UserInfo or an operator-controlled provider validation gateway when a caller + supplies an access token and record group-claim checks without storing or + forwarding that token to the gateway, + SSO credential exchange can issue bearer sessions plus HttpOnly cookies after + local token or assertion verification, OIDC group claim values can map to + session-scoped roles, and API-first session logout can revoke the current SSO + bearer session. +- Extend object-lock/WORM verification beyond the current S3/MinIO bucket-level + checks plus optional sample-object retention and legal-hold checks where + deployments require broader provider policy evidence. +- Run final product, codebase, security, documentation, and test audits before + changing release status beyond controlled self-hosted production candidate. + +Operators remain responsible for secret management, TLS, network policy, +database and object-store durability, backups, restore rehearsals, monitoring, +provider configuration, incident response, and external review. diff --git a/docs/reference/release-candidate.md b/docs/reference/release-candidate.md new file mode 100644 index 0000000..7c8a3c9 --- /dev/null +++ b/docs/reference/release-candidate.md @@ -0,0 +1,102 @@ +# Release Candidate Checklist + +This reference defines the minimum evidence for a controlled self-hosted +Evydence release candidate such as `v0.1.0-rc.1` or `v0.9.0-rc.1`. + +Release-candidate evidence supports reproducible engineering and operator +review. It is not a certification, legal compliance conclusion, complete SBOM +claim, authoritative vulnerability result, secure-release guarantee, regulator +acceptance, or auditor acceptance. + +## Required Evidence + +Before creating a release-candidate tag, collect: + +- passing `make production-check` output with live PostgreSQL configured; +- `tmp/release-check-summary.txt` from the same run; +- `coverage.out` and the total coverage summary; +- `openapi.yaml` plus an OpenAPI checksum; +- migration checksum output for the directory or per-file migration checksums; +- release SBOM metadata and release provenance metadata; +- signed release artifact manifest and manifest signature; +- checksums for every published binary, container image digest, chart package, + and release archive; +- release notes with supported profile, upgrade notes, assumptions, + limitations, and unresolved hardening work. + +The artifact map and verification commands are maintained in +[Release evidence index](release-evidence-index.md). + +## Required Commands + +Run from a clean checkout with a disposable PostgreSQL database: + +```sh +set -a; . ./.test.env; set +a +export EVYDENCE_RELEASE_SIGNING_PRIVATE_KEY_B64="$(cat evydence-release-private.key)" +make release-candidate-check TAG= +``` + +The target runs `scripts/release_candidate_package.sh`, which requires a clean +worktree, a release-candidate tag such as `v0.1.0-rc.1`, no existing local tag +unless the CI tag workflow explicitly allows it, live PostgreSQL through +`EVYDENCE_TEST_DATABASE_URL`, and release signing material. It runs +`make production-check`, builds the release archive matrix, writes checksums, +generates release SBOM and provenance metadata, signs the release manifest, +verifies the manifest signature, and validates the release-note language. + +Do not tag from a run where live PostgreSQL checks, migration compatibility, +coverage threshold enforcement, OpenAPI checks, docs checks, deployment checks, +SDK checks, lint, gosec, govulncheck, race tests, artifact checksums, or +manifest signature verification were skipped. + +## Supported Profile Statement + +Release notes must use this status unless the production exit review has +explicitly changed it: + +> Controlled self-hosted production candidate for evaluation, pilots, and +> controlled internal production after operator review. + +The notes must also state that broad production for most uses, regulated +production, and hosted SaaS production require additional review and controls. + +## Public Publication + +The current public release candidate, +[`v0.1.0-rc.7`](https://github.com/aatuh/evydence/releases/tag/v0.1.0-rc.7), +is published as a prerelease. Public release readiness requires a pushed tag, a +completed release-artifacts workflow run, uploaded release archives, checksums, +signed manifest files, release notes, and any configured repository or registry +trust settings. + +Release archives and release evidence are published by the Release Artifacts +workflow. Project-owned container images are published by the separate Container +Image workflow to `ghcr.io/aatuh/evydence:` when that workflow is run for a +release tag. Operators should pin the resulting digest, verify the cosign +evidence, and record that image digest with deployment evidence. If no image +workflow evidence exists for a tag, operators must build and publish images into +their own registry for Helm or air-gapped deployment flows. + +The release packaging workflow keeps the repository `GITHUB_TOKEN` read-only and +separates package generation from draft-release publication. Creating or +updating the draft GitHub release happens in a separate job and requires the +`EVYDENCE_RELEASE_PUBLISH_TOKEN` secret with only the release-publication scope +the maintainer account approves for that workflow. The container image workflow +also keeps `GITHUB_TOKEN` from receiving package-write permissions; publishing +to GHCR requires `EVYDENCE_GHCR_PUBLISH_TOKEN`, and only the separate signing +job receives `id-token: write` for keyless cosign signing. Both secrets are +external repository settings and must not be printed in logs, committed to +files, or included in release evidence. + +## Deployment Constraints + +- Use one API writer replica for the current production profile. +- Worker replicas may be scaled when PostgreSQL outbox locking is enabled. +- Use external PostgreSQL, external object storage, TLS ingress, non-default + API-key pepper, externalized secrets, backup and restore rehearsal, + monitoring, and documented incident response. +- Keep service decomposition, HA/multi-writer operation, native PKCS#11/HSM + modules, provider-specific management API/group synchronization, and broader + object-lock proof beyond configured bucket/sample-object checks listed as + unresolved hardening work until they are implemented and verified. diff --git a/docs/reference/release-evidence-index.md b/docs/reference/release-evidence-index.md new file mode 100644 index 0000000..78f6411 --- /dev/null +++ b/docs/reference/release-evidence-index.md @@ -0,0 +1,143 @@ +# Release Evidence Index + +This reference maps each release-candidate evidence artifact to the command +that creates or verifies it. Keep it with +[Release candidate checklist](release-candidate.md) and +[Release validation](release-validation.md) when preparing a controlled +self-hosted release candidate. + +Release evidence supports reproducible engineering and operator review. It is +not legal compliance proof, certification, complete SBOM proof, authoritative +vulnerability coverage, a secure-release guarantee, regulator acceptance, or +auditor acceptance. + +## Artifact Map + +`scripts/release_candidate_package.sh ` writes the release evidence set +under `dist//` after `make production-check` passes. + +| Artifact | Created By | Verification Use | +| --- | --- | --- | +| `evydence__.tar.gz` or `.zip` | `scripts/release_candidate_package.sh` | Operator installs the API, worker, migration command, and CLI from the release archive for the matching platform. | +| `SHA256SUMS` | `scripts/release_candidate_package.sh` | Verify every release archive byte-for-byte before extraction or redistribution. | +| `openapi.yaml` | `cmd/openapi` through the release package script | Preserve the exact public API contract shipped with the release. | +| `openapi.sha256` | `scripts/release_candidate_package.sh` | Verify the shipped OpenAPI contract has not drifted from the release evidence set. | +| `migrations.sha256` | `scripts/release_candidate_package.sh` | Verify the migration directory that the release was checked against. | +| `coverage.out` | `make production-check` | Preserve the coverage input used by the release gate. | +| `release-check-summary.txt` | `make release-check` inside `make production-check` | Record whether formatting, unit tests, OpenAPI, docs, deployment, SDK, lint, gosec, govulncheck, race, and live PostgreSQL checks passed. | +| `evydence-release-manifest.json` | `./evydence release manifest` through the package script | List release artifacts, hashes, OpenAPI checksum, migration checksum, and release metadata. | +| `evydence-release-manifest.sig.json` | `./evydence release sign` through the package script | Verify the release manifest signature with the release public key. | +| `evydence-release-manifest.sig` | Copied from `evydence-release-manifest.sig.json` by the package script | Scorecard-compatible alias for the signed manifest metadata. Use the `.sig.json` file with the Evydence CLI verifier. | +| `evydence-release-sbom.cdx.json` | `scripts/release_evidence_metadata.py` | Record release SBOM metadata and limitations; this does not prove SBOM completeness. | +| `evydence-release-provenance.json` | `scripts/release_evidence_metadata.py` | Record build/release provenance metadata and limitations; this does not prove provider trust by itself. | +| `evydence-release-provenance.intoto.jsonl` | `scripts/release_evidence_metadata.py` | Scorecard-compatible in-toto statement with the same Evydence release provenance limitations. This is not a SLSA level claim. | +| `release-notes.md` | Tag-specific release notes or `docs/reference/release-notes-template.md` | State supported profile, upgrade notes, assumptions, limitations, and unresolved hardening work. | +| `ghcr.io/aatuh/evydence:` | `.github/workflows/container-image.yml` | Optional project-owned image publication path for a release tag. Verify by digest and cosign workflow identity; do not deploy by mutable tag alone. | +| `evydence-container-image-manifest.json` | `.github/workflows/container-image.yml` | Records the image digest, source commit, cosign verification file, assumptions, and limitations for the image workflow run. | + +## Current Public Release Candidate + +The current public release candidate is +[`v0.1.0-rc.7`](https://github.com/aatuh/evydence/releases/tag/v0.1.0-rc.7). +It was built from tag `v0.1.0-rc.7` at commit +`2f759100e723554e4e68e3ada712c923e391a17a`; the public Release Artifacts +workflow run is +[`26769039974`](https://github.com/aatuh/evydence/actions/runs/26769039974). +The release commit also has a public CI `Production Check` run +[`26767778491`](https://github.com/aatuh/evydence/actions/runs/26767778491) +and CodeQL run +[`26767778487`](https://github.com/aatuh/evydence/actions/runs/26767778487). + +The public prerelease includes release archives for Linux, macOS, and Windows; +`SHA256SUMS`; `openapi.yaml`; `openapi.sha256`; `migrations.sha256`; +`coverage.out`; `release-check-summary.txt`; `evydence-release-sbom.cdx.json`; +`evydence-release-provenance.json`; +`evydence-release-provenance.intoto.jsonl`; `release-notes.md`; +`evydence-release-manifest.json`; `evydence-release-manifest.sig.json`; and +`evydence-release-manifest.sig`. + +Project-owned container images are separate release evidence. At the time this +index was updated, the latest verified project-owned container image evidence +for the release-candidate line was +`ghcr.io/aatuh/evydence:v0.1.0-rc.5@sha256:38188044a3e5ded3c6094564ab39ce989185f65e22cf4296985ec19ba0eb1888`. +It was produced by the Container Image workflow run +[`26752805538`](https://github.com/aatuh/evydence/actions/runs/26752805538) +from release source commit `d5098c635ee7b171dac3a449ba7ca2e30d8b0c16`. +That image is not an `v0.1.0-rc.7` image. For `v0.1.0-rc.7`, use release +archives or publish and verify a tag-specific image before treating it as +deployment evidence. A tag-specific image workflow run should attach +`evydence-container-image-manifest.json` and +`evydence-container-image-cosign-verify.json` when available. + +## Local Verification + +After packaging, verify the evidence directory before publishing: + +```sh +gh release download v0.1.0-rc.7 --repo aatuh/evydence --dir dist/v0.1.0-rc.7 +(cd dist/v0.1.0-rc.7 && sha256sum -c SHA256SUMS) +(cd dist/v0.1.0-rc.7 && sha256sum -c openapi.sha256) +sha256sum -c dist/v0.1.0-rc.7/migrations.sha256 +tar -C dist/v0.1.0-rc.7 -xzf dist/v0.1.0-rc.7/evydence_v0.1.0-rc.7_linux_amd64.tar.gz +./dist/v0.1.0-rc.7/evydence_v0.1.0-rc.7_linux_amd64/evydence release verify \ + --manifest dist/v0.1.0-rc.7/evydence-release-manifest.json \ + --signature dist/v0.1.0-rc.7/evydence-release-manifest.sig.json +``` + +Use the platform-specific `evydence` binary from the release archive whenever +possible so the verifier matches the released toolchain. If you verify from a +source checkout instead, record that limitation in the release notes. + +To verify the already-published public release from a clean temporary directory, +run: + +```sh +make public-release-verify TAG=v0.1.0-rc.7 +``` + +The helper downloads the release assets with `gh`, checks the public checksums, +validates the in-toto statement shape, extracts the released Linux amd64 CLI, +and verifies the signed manifest with that released binary. + +For local release asset smoke validation without live GitHub Releases or full +release-candidate packaging, run: + +```sh +make release-asset-smoke-check +``` + +The smoke check verifies a synthetic release asset set, checksum mismatch +failure, missing-asset failure, signed manifest verification, tampered manifest failure, +sample customer package verification output, and wrong expected package ID failure. + +To verify the last checked public container image digest and workflow identity, +run: + +```sh +docker buildx imagetools inspect ghcr.io/aatuh/evydence:v0.1.0-rc.5 \ + --format '{{json .Manifest.Digest}}' +cosign verify \ + --certificate-identity-regexp 'https://github.com/aatuh/evydence/.github/workflows/container-image.yml@.*' \ + --certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \ + ghcr.io/aatuh/evydence@sha256:38188044a3e5ded3c6094564ab39ce989185f65e22cf4296985ec19ba0eb1888 +``` + +Expected digest: +`sha256:38188044a3e5ded3c6094564ab39ce989185f65e22cf4296985ec19ba0eb1888`. +The cosign verification proves the configured workflow identity signed the +image digest; it is not legal compliance proof, certification, complete SBOM +proof, authoritative vulnerability coverage, or a secure-release guarantee. + +## Publication Status + +The first public release candidate is published. GitHub Releases are now the +operator evaluation source for release-candidate binaries and release evidence. +Source checkout remains the development path. + +Project-owned container images are published separately from the release archive +workflow through `.github/workflows/container-image.yml` as +`ghcr.io/aatuh/evydence:`. Treat an image as release evidence only when the +workflow has produced an immutable digest and +`evydence-container-image-manifest.json` for that tag. Operators who mirror or +rebuild images for Kubernetes or air-gapped workflows must record the resulting +digest with their deployment evidence. diff --git a/docs/reference/release-notes-template.md b/docs/reference/release-notes-template.md new file mode 100644 index 0000000..fd84209 --- /dev/null +++ b/docs/reference/release-notes-template.md @@ -0,0 +1,60 @@ +# Evydence {{TAG}} Release Notes + +Status: Controlled self-hosted production candidate. + +This release candidate is intended for evaluation, pilots, and controlled +internal production after operator review. It is not legal compliance proof, +not a certification, not a secure-release guarantee, not complete SBOM proof, +not authoritative vulnerability coverage, and not auditor or regulator +acceptance. + +## Supported Profile + +- Self-hosted API, worker, PostgreSQL, and object storage deployment. +- Use a single API writer replica for the current production profile. +- Worker replicas may scale through PostgreSQL outbox row locking. +- External TLS, secret management, backup automation, monitoring, and incident + response remain operator responsibilities. + +## Evidence In This Release Package + +- Passing `make production-check` evidence with live PostgreSQL. +- `release-check-summary.txt` from the same run. +- `coverage.out` and threshold evidence. +- `openapi.yaml` and `openapi.sha256`. +- `migrations.sha256`. +- `evydence-release-sbom.cdx.json` generated from Go module metadata; this is + release evidence, not complete SBOM proof. +- `evydence-release-provenance.json` with release-candidate packaging inputs; + this is not a SLSA level claim. +- `SHA256SUMS` for release archives and evidence files. +- Signed release artifact manifest and manifest signature. + +## Install And Upgrade Notes + +- Use PostgreSQL for durable runtime state. +- Use S3/MinIO-compatible object storage or the documented filesystem mode for + local evaluation only. +- Set `ENV=production`, a non-default `EVYDENCE_API_KEY_PEPPER`, and a + production signing mode such as `external`, `aws-kms`, `gcp-kms`, + `azure-key-vault`, or gateway-backed `pkcs11-hsm` for production-profile + startup. +- Apply all committed migrations before starting API or worker processes. +- For Kubernetes, set an explicit image tag or digest and keep API replicas at + `1` until HA/multi-writer support is reviewed. + +## Known Limitations + +- Full repository decomposition remains hardening work; focused critical + PostgreSQL writes cover the highest-risk runtime mutations, but not every + resource family. +- HA/multi-writer API operation is not supported in this profile. +- AWS KMS, GCP Cloud KMS, and Azure Key Vault signing executors are included; + native PKCS#11/HSM modules remain gateway-backed and deployment-specific. +- OIDC UserInfo validation is available when a caller supplies an access token; + provider-specific management API validation and external group + synchronization remain deployment-dependent. +- Broader WORM/object-lock proof beyond configured S3/MinIO bucket and + sample-object retention/legal-hold checks remains deployment-dependent. +- Operators must run backup and restore rehearsals for their target + infrastructure before relying on this in production. diff --git a/docs/reference/release-notes-v0.1.0-rc.1.md b/docs/reference/release-notes-v0.1.0-rc.1.md new file mode 100644 index 0000000..327cabf --- /dev/null +++ b/docs/reference/release-notes-v0.1.0-rc.1.md @@ -0,0 +1,60 @@ +# Evydence v0.1.0-rc.1 Release Notes + +Status: Controlled self-hosted production candidate. + +This release candidate is intended for evaluation, pilots, and controlled +internal production after operator review. It is not legal compliance proof, +not a certification, not a secure-release guarantee, not complete SBOM proof, +not authoritative vulnerability coverage, and not auditor or regulator +acceptance. + +## Supported Profile + +- Self-hosted API, worker, PostgreSQL, and object storage deployment. +- Use a single API writer replica for the current production profile. +- Worker replicas may scale through PostgreSQL outbox row locking. +- External TLS, secret management, backup automation, monitoring, and incident + response remain operator responsibilities. + +## Evidence In This Release Package + +- Passing `make production-check` evidence with live PostgreSQL. +- `release-check-summary.txt` from the same run. +- `coverage.out` and threshold evidence. +- `openapi.yaml` and `openapi.sha256`. +- `migrations.sha256`. +- `evydence-release-sbom.cdx.json` generated from Go module metadata; this is + release evidence, not complete SBOM proof. +- `evydence-release-provenance.json` with release-candidate packaging inputs; + this is not a SLSA level claim. +- `SHA256SUMS` for release archives and evidence files. +- Signed release artifact manifest and manifest signature. + +## Install And Upgrade Notes + +- Use PostgreSQL for durable runtime state. +- Use S3/MinIO-compatible object storage or the documented filesystem mode for + local evaluation only. +- Set `ENV=production`, a non-default `EVYDENCE_API_KEY_PEPPER`, and a + production signing mode such as `external`, `aws-kms`, `gcp-kms`, + `azure-key-vault`, or gateway-backed `pkcs11-hsm` for production-profile + startup. +- Apply all committed migrations before starting API or worker processes. +- For Kubernetes, set an explicit image tag or digest and keep API replicas at + `1` until HA/multi-writer support is reviewed. + +## Known Limitations + +- Full repository decomposition remains hardening work; focused critical + PostgreSQL writes cover the highest-risk runtime mutations, but not every + resource family. +- HA/multi-writer API operation is not supported in this profile. +- AWS KMS, GCP Cloud KMS, and Azure Key Vault signing executors are included; + native PKCS#11/HSM modules remain gateway-backed and deployment-specific. +- OIDC UserInfo validation is available when a caller supplies an access token; + provider-specific management API validation and external group + synchronization remain deployment-dependent. +- Broader WORM/object-lock proof beyond configured S3/MinIO bucket and + sample-object retention/legal-hold checks remains deployment-dependent. +- Operators must run backup and restore rehearsals for their target + infrastructure before relying on this in production. diff --git a/docs/reference/release-validation.md b/docs/reference/release-validation.md new file mode 100644 index 0000000..94935e0 --- /dev/null +++ b/docs/reference/release-validation.md @@ -0,0 +1,225 @@ +# Release Validation + +This reference describes the project-owned release validation profile. It is evidence for engineering review only; it does not prove legal compliance, certification, complete vulnerability detection, or secure releases. + +## Default Local Profile + +Run the default release gate from the repository root: + +```sh +make release-check +``` + +The target runs formatting, unit tests, OpenAPI drift checks, docs/deployment/SDK checks, release acceptance, linting, gosec, govulncheck, race tests, and the live PostgreSQL targets. When `EVYDENCE_TEST_DATABASE_URL` is unset, live PostgreSQL checks are explicitly skipped and the summary records that limitation. + +For the deterministic metadata-only release evidence gate, run: + +```sh +make release-acceptance +``` + +That target runs the fast local checks, verifies the root license, governance, support, trademark, release-evidence, changelog, and Docker build-context metadata, and rejects prohibited product claims in the documented surfaces. +It also runs `make release-asset-smoke-check`, which creates a synthetic local +release-evidence set, verifies checksum files, verifies a signed release +manifest, runs the customer package verifier against the checked sample package, +and confirms checksum, signature, missing-asset, and package-identity mismatch +failure cases are rejected. + +The target writes: + +```text +tmp/release-check-summary.txt +``` + +Keep this file with release evidence when preparing an internal release review. It records the pass/skip status for the gate families, including whether live PostgreSQL checks ran. + +## Production Readiness Gate + +For self-hosted production-readiness evidence, run: + +```sh +make production-check +``` + +That gate is stricter than `make release-check`: it requires +`EVYDENCE_TEST_DATABASE_URL`, rejects skipped live PostgreSQL checks, enforces +the configured coverage threshold, verifies every committed migration prefix can +upgrade to the current schema in a temporary PostgreSQL schema, runs the checked +release-evidence benchmark, starts an API and worker against a disposable +PostgreSQL schema for a black-box demo/restart persistence check, runs the same +black-box flow against release-style local binaries through +`make black-box-release-artifact-check`, and runs a release artifact signing +smoke test. See [Production readiness](production-readiness.md) for the +supported profiles and exit criteria. + +`make coverage-check` is intentionally part of the production profile and fails +early when `EVYDENCE_TEST_DATABASE_URL` is unset. Use `make coverage` for a +local no-database coverage report that is not release-candidate evidence. + +## Release Candidate Checklist + +Before tagging `v0.1.0-rc.1`, `v0.9.0-rc.1`, or another controlled +self-hosted release candidate, follow +[Release candidate checklist](release-candidate.md). The minimum evidence set +is: + +- passing `make production-check` with live PostgreSQL; +- `tmp/release-check-summary.txt` from the same run; +- coverage output and threshold result; +- OpenAPI checksum and migration checksums; +- release SBOM metadata and release provenance metadata; +- signed release artifact manifest, manifest signature, and artifact checksums; +- release notes that state supported profile, assumptions, limitations, + upgrade notes, and unresolved hardening work. + +Do not use release-candidate evidence as legal compliance proof, certification, +complete vulnerability coverage, complete SBOM proof, secure-release proof, or +auditor/regulator acceptance. + +The local packaging gate is: + +```sh +set -a; . ./.test.env; set +a +export EVYDENCE_RELEASE_SIGNING_PRIVATE_KEY_B64="$(cat evydence-release-private.key)" +make release-candidate-check TAG= +``` + +`scripts/release_candidate_package.sh` creates `dist//` with the +release archives, `SHA256SUMS`, `openapi.sha256`, `migrations.sha256`, +release SBOM metadata, release provenance metadata, `coverage.out`, +`release-check-summary.txt`, checked release notes, signed release manifest, +and manifest signature. It refuses dirty worktrees, invalid release-candidate +tags, missing live PostgreSQL configuration, missing signing material, and +existing local tags unless a CI tag build explicitly sets +`EVYDENCE_RELEASE_ALLOW_EXISTING_TAG=1`. + +For a lightweight local smoke check that does not require a clean worktree, +live PostgreSQL, release signing credentials, or GitHub Releases, run: + +```sh +make release-asset-smoke-check +``` + +The smoke check writes temporary files under `tmp/release-asset-smoke/`, +removes the temporary private signing key after signing, and leaves +`tmp/release-asset-smoke/summary.txt` with the checked result. + +## Configured Live PostgreSQL Profile + +For the scripted local profile, run: + +```sh +make release-check-local-postgres +``` + +The target starts the Compose PostgreSQL service, waits for readiness, loads +`.test.env` when present or `.test.env.example` otherwise, runs +`make release-check`, and preserves `tmp/release-check-summary.txt`. For +production-gate failures, use +[Production gate troubleshooting](production-gate-troubleshooting.md) before +changing release status or weakening a gate. + +You can also run the sequence manually: + +```sh +make compose-up +set -a; . ./.test.env; set +a +make release-check +``` + +The configured profile requires `EVYDENCE_TEST_DATABASE_URL`. The example `.test.env.example` points at the Docker Compose PostgreSQL service: + +```sh +EVYDENCE_TEST_DATABASE_URL=postgres://evydence:change-me@localhost:5432/evydence?sslmode=disable +``` + +With that variable set, `make release-check` applies migrations through `make live-postgres-check` and runs the Postgres-backed integration target. The summary should contain: + +```text +live_postgres=passed +postgres_integration=passed +``` + +If either line is skipped, the release evidence should state that durable-store validation was not covered in that run. + +## CI Usage + +The checked-in GitHub Actions workflow provides a disposable PostgreSQL service, +sets `EVYDENCE_TEST_DATABASE_URL`, and runs `make production-check`. That gate +runs the live PostgreSQL release check, coverage threshold enforcement, lint, +gosec, govulncheck, race tests, OpenAPI/docs/deployment/SDK checks, migration +compatibility tests, production benchmark evidence, black-box release-style binary evidence, +and a release manifest signing smoke test. + +The workflow preserves `tmp/release-check-summary.txt`, `coverage.out`, and the +production benchmark summary, black-box release-style binary summary, +release-style binary checksums, and production-check release manifest/signature +smoke artifacts as build artifacts. The database must not contain production +evidence, customer package tokens, signing-key material, or other real secrets. + +GitHub Actions and service container dependencies are pinned by commit SHA or +image digest in the checked CI workflows. The repository also runs a CodeQL +workflow with the `security-and-quality` query suite so SAST results are +published through GitHub code scanning when that service is available. + +## Signed Release Artifact Workflow + +The checked-in `.github/workflows/release-artifacts.yml` workflow calls the +same release-candidate package script used locally. The package job has +read-only `GITHUB_TOKEN` permissions and the release signing secret only; the +separate draft-release publication job downloads the packaged evidence artifact +and uses `EVYDENCE_RELEASE_PUBLISH_TOKEN` only when publishing is explicitly +enabled or a release tag is pushed. It packages self-contained release archives +for the CLI, API, worker, and migration commands on Linux, macOS, and Windows +targets. The script runs `make production-check` against disposable PostgreSQL +before building artifacts. + +The workflow requires this repository secret: + +```text +EVYDENCE_RELEASE_SIGNING_PRIVATE_KEY_B64 +``` + +The value must be the base64 Ed25519 private key generated by: + +```sh +./dist/evydence release keygen \ + --private-out evydence-release-private.key \ + --public-out evydence-release-public.key +``` + +The private key is written only to a temporary file with restrictive +permissions inside the workflow, used to produce +`evydence-release-manifest.sig.json`, copied to the Scorecard-compatible +`evydence-release-manifest.sig` alias, and then removed. The uploaded artifact +set includes binaries, checksums, `openapi.yaml`, `openapi.sha256`, migration +checksums, `coverage.out`, `release-check-summary.txt`, checked release notes, +release SBOM/provenance metadata, the Scorecard-compatible in-toto provenance +statement, the release manifest, and the manifest signature. Tag pushes create +or update a draft GitHub release only when the external +`EVYDENCE_RELEASE_PUBLISH_TOKEN` secret is configured. The publish token should +be a maintainer-controlled fine-grained token or GitHub App token with the +minimum release-publication scope needed for the repository. Maintainers publish +a draft as a prerelease only after the workflow artifact and release assets are +verified. Manual runs can upload only the workflow artifact unless +`upload_draft_release` is enabled. + +The container image workflow similarly keeps `GITHUB_TOKEN` from receiving +package-write permissions. GHCR publication requires +`EVYDENCE_GHCR_PUBLISH_TOKEN`; keyless image signing still requires GitHub OIDC +through `id-token: write`, but that permission is scoped to the image-signing +job rather than the build/push job. These secrets are repository settings, not +release evidence artifacts, and must not be logged or committed. + +The canonical artifact map is +[Release evidence index](release-evidence-index.md). Keep that page aligned +with the release-candidate package script whenever the artifact set changes. + +These artifacts support reproducible engineering review. They are not legal +compliance proof, certification, a secure-release guarantee, complete SBOM +proof, or authoritative vulnerability coverage. + +Before promoting the release-candidate line to a stable `v0.1.0` tag, also use +[Stable v0.1.0 exit criteria](stable-v0.1.0-exit-criteria.md). That reference +records the extra release evidence, documentation alignment, and hard blockers +for leaving the candidate line. diff --git a/docs/reference/roadmap.md b/docs/reference/roadmap.md new file mode 100644 index 0000000..7ed7883 --- /dev/null +++ b/docs/reference/roadmap.md @@ -0,0 +1,60 @@ +# Roadmap And Release Cadence + +This roadmap keeps public expectations aligned with repository evidence. It +does not make legal compliance conclusions, grant certification, prove SBOM +completeness, treat scanner output as authoritative, or guarantee release +security. + +## Current Release Line + +The current release line is a controlled self-hosted production candidate. The +supported API deployment profile is one API writer replica with scalable worker +replicas through PostgreSQL outbox locking. Multi-writer API high availability +is not part of the current supported profile. This single-writer stance is +acceptable for the current controlled self-hosted release line; multi-writer API +HA becomes a blocker before any future broad production-ready or hosted SaaS +status. See [HA strategy](ha-strategy.md) for the decision record. + +## Near-Term Focus + +- keep the public signed release-candidate line current with checksums, release + notes, OpenAPI checksum, migration checksum, coverage output, + production-check summary, SBOM/provenance metadata, and a signed release + manifest; +- keep public GitHub trust controls active, including required checks, branch + protection, vulnerability alerts, Dependabot security updates, and private + vulnerability reporting; +- keep the first evaluation path tied to release artifacts, the end-to-end + release evidence example, the package viewer, and the release evidence index; +- keep the demo surface static/offline for now: the package viewer and generated + `report.html` exports are the supported reviewer view, and a server-backed + internal dashboard remains deferred until a pilot proves it is necessary; +- continue reducing large service surfaces while preserving tenant isolation, + append-only evidence behavior, and safe error handling. The + [Persistence decomposition inventory](persistence-decomposition.md) is the + current generated map for that work. + +## Later Production Hardening + +- keep one API writer as the supported profile for the current release line, + while designing multi-writer API HA only after focused repository + decomposition, optimistic-concurrency or resource-locking tests, and operator + demand justify the added complexity; +- harden provider-specific KMS/HSM, object-lock/WORM, SSO/group-sync, and + transparency-proof profiles with deployment-specific evidence and tests; +- publish immutable container image digests if container images become a + supported install path; +- run production exit reviews only against a concrete release candidate and + target deployment profile. + +## Cadence + +Release candidates should be cut only after `make production-check` and the +release-candidate evidence package pass. Public releases should include a clear +support window, upgrade notes, limitations, and known unresolved hardening work. +Stable `v0.1.0` should be tagged only after +[Stable v0.1.0 exit criteria](stable-v0.1.0-exit-criteria.md) pass for the +candidate being promoted. + +Community contributions remain selective until the public release, security +intake, branch protection, and review enforcement surfaces are stable. diff --git a/docs/reference/source-of-truth.md b/docs/reference/source-of-truth.md new file mode 100644 index 0000000..58bafa7 --- /dev/null +++ b/docs/reference/source-of-truth.md @@ -0,0 +1,50 @@ +# Documentation Source Of Truth + +Use this map to avoid duplicating long command lists, release criteria, or +product-boundary language across the docs. + +| Topic | Canonical Source | Notes | +| --- | --- | --- | +| Product positioning and current status | `README.md` and `docs/reference/production-readiness.md` | Other docs should link here instead of redefining release status. | +| Buyer evaluation path | `docs/buyer-overview.md` | Links demo, package viewer, release evidence, capability map, and API review without duplicating commands. | +| Operator path | `docs/operator-overview.md` and `docs/operations.md` | Links install, config, deployment, gates, runbooks, and production boundaries. | +| Runtime configuration | `docs/reference/configuration.md` | How-to guides may show a short example, then link here for variables. | +| API routes, scopes, idempotency, schemas | `openapi.yaml`, `docs/api.md`, `docs/reference/api-contract-matrix.md`, and `docs/openapi/index.html` | `openapi.yaml` is generated; the matrix and rendered OpenAPI docs are generated from it. | +| Local startup | `docs/tutorials/getting-started.md` | Uses in-process state only. | +| Durable operation | `docs/how-to/install-and-operate.md` | Includes PostgreSQL/object storage and production-like Compose rehearsal. | +| Kubernetes | `docs/kubernetes.md` | Helm-specific operator interface. | +| Hardened self-hosted topology | `docs/reference/hardened-reference-deployment.md` | One API writer, scalable workers, external PostgreSQL/object storage, TLS, secrets, backups, monitoring, signing, and operator-owned evidence. | +| HA and concurrency decision | `docs/reference/ha-strategy.md` | Current single-writer appliance decision, recovery boundaries, and multi-writer prerequisites. | +| Persistence decomposition inventory | `docs/reference/persistence-decomposition.md` | Generated map of focused mutation paths, remaining broad relational-state call sites, next split order, and regression checks. | +| Release validation | `docs/reference/release-validation.md` | Canonical release gate behavior. | +| Release-candidate evidence | `docs/reference/release-candidate.md` | Canonical release-candidate artifact checklist. | +| Release evidence artifact map | `docs/reference/release-evidence-index.md` | Maps each release artifact to generation and verification commands. | +| Production profiles and exit criteria | `docs/reference/production-readiness.md`, `docs/reference/production-exit-review.md`, and `docs/reference/stable-v0.1.0-exit-criteria.md` | Do not broaden status elsewhere without updating these. | +| Design-partner pilot checklist | `docs/how-to/pilot-deployment-checklist.md` | Narrow copy-paste checklist for one controlled self-hosted pilot profile. | +| Maintainer review ownership | `CODEOWNERS` and `docs/reference/maintainer-review-policy.md` | Branch protection or repository rules must enforce this before it is a merge gate. | +| Roadmap and cadence | `docs/reference/roadmap.md` | Public roadmap, supported release line, and cadence expectations. | +| Backup/restore | `docs/runbooks/backup-restore.md` and `docs/runbooks/object-store-recovery.md` | Operator rehearsal steps, object-store recovery, and evidence to keep. | +| Upgrade | `docs/runbooks/upgrade.md` and `docs/reference/upgrade-compatibility-policy.md` | Migration, release artifact verification, supported paths, and API compatibility. | +| Incident response | `docs/runbooks/incident-response.md` | Secrets, tenant, object-store, signing, provider, and package boundaries. | +| Key rotation | `docs/runbooks/key-rotation.md` | Credential and signing-provider rotation boundaries. | +| Capacity and failure modes | `docs/reference/capacity-and-failures.md` and `docs/reference/benchmark-results.md` | Benchmark results are narrow and local. | +| Security reporting | `SECURITY.md` | Repository settings for private reporting must be verified on GitHub. | +| Support expectations | `SUPPORT.md` | Public support boundaries and sanitized-report rules. | +| Commercial pilot positioning | `docs/commercial/design-partner-pilot.md` and `COMMERCIAL.md` | Pilot docs must not imply legal compliance, certification, scanner authority, SBOM completeness, or secure releases. | +| Reusable product copy | `docs/commercial/product-landing-copy.md` | Website, outreach, and README excerpts should start from this conservative copy. | +| Category comparison | `docs/commercial/category-comparison.md` | Keep comparisons fair, non-hostile, and focused on category boundaries. | + +## Repetition Policy + +Repeat non-claims briefly where a document can be read alone, but keep detailed +wording in the canonical source. Prefer links over copying full checklists. + +Allowed short boundary wording: + +> Evydence supports compliance readiness and technical evidence organization; +> it does not make legal compliance conclusions, grant certification, prove SBOM +> completeness, treat scanner output as authoritative, or guarantee release +> security. + +Do not create new variants of release status, production profiles, or supported +deployment modes outside the canonical sources above. diff --git a/docs/reference/stable-v0.1.0-exit-criteria.md b/docs/reference/stable-v0.1.0-exit-criteria.md new file mode 100644 index 0000000..a226553 --- /dev/null +++ b/docs/reference/stable-v0.1.0-exit-criteria.md @@ -0,0 +1,149 @@ +# Stable v0.1.0 Exit Criteria + +This reference defines the criteria for moving Evydence from the public +release-candidate line to a stable `v0.1.0` tag. It is an engineering and +operator-readiness decision record, not legal compliance proof, certification, +complete SBOM proof, authoritative vulnerability coverage, or a secure-release +guarantee. + +## Decision Rule + +Do not tag stable `v0.1.0` unless all repo-owned gates in this document pass, +the current release evidence is public and reproducible, and unresolved +limitations are explicitly carried into release notes and status docs. + +Stable `v0.1.0` may still be scoped to controlled self-hosted use. It must not +be used to claim broad production readiness, regulated production readiness, +hosted SaaS readiness, legal compliance, certification, complete vulnerability +detection, complete SBOM coverage, or guaranteed release security. + +## Required Repo-Owned Gates + +Run these gates from a clean checkout for the release candidate that will be +promoted: + +```sh +make production-check +make release-acceptance +make public-release-verify TAG= +make docs-check +make finalize +``` + +`make production-check` must run with `EVYDENCE_TEST_DATABASE_URL` set to a +disposable PostgreSQL database and must not record skipped live PostgreSQL +checks. The production-check evidence must include coverage threshold output, +live PostgreSQL integration results, migration compatibility, backup/restore +rehearsal, black-box restart persistence, release signing smoke evidence, race +tests, OpenAPI drift checks, docs checks, deployment checks, SDK checks, lint, +gosec, and govulncheck. + +`make public-release-verify TAG=` must verify the public release +candidate artifact set before the stable tag is created. If public artifacts +are missing, stale, unsigned, or unverifiable, stable tagging is blocked. + +## Required Release Evidence + +The candidate promoted to stable must have a public release evidence set with: + +- release archives for the supported target matrix; +- `SHA256SUMS`; +- `openapi.yaml` and `openapi.sha256`; +- `migrations.sha256`; +- `coverage.out`; +- `release-check-summary.txt`; +- release SBOM metadata and release provenance metadata; +- signed `evydence-release-manifest.json`; +- `evydence-release-manifest.sig.json` and the compatibility + `evydence-release-manifest.sig` alias; +- release notes with supported profile, install and upgrade notes, assumptions, + limitations, unresolved hardening work, and explicit non-claims; +- links to the CI production-check run, release-artifacts workflow run, CodeQL + workflow status, and Scorecard evidence where those services are available. + +Project-owned container images are stable release evidence only after the +container-image workflow has run for the same tag and produced digest plus +signing evidence. Until then, release archives remain the supported install +source for the stable line. + +## Required Documentation Alignment + +Before tagging stable, these sources must agree on the current tag, commit, +workflow run, supported profile, and limitations: + +- `release/current.json`; +- `README.md`; +- `CHANGELOG.md`; +- `RELEASE_EVIDENCE.md`; +- `docs/reference/release-evidence-index.md`; +- `docs/reference/production-readiness.md`; +- `docs/reference/production-exit-review.md`; +- `docs/reference/release-validation.md`; +- `docs/reference/release-candidate.md`; +- `docs/how-to/install-and-operate.md`; +- `docs/tutorials/evaluate-in-10-minutes.md`; +- release notes for the promoted tag. + +Run `scripts/check_release_truth.py` after any release metadata edit. The +stable tag is blocked if public docs, helper defaults, or release metadata +disagree. + +## Hard Blockers + +Do not tag stable `v0.1.0` while any of these repo-local conditions exist: + +- `make production-check`, `make release-acceptance`, `make docs-check`, or + `make finalize` fails; +- live PostgreSQL checks are skipped in production-check evidence; +- coverage is below the configured threshold; +- release evidence is missing, unsigned, stale, or not publicly verifiable; +- OpenAPI, migration, SDK, deployment, docs, or release-truth drift checks fail; +- backup/restore rehearsal, migration compatibility, or black-box restart + persistence checks fail; +- release notes omit unresolved limitations or use prohibited compliance, + certification, complete-SBOM, scanner-authority, or secure-release language; +- the supported one API writer replica stance is unclear or contradicted by + Helm, Compose, README, production readiness, or release notes; +- public release artifacts and `release/current.json` point at different tags, + commits, or workflow runs. + +External provider and account checks can also block stronger public claims. +Private vulnerability reporting, branch protection, required checks, secret +scanning, push protection, Dependabot security updates, CodeQL, Scorecard, +publication credentials, external security review, design-partner proof, and +provider-specific KMS/HSM/object-lock verification must be either verified for +the target deployment or listed as external limitations. Do not treat repository +source alone as proof of those controls. + +## Limitations That May Remain + +Stable `v0.1.0` may keep these limitations if they are visible in release +notes, production readiness, and the production exit review: + +- one API writer replica is the supported production profile; +- multi-writer API high availability and hosted SaaS production are out of + scope; +- regulated production needs operator, legal, provider, retention, signing, + SSO, and object-lock review outside repository source; +- provider-specific KMS/HSM, object-lock, transparency, SSO/group-sync, and + external security review evidence depends on the operator or provider; +- release evidence supports reproducibility and engineering review, not + compliance conclusions, certification, complete scanner coverage, or secure + release claims. + +## Stable Release Decision Record + +When stable criteria are met, update +`docs/reference/production-exit-review.md` with: + +- the stable tag and commit; +- release evidence links and workflow run identifiers; +- production-check and public-release verification results; +- supported deployment profile; +- unresolved limitations; +- external controls that remain operator responsibilities; +- explicit non-claims. + +Do not remove this reference after stable tagging. Use it as the regression +checklist for subsequent patch releases until a new release profile supersedes +it. diff --git a/docs/reference/upgrade-compatibility-policy.md b/docs/reference/upgrade-compatibility-policy.md new file mode 100644 index 0000000..13fb72d --- /dev/null +++ b/docs/reference/upgrade-compatibility-policy.md @@ -0,0 +1,76 @@ +# Upgrade And Compatibility Policy + +This reference defines upgrade expectations for Evydence release candidates and +future stable releases. It is an operator compatibility policy, not legal +compliance proof, certification, complete SBOM proof, authoritative scanner +coverage, or a secure-release guarantee. + +## Supported Upgrade Paths + +| Source | Target | Policy | +| --- | --- | --- | +| `v0.1.0-rc.N` | later `v0.1.0-rc.M` | Supported after reading release notes, verifying release artifacts, backing up PostgreSQL and object storage together, and applying all committed migrations. | +| `v0.1.0-rc.N` | stable `v0.1.0` | Supported only after the stable exit criteria pass for the promoted candidate. | +| stable `v0.1.0` | later patch release | Intended to be forward-compatible for `/v1` API clients and migration history unless release notes document a security or correctness exception. | +| any release | older release | Not a normal upgrade path. Restore the matching PostgreSQL and object-store backups together instead of trying to run old binaries against newer data. | + +Operators should upgrade one release line at a time. Do not skip release notes, +OpenAPI checksums, migration checksums, backup manifests, or production-check +evidence. + +## Migration Expectations + +- Migrations are forward-only release evidence. +- Do not edit committed migration history. +- Apply all committed migrations before starting API or worker processes. +- Production startup must fail when committed migrations are pending unless an + operator-controlled migration job has already applied them. +- `make migration-compatibility-check` verifies that every committed migration + prefix can upgrade to the current schema in a disposable PostgreSQL schema. +- Rollback after a migration writes new state requires restoring the matching + PostgreSQL and object-store backups together. + +## API Compatibility + +The public API is `/v1`. Within a stable release line: + +- existing operation IDs, paths, required scopes, idempotency requirements, and + response envelope shapes should remain compatible; +- new optional request fields, response fields, query filters, and endpoints + may be added; +- errors continue to use RFC 9457 Problem Details with stable `code` and + `request_id` fields; +- secrets, token hashes, private keys, raw evidence payload bytes, and + object-store paths must not become public response fields. + +Release candidates may still make breaking API or schema changes when needed +for correctness, tenant isolation, evidence integrity, safe error handling, or +security. Breaking changes must be documented in release notes, reflected in +`openapi.yaml`, and covered by `make openapi-check`. + +## Data And Evidence Compatibility + +- Historical evidence, audit entries, vulnerability decisions, approvals, + release bundles, package records, and lifecycle events remain append-only. +- Corrections use supersession, amendments, redaction events, tombstones, + replacement packages, or new verification receipts rather than silent edits. +- Parser versions, schema versions, and package versions must be retained in + stored records so old evidence can be interpreted after upgrades. +- If a new release cannot interpret older evidence safely, release notes must + document the limitation and the affected verification path. + +## Pre-Upgrade Checklist + +Before changing binaries, images, Helm values, or migrations: + +1. Verify the target release artifacts and signed manifest. +2. Read release notes and this policy. +3. Back up PostgreSQL and object storage together. +4. Verify or generate a backup manifest. +5. Confirm the target still supports one API writer replica unless a later + reviewed design explicitly changes that contract. +6. Confirm production secrets are externalized and local plaintext signing-key + mode is not used in production. + +For the command-oriented procedure, use +[Upgrade runbook](../runbooks/upgrade.md). diff --git a/docs/reference/upload-manifest.md b/docs/reference/upload-manifest.md new file mode 100644 index 0000000..b37ad4a --- /dev/null +++ b/docs/reference/upload-manifest.md @@ -0,0 +1,87 @@ +# Upload Manifest + +The upload manifest is a local CLI file for batching existing `/v1` create +requests. It is useful in CI when scanner, SBOM, VEX, provenance, approval, and +package-export payloads are produced as files before upload. + +The schema file is [schemas/upload-manifest.v1.schema.json](../../schemas/upload-manifest.v1.schema.json). +The current schema version is `evydence-upload-manifest.v1.0.0`. + +## Shape + +```json +{ + "schema_version": "evydence-upload-manifest.v1.0.0", + "requests": [ + { + "kind": "sbom", + "path": "/v1/sboms", + "idempotency_key": "release-123-sbom", + "payload_file": "sbom-cyclonedx-upload.json" + } + ] +} +``` + +Each request must include: + +- `path`: a `/v1` create/action endpoint. +- `idempotency_key`: the exact idempotency key sent to the API. +- exactly one of `payload` or `payload_file`. + +`payload_file` is resolved relative to the manifest directory and must stay +inside that directory after symlink resolution. The CLI validates JSON before it +sends any request. + +`kind` is optional for backward compatibility, but recommended. It documents the +request intent and lets the CLI reject obvious path mistakes: + +| Kind | Supported paths | +| --- | --- | +| `artifact` | `/v1/artifacts` | +| `sbom` | `/v1/sboms`, `/v1/sboms/spdx` | +| `scan`, `vulnerability_scan` | `/v1/vulnerability-scans` | +| `vex` | `/v1/vex`, `/v1/vex/cyclonedx` | +| `provenance`, `build`, `build_attestation` | `/v1/builds`, `/v1/builds/{id}/attestations` | +| `approval` | `/v1/approvals`, release approval paths | +| `package_export`, `customer_package` | `/v1/customer-packages` | +| `release_bundle` | `/v1/release-bundles` | +| `evidence` | `/v1/evidence` | + +## Commands + +Validate without network access: + +```sh +go run ./cmd/evydence upload validate-manifest \ + --manifest .evydence/upload-manifest.json +``` + +Upload after validation: + +```sh +go run ./cmd/evydence upload manifest \ + --url "$EVYDENCE_API_URL" \ + --api-key "$EVYDENCE_API_KEY" \ + --manifest .evydence/upload-manifest.json +``` + +The upload command validates the manifest again before sending requests. It does +not print the API key or raw payload bodies. + +## Supported Evidence Flow + +The manifest supports the release evidence path used by the GitHub and GitLab +examples: + +- artifact registration through `/v1/artifacts`; +- CycloneDX or SPDX SBOM upload; +- generic vulnerability scan upload; +- OpenVEX or CycloneDX VEX upload; +- build provenance or attestation upload; +- approval records; +- release bundle creation; +- customer package export. + +Scanner output remains evidence for review. It is not complete or authoritative +vulnerability coverage by itself. diff --git a/docs/reference/vulnerability-decisions.md b/docs/reference/vulnerability-decisions.md new file mode 100644 index 0000000..94bc6eb --- /dev/null +++ b/docs/reference/vulnerability-decisions.md @@ -0,0 +1,256 @@ +# Vulnerability Decisions + +This reference records the current VEX and vulnerability-decision model so +future changes can be made from observed gaps instead of assumptions. It is a +product and API design reference; it does not claim legal compliance, +certification, complete SBOM coverage, authoritative scanner results, or release +security. + +## Current Objects + +`VEXDocument` records an uploaded VEX payload: + +| Capability | Current support | +| --- | --- | +| Format | `openvex` and `cyclonedx` metadata are recorded. | +| Release and artifact scope | `release_id` is required for OpenVEX upload; `artifact_id` is optional. | +| Raw payload preservation | Raw bytes are stored in object storage through the evidence item payload reference and hash. | +| Import linkage | The VEX document links to the created evidence item through `evidence_id`. | +| Statement summary | Author, version, statement count, and status summary are recorded when parsed. | +| Parser replay | Worker-owned parser mode can defer normalized VEX fields and VEX-derived decisions to the outbox worker. | + +`VulnerabilityDecision` records the operational answer for one normalized scan +finding: + +| Capability | Current support | +| --- | --- | +| Vulnerability ID | `vulnerability` is copied from the finding. | +| Source scanner | Indirect through `scan_id`; scanner metadata is on the vulnerability scan. | +| Affected component/package/PURL | `component` is copied from the finding. | +| Source SBOM context | `sbom_id`, `sbom_component_purl`, and `sbom_component_name` are populated when a same-release SBOM component matches the finding component. | +| Product/release scope | `release_id` is copied from the scan; product scope is indirect through the release. | +| Status | `affected`, `not_affected`, `fixed`, and `under_investigation`. | +| Justification | Required for manual decisions and imported from VEX where available. | +| Customer-safe impact/action text | `impact_statement` and `action_statement` exist. `impact_statement` is required when `customer_visible` is true. | +| Customer visibility | `customer_visible` marks decisions eligible for customer-safe package summaries. | +| Internal notes | `internal_notes` stores tenant-internal context separately from customer-safe impact text. | +| Review freshness | `reviewed_at` records when the decision was reviewed; `review_due_at` optionally records the next expected review time. | +| Approver/actor | `approved_by` stores the actor id for API/OpenVEX-created decisions. | +| Supersession | Creating a new decision for the same finding marks the prior active decision with `superseded_by` and sets `supersedes` on the new decision. | +| Source VEX/import | `vex_document_id` and `evidence_id` link imported decisions to the VEX document and evidence item. Manual decisions may also set `vex_document_id` to link a same-release imported VEX document without changing the manual source. | +| Scan finding link | `finding_id` and `scan_id` link the decision to the normalized finding. | +| Supporting evidence links | `evidence_ids` can link tenant-scoped supporting evidence from the same release. | +| Supporting record links | `supporting_refs` can link same-release first-class records: `approval`, `exception`, `waiver`, `remediation_task`, `release_bundle`, and `incident`. | +| Audit history | Decision creation appends an audit-chain entry. | +| Import report | `GET /v1/vex/{id}/import-report` exposes safe parser counts, warnings, invalid-statement issues, and mapping failures for OpenVEX and CycloneDX VEX imports. | + +## Canonical Lifecycle + +The current canonical decision statuses intentionally match the OpenVEX statuses +Evydence already ingests: + +| Status | Meaning in Evydence | +| --- | --- | +| `under_investigation` | The finding is still being reviewed for this release. | +| `affected` | The release is treated as affected until remediation, exception, or a later decision changes the state. | +| `not_affected` | The recorded evidence says the release is not affected. Customer-visible decisions require `impact_statement`. | +| `fixed` | The recorded evidence says the finding is fixed for this release. Customer-visible decisions require `impact_statement`. | + +Decisions are append-only. Creating a later decision for the same finding marks +the previous active decision with `superseded_by`, sets `supersedes` on the new +decision, appends `vulnerability_decision.superseded` for the prior decision, +and appends `vulnerability_decision.created` for the replacement. The current +transition table permits any canonical status to be superseded by any canonical +status because VEX imports and manual corrections may revise earlier analysis. +Unknown statuses are rejected. Evydence intentionally does not add +`accepted_risk`, `false_positive`, or `will_not_fix` as decision statuses in the +current API version. Decision status answers the technical VEX question for the +release artifact. Business acceptance, policy exceptions, and control waivers +are represented by exception or waiver records with owner, reason, approval, +scope, expiry, and audit-chain entries. + +## Risk Outcome Taxonomy + +Use these mappings when turning scanner triage into Evydence records: + +| Reviewer phrase | Evydence representation | +| --- | --- | +| Accepted risk | Keep the decision `affected` or `under_investigation` as appropriate, then attach an approved, unexpired exception scoped to the release, finding, policy, or control. | +| Will not fix | Use a waiver or exception with owner, reason, scope, expiry, and approval. Do not turn it into `fixed` or `not_affected`. | +| False positive | Use `not_affected` only when the recorded technical analysis says the finding does not apply to this release artifact or component. Put scanner disagreement and supporting context in customer-safe impact/action text or supporting evidence. | +| Remediated in this release | Use `fixed` with release-specific impact/action text and supporting evidence. | +| Still investigating | Use `under_investigation`; readiness continues to show unresolved risk unless an approved exception applies. | + +This split keeps customer-facing wording precise: vulnerability decisions record +technical applicability/remediation, while exceptions and waivers record risk +acceptance and policy overrides. It also keeps expiry semantics on the risk +acceptance object instead of silently expiring historical technical decisions. + +## Decision Freshness + +New manual decisions may include `reviewed_at` and `review_due_at`. When +`reviewed_at` is omitted, Evydence records the decision creation time as the +review time. `review_due_at` is optional; when it is present, it must be after +`reviewed_at`. + +Freshness metadata is append-only with the decision version. To correct a stale +or inaccurate date, create a superseding decision with the corrected freshness +metadata rather than editing the existing version. Customer-safe decision +summaries and customer packages include `reviewed_at` and `review_due_at` when +available. Reports state the limitation that these dates are tenant-supplied +metadata and do not prove the vulnerability analysis is still correct. + +## SBOM Context + +When a decision is created, Evydence searches tenant-owned SBOMs for the same +release. If an SBOM component PURL exactly matches the finding component, the +decision records `sbom_id`, `sbom_component_purl`, and `sbom_component_name`. +If no PURL match exists, Evydence falls back to an exact component name or +`name@version` match. Raw SBOM payload bytes are not copied into the decision or +customer package. + +The link is created only from same-tenant, same-release SBOM metadata already +stored in Evydence. If a matching SBOM is uploaded after a decision is created, +create a superseding decision to attach the new context. + +Customer package manifests include only active decisions unless a future +history-specific export is requested. + +Customer package ZIP exports now include `vulnerability-decisions.json` when the +package contains customer-visible decisions. The file is package-scoped, records +the source manifest hash, and repeats only the safe decision fields already +allowed by the redaction profile, plus assumptions and limitations for reviewer +context. + +## Supporting Record Links + +Manual decisions may include `supporting_refs` when the reviewer wants to point +to first-class Evydence records instead of wrapping everything as generic +evidence. Evydence validates each reference before the decision is created: + +- the referenced record must belong to the same tenant; +- the referenced record must be scoped to the same release, or to the product + subject in a way that matches the decision release; +- duplicates, empty references, unknown types, and supplied digests are + rejected; +- refs are normalized deterministically and are immutable with the decision + version. + +Customer-safe summaries and packages include only reference type and id. They do +not copy approval reason internals, exception payloads, incident timeline +details, raw evidence payloads, object-store paths, or tenant-internal decision +notes. To add, remove, or correct supporting links, create a superseding +decision. + +Manual decisions are the fallback when a VEX document is not available or when +automated VEX mapping does not create the required decision. They use the same +append-only lifecycle as imported decisions. If a VEX document is imported +later, create a new manual decision version with `vex_document_id` set to that +same-release VEX document; Evydence rejects foreign-tenant and wrong-release VEX +links. Customer-visible manual decisions require `impact_statement`, and +customer-safe package summaries omit `internal_notes`. + +## Decision History API + +`GET /v1/vulnerability-decisions` returns the tenant-scoped append-only +decision history for operators with `evidence:read`. + +Supported filters: + +- `product_id` +- `release_id` +- `vulnerability` +- `component` +- `status` +- `active` + +The response uses the `VulnerabilityDecision` schema, including `created_at`, +`supersedes`, and `superseded_by` so clients can reconstruct the decision +chain. `status` must be one of the canonical statuses. `active=true` returns +decisions with no `superseded_by`; `active=false` returns superseded decisions. +Product and release filters are validated against tenant-owned resources and +the release must belong to the requested product when both filters are present. + +## VEX Import Reports + +OpenVEX and CycloneDX VEX uploads create durable import reports. The report +captures: + +- parser version; +- number of statements parsed; +- number of decisions created; +- number of decisions superseded; +- unsupported field names when they can be reported safely; +- warnings; +- invalid statement issue codes; +- mapping failures such as no matching scan finding. +- safe parser failure code and detail when worker replay fails. + +`GET /v1/vex/{id}/import-report` returns the report for operators with +`evidence:read`. The report is intentionally safe to show in operational review: +it excludes raw VEX payload bytes, object-store payload references, bearer +tokens, internal triage notes, and private evidence content. Worker-owned parser +mode initially stores the report as `accepted`; the worker updates it to +`parsed` after replaying the tenant-prefixed payload and applying idempotent +decision side effects. + +CycloneDX VEX reports include format-specific invalid-statement issues for +missing vulnerability IDs and unsupported analysis states, duplicate mapping +warnings, and no-finding mapping failures. + +When worker replay fails, the import report status becomes `failed` and records +only safe operator fields such as `failure_code` and `failure_detail`. It does +not include raw payload bytes, object-store keys, bearer tokens, backend error +strings, or tenant-internal notes. + +`POST /v1/vex/preview` and `POST /v1/vex/cyclonedx/preview` validate payloads +and return advisory mapping counts before committing an import. Preview results +do not store raw payloads, create evidence, create decisions, append audit-chain +entries, or enqueue parser jobs. They are recalculated from current stored scan +findings and active decisions and may change before a later upload. + +## Customer-Safe Summary API + +`GET /v1/reports/vulnerability-decision-summary?release_id=...` returns active +decisions that are explicitly marked `customer_visible`. The response excludes +`internal_notes`, raw evidence payload bytes, object-store references, bearer +tokens, and private review context. It includes assumptions and limitations so +package viewers and exports can present the decisions without implying +certification, legal compliance proof, complete SBOM coverage, or authoritative +scanner coverage. + +## Current Gaps + +The current model is enough to support release-readiness gating, but it is not +yet the full buyer-facing VEX decision workflow: + +- Decision history can now be queried directly, but it is still an operational + API in addition to the package-scoped customer export. +- VEX import reports, dry-run previews, and safe worker parser-failure fields + cover OpenVEX and CycloneDX VEX. +- Customer package manifests can include active `customer_visible` decision + summaries when the redaction profile explicitly allows + `vulnerability_decision`. They exclude `internal_notes`, raw payload bytes, + and bearer secrets, and package ZIPs include a scoped + `vulnerability-decisions.json` export when such decisions are present. +- The dedicated customer-safe summary endpoint is release-scoped JSON. HTML, + PDF, and package-specific rendering remain handled by package/report + resources. +- Decisions now have append-only `reviewed_at` and optional `review_due_at` + metadata. Expiry behavior still lives on exceptions and waivers, not + decisions. +- Supporting links now cover evidence-item records and selected same-release + first-class records. Expanding link types to future resources still needs + explicit modeling. +- The source SBOM context is direct when a same-release SBOM component matches + the finding component. More advanced multi-SBOM disambiguation remains future + work. + +## Concrete Follow-Up Changes + +The productization backlog should add these changes in order: + +1. Add richer operator filtering for failed VEX imports and parser job states. + +These follow-ups should update OpenAPI, HTTP tests, package export tests, and +docs together. diff --git a/docs/reference/worker-outbox.md b/docs/reference/worker-outbox.md new file mode 100644 index 0000000..2a630fb --- /dev/null +++ b/docs/reference/worker-outbox.md @@ -0,0 +1,40 @@ +# Worker Outbox Contract + +The worker claims PostgreSQL outbox jobs with row locking and retries failed jobs with backoff. Job handlers must be idempotent: retrying a job may verify existing durable state, but must not duplicate evidence, signatures, reports, or audit-chain entries. + +Configured job kinds: + +- `parse_sbom` +- `parse_vulnerability_scan` +- `parse_openapi_contract` +- `parse_vex` +- `sign_bundle` +- `verify_subject` +- `verify_attestation` + +Current behavior is intentionally conservative. The API still records normalized signing and verification results before enqueueing jobs for the implemented paths. Parser jobs independently replay tenant-prefixed payload objects when `payload_ref` is present, verify object metadata and byte digests when `payload_hash` is present, parse SBOM, vulnerability-scan, OpenAPI, OpenVEX, CycloneDX VEX, and DSSE attestation payloads, and check that replayed payload summaries match the expected durable state. When `EVYDENCE_WORKER_OWNED_PARSER_SIDE_EFFECTS=true`, parser-backed uploads store accepted records first and workers write parser-derived document fields after replay. The `parse_vex` worker also creates VEX-derived vulnerability decisions idempotently in that mode and updates the VEX import report from `accepted` to `parsed` with safe decision and mapping-failure counts. VEX parser failures update the import report to `failed` with `failure_code` and `failure_detail` before the outbox job is retried or marked terminal by the persisted job status. Missing objects, wrong tenant prefixes, tenant mismatches, oversized payload objects, malformed replay payloads, durable-state mismatches, incomplete verification, missing signatures, hash mismatch, uninitialized storage, and unsupported job kinds fail the job safely. + +Parser and attestation jobs include a deterministic `parser_version` payload +field for new uploads. Workers reject unsupported parser versions and accept +older jobs with no parser version for upgrade compatibility. + +Parser replay is worker-owned for CycloneDX SBOM, generic vulnerability-scan, +OpenAPI contract, DSSE build-attestation, OpenVEX document metadata, and +CycloneDX VEX document metadata when +`EVYDENCE_WORKER_OWNED_PARSER_SIDE_EFFECTS=true`. VEX-derived vulnerability +decision creation is also worker-owned in that mode and uses deterministic +decision IDs to avoid duplicate side effects on retry. + +Workers use the same object-store environment variables as the API. The default worker payload replay limit is 20 MiB and can be adjusted with `EVYDENCE_WORKER_MAX_PAYLOAD_BYTES`. + +Safe logging rules: + +- Log job ID, kind, subject type, and attempt count. +- Do not log raw payload bytes, object paths, bearer tokens, customer portal tokens, private keys, or full provider environment dumps. +- Error messages should describe the failed invariant without echoing subject IDs or raw payload fields. +- VEX import report `failure_code` values are stable operator hints such as + `payload_read_failed`, `payload_ref_invalid`, `payload_tenant_mismatch`, + `payload_digest_mismatch`, `payload_too_large`, `payload_invalid`, + `unsupported_parser_version`, and `durable_state_mismatch`. + +This contract supports operations evidence for asynchronous processing. It does not claim external scanner authority, complete parsing coverage, or cryptographic attestation trust unless the relevant trust roots and verification receipts are recorded. diff --git a/docs/release-signing.md b/docs/release-signing.md index 0e0c689..f5e793b 100644 --- a/docs/release-signing.md +++ b/docs/release-signing.md @@ -2,23 +2,40 @@ This is an operator how-to for Evydence release artifacts. +The examples below use `./dist/evydence`, the CLI binary built from this checkout: + +```sh +go build -o dist/evydence ./cmd/evydence +``` + +If your release environment installs the same binary on `PATH`, replace `./dist/evydence` with `evydence`. + Create a release artifact manifest: ```sh -evydence release manifest --out evydence-release-manifest.json dist/evydence-api dist/evydence-worker dist/evydence +./dist/evydence release manifest --out evydence-release-manifest.json dist/evydence-api dist/evydence-worker dist/evydence ``` Generate a local Ed25519 keypair for development signing: ```sh -evydence release keygen --private-out release-private.key --public-out release-public.key +./dist/evydence release keygen --private-out release-private.key --public-out release-public.key ``` Sign and verify: ```sh -evydence release sign --manifest evydence-release-manifest.json --private-key release-private.key --out evydence-release-manifest.sig.json -evydence release verify --manifest evydence-release-manifest.json --signature evydence-release-manifest.sig.json +./dist/evydence release sign --manifest evydence-release-manifest.json --private-key release-private.key --out evydence-release-manifest.sig.json +./dist/evydence release verify --manifest evydence-release-manifest.json --signature evydence-release-manifest.sig.json ``` Production release signing should use controlled key custody and publish the manifest, signature, public key, and image digest references together. Verification proves that the manifest and listed artifact bytes match the signature; it does not prove that a deployment is secure. + +For Evydence evidence exports, use the same CLI for offline ledger checks: + +```sh +./dist/evydence verify-evidence-bundle evidence-bundle.json +./dist/evydence verify-audit-chain audit-chain.json +``` + +Evidence-bundle verification checks the canonical manifest hash and, when the export includes `signature_refs`, `signatures`, and `signing_keys`, verifies an included Ed25519 signature over the manifest hash. Audit-chain verification checks local hash continuity for the exported chain. Neither command makes legal compliance, external transparency, or secure-release claims. diff --git a/docs/runbooks/backup-restore.md b/docs/runbooks/backup-restore.md new file mode 100644 index 0000000..dfe9d16 --- /dev/null +++ b/docs/runbooks/backup-restore.md @@ -0,0 +1,80 @@ +# Backup And Restore Runbook + +This runbook is for self-hosted operators. It describes the evidence needed to +trust a restore rehearsal; it is not legal compliance proof or a disaster +recovery guarantee. + +## Scope + +Back up PostgreSQL and object storage as one logical unit. Evydence backup +manifests record counts, hashes, and verification checks, but they are not +backups by themselves. + +## Backup Procedure + +1. Record the Evydence commit, release tag, image digest, `openapi.sha256`, and + `migrations.sha256` used by the deployment. +2. Pause API writes or run the backup under an operator-approved consistency + window. Worker replicas may continue only if the backup procedure captures a + consistent database and object-store point. +3. Back up PostgreSQL with the operator's standard tool, such as + `pg_dump`, base backups, or managed service snapshots. +4. Back up the object store bucket/prefix that contains tenant-prefixed raw + payloads and package exports. +5. Call `POST /v1/backup-manifests` after the backup completes and store the + returned manifest with the backup metadata. +6. Store the release artifact manifest and signature next to the backup record. + +## Restore Rehearsal + +1. Restore PostgreSQL into a clean database. +2. Restore object payloads into a clean bucket or prefix. +3. Apply committed migrations for the target release. +4. Start one API writer and the required worker replicas. +5. Verify `/v1/ready`. +6. Verify the latest backup manifest with `GET /v1/backup-manifests/{id}/verify`. +7. Verify a representative audit chain, release bundle, readiness report, and + customer package from the restored deployment. +8. Record restore start/end time, data cut timestamp, object-store source, + database backup source, failed checks, and limitations. + +## Repository Rehearsal Evidence + +The repository-owned rehearsal checks exercise the same restore invariants with +non-sensitive local data: + +```sh +make restore-rehearsal-check +``` + +Expected result: the app-layer rehearsal verifies restored ledger state, +tenant credentials, object payload digest availability, backup-manifest +verification, SBOM metadata, and release-bundle verification. When +`EVYDENCE_TEST_DATABASE_URL` is set, the PostgreSQL rehearsal restores into a +clean schema and repeats the same database/object-store checks. When the +variable is unset, the PostgreSQL test reports an explicit skip. + +This command is repository proof for restore mechanics. It does not replace an +operator rehearsal against the target PostgreSQL backup tool, object-store +bucket, KMS/HSM configuration, network policy, or incident process. + +## Failure Handling + +- Missing object payloads: treat affected evidence, package, or report + verification as failed until the object backup is restored. +- Migration failure: stop startup, preserve logs, and use the previous release + artifact/migration set for recovery review. +- Hash mismatch: do not regenerate evidence silently. Record a verification + failure and investigate database/object-store pairing. +- Secret loss: rotate affected API keys, collector keys, SSO sessions, customer + portal tokens, and signing provider credentials. Do not place replacement + secrets in backup manifests or public issues. + +## Evidence To Keep + +- PostgreSQL backup identifier. +- Object-store backup identifier. +- Evydence release artifact manifest and signature. +- OpenAPI and migration checksums. +- Backup manifest and verification output. +- Restore rehearsal notes with assumptions and limitations. diff --git a/docs/runbooks/incident-response.md b/docs/runbooks/incident-response.md new file mode 100644 index 0000000..cb6f4c8 --- /dev/null +++ b/docs/runbooks/incident-response.md @@ -0,0 +1,51 @@ +# Incident Response Runbook + +Use this runbook for self-hosted Evydence operational incidents. It does not +replace the operator's security incident process. + +## First 15 Minutes + +1. Preserve logs, metrics, release evidence, and relevant configuration names. +2. Do not paste secrets, raw evidence payloads, customer data, bearer tokens, + private keys, provider credentials, or database URLs into public channels. +3. Identify the affected boundary: + API, tenant isolation, collector identity, SSO/session, object storage, + signing provider, release package, customer portal, provider integration, or + deployment infrastructure. +4. If API write integrity is uncertain, stop additional API writer processes and + keep worker processing paused until object/database consistency is reviewed. +5. If a secret may be exposed, revoke or rotate the affected key/token before + collecting broader diagnostics. + +## Containment Checklist + +- API key or collector key exposure: revoke the key, create a new scoped key, + and review audit-chain entries for the affected actor. +- SSO/session exposure: revoke sessions, refresh trust material if needed, and + review provider verification receipts. +- Object-store exposure: rotate object-store credentials, review bucket policy, + and verify tenant-prefixed object access. +- Signing provider exposure: disable the provider, rotate/revoke keys according + to provider policy, and verify valid-at-signing behavior for historical + signatures. +- Customer package exposure: expire portal access, create a new redaction + profile if required, and review package access records. +- Release artifact concern: verify `SHA256SUMS`, manifest signature, OpenAPI + checksum, migration checksum, SBOM metadata, and provenance metadata. + +## Communication + +Use concise, factual updates. Do not claim legal compliance, certification, +complete SBOM coverage, scanner authority, or secure releases. State what is +known, what evidence was checked, what remains under review, and what operators +should do next. + +## Post-Incident Evidence + +- Incident timeline and remediation tasks in Evydence when the deployment is + trusted again. +- Audit-chain verification result. +- Backup/restore verification if data integrity was in scope. +- Secret rotation evidence. +- Release or customer package re-issue evidence when affected. +- Limitations and assumptions for any report shared externally. diff --git a/docs/runbooks/key-rotation.md b/docs/runbooks/key-rotation.md new file mode 100644 index 0000000..8194359 --- /dev/null +++ b/docs/runbooks/key-rotation.md @@ -0,0 +1,65 @@ +# Key Rotation Runbook + +Use this runbook when rotating Evydence API keys, collector keys, SSO/session +trust material, customer portal tokens, release signing keys, tenant signing +keys, or external signing-provider credentials. It is an operator procedure, +not a certification, compliance, or secure-release statement. + +## Scope + +Repository-owned behavior can revoke, replace, and audit Evydence-managed +credentials. Operator-owned infrastructure remains outside repository control: +identity-provider policy, KMS/HSM key custody, object-store credentials, +GitHub/GitLab secrets, branch protection, and incident notifications. + +## Before Rotation + +1. Identify the credential class and affected tenant, collector, release, + package, or provider boundary. +2. Preserve current release, audit-chain, package, and verification evidence. +3. Decide whether rotation is routine, suspected exposure, confirmed exposure, + provider compromise, or operator policy change. +4. Pause affected automation if the old credential may still be used. +5. Prepare a rollback or forward-fix plan. For suspected compromise, prefer + revocation plus replacement over rollback. + +## Rotation Steps + +| Credential | Repository-owned step | Operator-owned step | +| --- | --- | --- | +| API key | Create a new scoped key, update callers, revoke the old key, and verify audit entries. | Remove the old secret from CI, shell history, secret managers, and runbooks. | +| Collector key | Create or update the collector-scoped key, restart the collector, and verify last-seen metadata. | Rotate provider-side CI secrets and pin the collector version used by jobs. | +| SSO/session trust | Revoke sessions and update configured trust material. | Rotate provider certificates, client credentials, group mappings, and session policy. | +| Customer portal token | Expire old package access, issue a new scoped token, and verify package access records. | Notify the recipient through an approved private channel. | +| Tenant signing key | Rotate the tenant signing key, keep historical public material, and verify valid-at-signing behavior. | Review custody policy and provider access for private signing material. | +| Release signing key | Generate a new release signing key, publish the new public key, and sign the next release manifest. | Protect the private key in the release workflow secret store. | +| KMS/HSM provider credential | Disable the old provider credential and record signing-provider verification receipts. | Rotate provider IAM, HSM partitions, network access, and audit logs. | + +## Verification + +After rotation: + +1. Verify the affected actor can perform only the expected scoped operations. +2. Verify the old key, token, session, or provider credential is rejected. +3. Run the relevant package, release bundle, or audit-chain verification. +4. Check logs and metrics for authentication failures without recording the + supplied secret value. +5. Record the rotation reason, actor, time, affected IDs, and residual + limitations. + +For release artifacts, verify the manifest and signature with +[Release signing](../release-signing.md). For customer packages, use +[Review a customer package](../how-to/review-customer-package.md). For +production readiness gates, use [Release validation](../reference/release-validation.md). + +## Failure Handling + +- If the new credential fails, keep the old credential revoked when exposure is + suspected and issue a corrected replacement. +- If historical signatures fail, do not rewrite past evidence. Record a + verification failure and investigate valid-at-signing semantics. +- If a provider cannot prove custody or revocation, keep the affected profile + marked as a limitation until provider evidence is available. +- Do not place replacement secrets, private keys, bearer tokens, database URLs, + or raw evidence payloads in public issues, support requests, logs, or + customer packages. diff --git a/docs/runbooks/object-store-recovery.md b/docs/runbooks/object-store-recovery.md new file mode 100644 index 0000000..552c298 --- /dev/null +++ b/docs/runbooks/object-store-recovery.md @@ -0,0 +1,86 @@ +# Object Store Recovery Runbook + +Use this runbook when raw evidence payloads, customer package exports, release +artifacts, or object metadata may be missing, corrupted, incorrectly scoped, or +restored from the wrong point in time. It is an operator recovery guide, not a +disaster-recovery guarantee. + +## Scope + +PostgreSQL stores metadata and object references. Object storage stores raw +payload bytes and package/export bytes. A trustworthy recovery must restore the +database and object storage as one logical unit. + +Repository-owned checks can verify tenant-prefixed object keys, expected +payload hashes, backup-manifest consistency, release-bundle verification, and +package verification. Operator-owned checks must cover provider snapshots, +bucket policy, object-lock/WORM configuration, IAM, encryption, lifecycle, and +regional availability. + +## Triage + +1. Preserve the failing verification output and relevant request IDs. +2. Do not regenerate or overwrite historical evidence to hide a missing object. +3. Pause affected API writes and worker jobs if database/object-store pairing + may be inconsistent. +4. Identify affected tenant IDs, object prefixes, evidence IDs, package IDs, + release IDs, and backup timestamps. +5. Confirm whether the failure is missing object, digest mismatch, wrong + tenant prefix, wrong bucket/prefix, permission denial, provider outage, or + metadata drift. + +## Recovery Procedure + +1. Restore the object-store bucket or tenant prefix from the backup matching + the PostgreSQL restore point. +2. If the database was also restored, apply migrations for the Evydence release + being recovered. +3. Start one API writer and the required worker replicas. +4. Verify `/v1/ready`. +5. Verify the latest backup manifest and representative release bundles, + evidence payload hashes, readiness reports, and customer packages. +6. Resume worker jobs only after digest checks pass for the affected payloads. +7. Record object-store backup ID, database backup ID, restored prefixes, + verification results, failed objects, and limitations. + +## Repository-Owned Checks + +Use the local rehearsal for non-sensitive restore mechanics: + +```sh +make restore-rehearsal-check +``` + +Use package verification for customer package fixtures or exports: + +```sh +go run ./cmd/evydence package verify \ + --archive examples/end-to-end-release-evidence/sample-customer-package.zip \ + --expected-package-id csp_example \ + --expected-product-id prod_example \ + --expected-release-id rel_example +``` + +For a live deployment, use equivalent API verification endpoints and retain +sanitized outputs with backup evidence. Do not paste object keys, raw payload +bytes, bearer tokens, private keys, database URLs, or customer data into public +channels. + +## Forward Fix Or Restore + +Prefer restoring the correct object bytes when historical evidence is missing +or mismatched. If an object cannot be recovered, do not mutate historical +evidence. Record the missing object as a verification failure, create new +evidence for any replacement payload, regenerate affected packages, and include +the limitation in customer-facing reports. + +## Evidence To Keep + +- Database backup identifier and restore timestamp. +- Object-store backup identifier, bucket, prefix, and restore timestamp. +- Evydence release tag, commit, OpenAPI checksum, and migration checksum. +- Backup manifest and verification output. +- List of affected object refs or evidence IDs, sanitized for sharing. +- Package, bundle, readiness, and audit-chain verification results. +- Operator notes about provider-side IAM, encryption, lifecycle, object lock, + and retention assumptions. diff --git a/docs/runbooks/upgrade.md b/docs/runbooks/upgrade.md new file mode 100644 index 0000000..3766f3c --- /dev/null +++ b/docs/runbooks/upgrade.md @@ -0,0 +1,49 @@ +# Upgrade Runbook + +Use this runbook when moving a self-hosted Evydence deployment to a new release +candidate or release. It is a technical upgrade guide, not an assurance or +certification statement. + +Read [Upgrade and compatibility policy](../reference/upgrade-compatibility-policy.md) +before changing binaries, images, Helm values, or migrations. + +## Before Upgrade + +1. Read the release notes and known limitations for the target version. +2. Review the supported upgrade path and breaking-change notes in the + compatibility policy. +3. Verify release artifacts: + `SHA256SUMS`, signed release manifest, OpenAPI checksum, migration checksum, + release SBOM metadata, and release provenance metadata. +4. Back up PostgreSQL and object storage together. +5. Generate and verify a backup manifest. +6. Confirm the target profile still uses one API writer replica unless a later + release explicitly documents a reviewed multi-writer design. +7. Check that production secrets are externalized and that local plaintext + signing-key mode is not used in production. + +## Upgrade + +1. Stop API writers. +2. Let workers finish or stop them after recording pending outbox counts. +3. Apply migrations with `make migrate` or `evydence-migrate` using the target + release artifacts. +4. Start one API writer. +5. Start worker replicas. +6. Verify `/v1/ready`, a release-readiness report, an audit-chain verification, + a release bundle verification, and a representative package/export. + +## Rollback Or Forward Fix + +Evydence migrations are treated as forward-moving release evidence. Prefer a +forward fix when a migration has written new state. If an operator must restore +an older version, restore the matching PostgreSQL and object-store backups +together and record the data cut timestamp. + +## Evidence To Keep + +- Previous and target release artifact manifests and signatures. +- Migration checksum file. +- Backup manifest before upgrade. +- Upgrade command transcript with secrets redacted. +- Post-upgrade verification outputs and known limitations. diff --git a/docs/sdk/README.md b/docs/sdk/README.md index 3621f59..a8eef65 100644 --- a/docs/sdk/README.md +++ b/docs/sdk/README.md @@ -1,13 +1,138 @@ # SDK Workflow -This is a reference for the lightweight SDK layout. +This is a reference for the lightweight SDK wrappers committed in this repository. -The committed SDK examples are curated wrappers around the OpenAPI-backed HTTP API: +## Current Scope -- `sdk/go/evydence` -- `sdk/typescript/client.ts` -- `sdk/python/evydence_client.py` +The wrappers provide authenticated JSON helpers with explicit idempotency keys and small typed convenience helpers for the release-ledger and SSO/OIDC identity-provider routes: -They intentionally keep a small surface: authenticated JSON `POST` requests with explicit idempotency keys. Broader generated clients should be regenerated from the committed `openapi.yaml` and reviewed before release. +- Go: `sdk/go/evydence` +- TypeScript: `sdk/typescript/client.ts` +- Python: `sdk/python/evydence_client.py` -SDK drift checks currently rely on `make openapi-check`, `go test ./...`, and documentation review. Generated SDK publishing is a release process, not an API runtime dependency. +They do not expose typed methods for every route. The current typed coverage is intentionally narrow: products, releases, artifacts, build runs, process readiness, release-readiness reports, SSO provider creation, and provider identity verification. + +The generated SDK route catalog at [`sdk/openapi-route-catalog.json`](../../sdk/openapi-route-catalog.json) lists every committed public operation with method, path, scopes, idempotency requirement, and request/response schema references. Use that catalog as the checked input for broader generated clients, then review and release language-specific packages through a separate SDK publishing process. + +No package publishing manifests are committed for these wrappers yet. Use them as in-repository examples or copy them into an application-owned SDK package until a release process publishes versioned SDK artifacts. + +For short language-specific create/read snippets, idempotency guidance, Problem +Details handling, and package verification boundaries, start with +[SDK quickstarts](quickstarts.md). + +## Go + +Import the wrapper from this module path when your code is in this repository or uses a local `replace` to this checkout: + +```go +package main + +import ( + "context" + "os" + + "github.com/aatuh/evydence/sdk/go/evydence" +) + +func createProduct() error { + client := evydence.Client{ + BaseURL: "http://localhost:8080", + APIKey: os.Getenv("EVYDENCE_API_KEY"), + } + + var out map[string]any + err := client.CreateProduct( + context.Background(), + "product-payments-api", + evydence.CreateProductRequest{Name: "Payments API", Slug: "payments-api"}, + &out, + ) + if err != nil { + return err + } + return nil +} +``` + +The helper rejects paths that do not start with `/v1/` and blank idempotency keys. Non-2xx responses return an error containing the HTTP status code; callers that need Problem Details bodies should use a generated or custom client. + +The Go wrapper also exposes typed helpers for `CreateRelease`, `RegisterArtifact`, `CreateBuild`, `Readiness`, `ReleaseReadiness`, `CreateSSOProvider`, and `VerifyProviderIdentity`. `VerifyProviderIdentity` can carry an OIDC `id_token`, OIDC `access_token` for live UserInfo validation, or SAML `saml_assertion`; SDK errors intentionally include only the HTTP status code and not the response body. + +See [`examples/sdk/go/main.go`](../../examples/sdk/go/main.go) for a minimal +product-create example. + +## TypeScript + +Import the source wrapper directly from the checkout or from your application-owned package copy. The wrapper uses `fetch`; Node.js 18+ provides it globally, and older runtimes should pass `fetchImpl`. + +```ts +import { EvydenceClient } from "./sdk/typescript/client"; + +const client = new EvydenceClient({ + baseUrl: "http://localhost:8080", + apiKey: process.env.EVYDENCE_API_KEY!, +}); + +const response = await client.createProduct<{ data: { id: string } }>( + "product-payments-api", + { name: "Payments API", slug: "payments-api" }, +); +``` + +The helper validates `/v1/` paths and idempotency keys. Non-2xx responses throw an error with the HTTP status code. + +The TypeScript wrapper also exposes `createRelease`, `registerArtifact`, `createBuild`, `readiness`, `releaseReadiness`, `createSSOProvider`, and `verifyProviderIdentity` helpers over the same routes. + +See [`examples/sdk/typescript/example.ts`](../../examples/sdk/typescript/example.ts) +for a minimal product-create example. + +## Python + +Put `sdk/python` on `PYTHONPATH`, install the copied module in your application package, or run the example from that directory: + +```python +import os + +from evydence_client import EvydenceClient + +client = EvydenceClient( + base_url="http://localhost:8080", + api_key=os.environ["EVYDENCE_API_KEY"], +) + +response = client.create_product( + "product-payments-api", + {"name": "Payments API", "slug": "payments-api"}, +) +``` + +The helper validates `/v1/` paths and idempotency keys. HTTP errors raise `RuntimeError` with the status code. + +The Python wrapper also exposes `create_release`, `register_artifact`, `create_build`, `readiness`, `release_readiness`, `create_sso_provider`, and `verify_provider_identity` helpers over the same routes. + +See [`examples/sdk/python/example.py`](../../examples/sdk/python/example.py) +for a minimal product-create example. + +## Idempotency Guidance + +Use stable, operation-specific idempotency keys for create and action requests: + +```text +product-payments-api +release-payments-api-1.0.0 +build-github-123456-1 +``` + +Reusing the same key with the same request returns the original response. Reusing the key with different request content returns `409`. + +## Drift Checks + +SDK drift checks currently rely on: + +```sh +make openapi-check +make test +make sdk-check +``` + +`make sdk-check` runs `scripts/sdk_check.py`, which verifies that the curated helper methods still map to committed OpenAPI operations, idempotent routes still require `Idempotency-Key`, SDKs keep basic `/v1/` path validation, and the generated route catalog matches `openapi.yaml`. Generated SDK publishing is not an API runtime dependency. Keep generated clients tied to the committed `openapi.yaml` and document any route coverage gaps at release time. diff --git a/docs/sdk/quickstarts.md b/docs/sdk/quickstarts.md new file mode 100644 index 0000000..69ca58b --- /dev/null +++ b/docs/sdk/quickstarts.md @@ -0,0 +1,199 @@ +# SDK Quickstarts + +These quickstarts show the current in-repository SDK wrappers for a small +create/read flow. They are intentionally narrow: the wrappers help with +authenticated JSON requests, `/v1/` path checks, and explicit idempotency keys. +They are not published language packages yet and do not verify ZIP package +archives or signatures. + +Use placeholders for credentials: + +```sh +export EVYDENCE_URL=http://localhost:8080 +export EVYDENCE_API_KEY=evy_test_or_live_key_from_your_tenant +``` + +Do not paste real API keys, bearer tokens, private keys, raw evidence payloads, +customer package contents, database URLs, or provider credentials into source +files, screenshots, public issues, or support requests. + +## Go + +```go +package main + +import ( + "context" + "fmt" + "os" + + "github.com/aatuh/evydence/sdk/go/evydence" +) + +func main() { + ctx := context.Background() + client := evydence.Client{ + BaseURL: os.Getenv("EVYDENCE_URL"), + APIKey: os.Getenv("EVYDENCE_API_KEY"), + } + + var product map[string]any + if err := client.CreateProduct(ctx, "quickstart-go-product-v1", evydence.CreateProductRequest{ + Name: "Quickstart API", + Slug: "quickstart-api", + }, &product); err != nil { + panic(err) + } + + productID := product["data"].(map[string]any)["id"].(string) + var release map[string]any + if err := client.CreateRelease(ctx, "quickstart-go-release-v1", evydence.CreateReleaseRequest{ + ProductID: productID, + Version: "1.0.0", + }, &release); err != nil { + panic(err) + } + + releaseID := release["data"].(map[string]any)["id"].(string) + var readiness map[string]any + if err := client.ReleaseReadiness(ctx, releaseID, &readiness); err != nil { + panic(err) + } + fmt.Println(readiness["data"]) +} +``` + +For Problem Details bodies, use a generated client or a custom HTTP call. The +lightweight Go wrapper currently returns an error with the HTTP status code +only: + +```go +type Problem struct { + Code string `json:"code"` + RequestID string `json:"request_id"` + Status int `json:"status"` + Title string `json:"title"` +} +``` + +## TypeScript + +```ts +import { EvydenceClient } from "../../sdk/typescript/client"; + +const client = new EvydenceClient({ + baseUrl: process.env.EVYDENCE_URL ?? "http://localhost:8080", + apiKey: process.env.EVYDENCE_API_KEY ?? "", +}); + +const product = await client.createProduct<{ data: { id: string } }>( + "quickstart-typescript-product-v1", + { name: "Quickstart API", slug: "quickstart-api" }, +); + +const release = await client.createRelease<{ data: { id: string } }>( + "quickstart-typescript-release-v1", + { product_id: product.data.id, version: "1.0.0" }, +); + +const readiness = await client.releaseReadiness<{ data: unknown }>(release.data.id); +console.log(readiness.data); +``` + +Use direct `fetch` or a generated client when you need RFC 9457 Problem Details +fields such as `code`, `request_id`, and `status`: + +```ts +type Problem = { code?: string; request_id?: string; status?: number; title?: string }; + +const response = await fetch(`${process.env.EVYDENCE_URL}/v1/products`, { + method: "POST", + headers: { + "Authorization": `Bearer ${process.env.EVYDENCE_API_KEY}`, + "Idempotency-Key": "quickstart-typescript-product-v1", + "Content-Type": "application/json", + }, + body: JSON.stringify({ name: "Quickstart API", slug: "quickstart-api" }), +}); +if (!response.ok) { + const problem = await response.json() as Problem; + throw new Error(`Evydence ${problem.code ?? response.status}: ${problem.request_id ?? "no-request-id"}`); +} +``` + +## Python + +```python +import os + +from evydence_client import EvydenceClient + + +client = EvydenceClient( + base_url=os.environ.get("EVYDENCE_URL", "http://localhost:8080"), + api_key=os.environ["EVYDENCE_API_KEY"], +) + +product = client.create_product( + "quickstart-python-product-v1", + {"name": "Quickstart API", "slug": "quickstart-api"}, +) +release = client.create_release( + "quickstart-python-release-v1", + {"product_id": product["data"]["id"], "version": "1.0.0"}, +) +print(client.release_readiness(release["data"]["id"])["data"]) +``` + +Use a direct `urllib.request` call or a generated client when you need Problem +Details response bodies. The lightweight Python wrapper raises `RuntimeError` +with the HTTP status code only. + +## Idempotency + +Every create/action request in these examples uses an `Idempotency-Key`. +Choose a stable key per operation and tenant, for example: + +```text +quickstart-go-product-v1 +quickstart-typescript-release-v1 +quickstart-python-package-v1 +``` + +Reusing the same key with the same request replays the original response. +Reusing the same key with changed content returns `409` with the +`IDEMPOTENCY_KEY_REUSED` Problem Details code. + +## Package Verification + +Customer package ZIP verification is currently an offline CLI verifier task, +not an SDK helper. Use SDK or generated-client calls to create and download a +package, then verify the archive with the CLI: + +```sh +dist/evydence package verify \ + --archive customer-package.zip \ + --expected-package-id csp_example \ + --expected-product-id prod_example \ + --expected-release-id rel_example +``` + +The verifier checks the package manifest shape, manifest hash, package identity, +expected product/release IDs when supplied, prohibited sensitive fields, archive +metadata, and evidence-bundle coverage when a bundle is included. It does not +make legal compliance conclusions, certify the release, prove SBOM completeness, +or treat scanner results as authoritative. + +## Limitations + +- Current wrappers are source files in this repository, not published package + artifacts. +- Typed helper coverage is intentionally narrow. Use + [`sdk/openapi-route-catalog.json`](../../sdk/openapi-route-catalog.json) and + [`openapi.yaml`](../../openapi.yaml) for generated clients that need all + routes. +- Wrapper error values intentionally avoid including response bodies to reduce + accidental leakage. Use a generated or custom client when you need Problem + Details fields. +- Binary downloads, package archive verification, evidence bundle verification, + and release manifest verification remain CLI/offline-verifier workflows. diff --git a/docs/tutorials/customer-cve-review-demo.md b/docs/tutorials/customer-cve-review-demo.md new file mode 100644 index 0000000..0486595 --- /dev/null +++ b/docs/tutorials/customer-cve-review-demo.md @@ -0,0 +1,94 @@ +# Customer CVE Review Demo + +This tutorial shows the shortest no-external-services path for the core +Evydence buyer question: + +> This CVE appears in your SBOM. Are you affected, why, who approved it, and +> what evidence can we verify? + +The tutorial uses deterministic fixtures under +`examples/customer-cve-review-demo/` and the checked customer package under +`examples/end-to-end-release-evidence/`. It does not start PostgreSQL, object +storage, CI providers, scanners, GitHub, GitLab, KMS, or SSO. + +## Prerequisites + +- Go toolchain available for `go run ./cmd/evydence`. +- Repository checkout with the example fixture files present. + +## Run The Demo + +From the repository root: + +```sh +examples/customer-cve-review-demo/run-demo.sh +``` + +Expected output: + +```text +customer CVE review demo passed +question=This CVE appears in your SBOM. Are you affected, why, who approved it, and what evidence can we verify? +product=Payments API +release=1.0.0 +vulnerability=CVE-2026-0002 +component=pkg:apk/openssl@3.1.0 +decision=fixed +approval=approved_for_customer_package +package_id=csp_example +verification_output=verification-output.txt +``` + +The script writes: + +```text +tmp/customer-cve-review-demo/verification-output.txt +tmp/customer-cve-review-demo/summary.txt +``` + +`verification-output.txt` must match +`examples/customer-cve-review-demo/expected-verification-output.txt`. + +## Inspect The Evidence Path + +Read: + +```text +examples/customer-cve-review-demo/customer-cve-review-story.json +``` + +The fixture contains one product, one release, one artifact digest, one +CycloneDX SBOM component, one scanner finding, one VEX-style fixed decision, +one approval, one readiness report, one release bundle reference, and one +customer package fixture. + +Then verify the package directly: + +```sh +go run ./cmd/evydence package verify \ + --manifest examples/end-to-end-release-evidence/sample-customer-package-manifest.json \ + --expected-package-id csp_example \ + --expected-product-id prod_example \ + --expected-release-id rel_example +``` + +Expected result includes: + +```text +customer package verified +package_id: csp_example +product_id: prod_example +release_id: rel_example +``` + +To inspect the package visually, open `site/package-viewer/index.html` and +press **Load bundled demo**, or load +`examples/end-to-end-release-evidence/sample-customer-package-manifest.json`. + +## Boundaries + +The fixture is non-sensitive and local. It must not be treated as legal +compliance proof, certification, complete SBOM proof, authoritative scanner +coverage, regulator acceptance, or a secure-release guarantee. It demonstrates +how Evydence organizes technical evidence, decisions, verification material, +gaps, assumptions, and limitations for a customer review. diff --git a/docs/tutorials/evaluate-in-10-minutes.md b/docs/tutorials/evaluate-in-10-minutes.md new file mode 100644 index 0000000..166e595 --- /dev/null +++ b/docs/tutorials/evaluate-in-10-minutes.md @@ -0,0 +1,137 @@ +# Evaluate Evydence In 10 Minutes + +This walkthrough is for a product-security, AppSec, platform, or customer +reviewer who wants to understand the VEX-first release-evidence path without +running a full production deployment. + +The question to answer is: + +> This CVE appears in your SBOM. Are you affected, why, who approved it, and +> what can I verify? + +Evydence answers that question by linking release, artifact, SBOM, +vulnerability scan, VEX or manual decision, approval or exception, readiness, +bundle, package, and audit-chain evidence. The checked fixtures are +non-sensitive examples. They are not legal compliance proof, certification, +complete SBOM proof, authoritative vulnerability results, or a secure-release +guarantee. + +## 1. Verify The Public Release Candidate + +Start by verifying the release artifacts you would install or evaluate: + +```sh +make public-release-verify TAG=v0.1.0-rc.7 +``` + +This downloads the public release-candidate evidence and checks the release +archives, OpenAPI checksum, migration checksum, in-toto provenance statement +shape, and signed release manifest. The release evidence index explains every +artifact in the set: + +- [Release evidence index](../reference/release-evidence-index.md) +- [Release candidate checklist](../reference/release-candidate.md) + +## 2. Open The Sample Customer Package + +Open the local package viewer: + +```sh +xdg-open site/package-viewer/index.html +``` + +If `xdg-open` is not available, open `site/package-viewer/index.html` directly +from your browser. + +In the viewer, press **Load bundled demo** or load this checked fixture: + +```text +examples/end-to-end-release-evidence/sample-customer-package-manifest.json +``` + +The sample package is also available as a ZIP archive: + +```text +examples/end-to-end-release-evidence/sample-customer-package.zip +``` + +Use the viewer to inspect: + +- release scope, package scope, and limitations; +- included artifact, SBOM, scan, VEX, decision, approval, and bundle metadata; +- the vulnerability decision that explains why the sample finding is not + treated as an unresolved blocker; +- verification status and audit-chain evidence; +- explicitly excluded raw payload bytes, secrets, hashes, and internal notes. + +The package viewer guide has the same path with screenshots and offline +verification commands: + +- [View packages locally](../how-to/view-packages.md) + +## 3. Verify The Sample Package + +Verify the checked package fixture without running the API: + +```sh +go run ./cmd/evydence package verify \ + --archive examples/end-to-end-release-evidence/sample-customer-package.zip \ + --expected-package-id csp_example \ + --expected-product-id prod_example \ + --expected-release-id rel_example +``` + +Expected result: the command exits successfully and validates the package +manifest, archive metadata, and expected scope identifiers. It verifies the +example package bytes; it does not verify facts outside the sample data. + +## 4. Read The Evidence Path + +Use the end-to-end example as the shortest map from the reviewer question to +concrete API outputs: + +- [End-to-end release evidence example](../../examples/end-to-end-release-evidence/README.md) +- [Sample readiness report](../../examples/end-to-end-release-evidence/sample-readiness-report.json) +- [Sample security summary](../../examples/end-to-end-release-evidence/sample-security-summary.json) +- [Sample audit-chain verification](../../examples/end-to-end-release-evidence/sample-audit-chain-verification.json) + +The key files are: + +| Reviewer question | Example file | +| --- | --- | +| Which SBOM did you use? | `sample-customer-package-manifest.json` and `sample-readiness-report.json` | +| Which scanner raised the finding? | `sample-security-summary.json` | +| Are you affected and why? | `sample-customer-package-manifest.json` vulnerability decision section | +| Who recorded or approved the answer? | package approvals and audit-chain fixture | +| What can I verify offline? | package archive, package manifest, bundle metadata, and audit-chain fixture | +| What is missing or limited? | readiness report gaps, assumptions, limitations, and non-claims | + +## 5. Run The Local API Demo When Ready + +After the static review path makes sense, run the API-backed demo: + +```sh +EVYDENCE_URL=http://localhost:8080 \ +EVYDENCE_API_KEY='evy_replace_with_scoped_secret' \ +examples/end-to-end-release-evidence/run-local-demo.sh +``` + +The demo creates a product, release, artifact, SBOM, vulnerability scan, +vulnerability decision, release bundle, redaction profile, customer package, +readiness report, security summary, and audit-chain verification output under +`tmp/end-to-end-release-evidence/`. + +For a step-by-step local API setup, use: + +- [Getting started](getting-started.md) +- [Install and operate](../how-to/install-and-operate.md) + +## What This Evaluation Should Show + +After these steps, a reviewer should be able to trace one vulnerability answer +from SBOM and scanner evidence through a VEX or manual decision into a +customer-safe package and verification commands. + +This supports compliance readiness and technical evidence organization. It +does not replace human review, legal analysis, scanner validation, provider +controls, or customer-specific disclosure decisions. diff --git a/docs/tutorials/getting-started.md b/docs/tutorials/getting-started.md index 7a871c9..9ff2218 100644 --- a/docs/tutorials/getting-started.md +++ b/docs/tutorials/getting-started.md @@ -1,37 +1,225 @@ # Getting Started -This tutorial runs the API locally and records a minimal release ledger flow. +This tutorial runs Evydence with in-process state and records a small release evidence flow. It is for local development only; data is lost when the process exits. + +For operator evaluation, prefer the current public release candidate +[`v0.1.0-rc.7`](https://github.com/aatuh/evydence/releases/tag/v0.1.0-rc.7) +and verify the release evidence before starting binaries. This tutorial remains +the source-checkout local development path. The release artifact map is in +[Release evidence index](../reference/release-evidence-index.md). ## Prerequisites - Go with the version declared by `go.mod`. -- Docker if you want PostgreSQL and object storage. +- `curl` and `jq`. +- A free local port matching `EVYDENCE_ADDR` from `.api.env.example`, default `:8080`. + +## Start The API -## Start Local Dependencies +In terminal 1: ```sh -make compose-up -set -a; . ./.test.env; set +a -make live-postgres-check +cp .api.env.example .api.env +unset EVYDENCE_DATABASE_URL +set -a; . ./.api.env; set +a +EVYDENCE_PRINT_BOOTSTRAP_SECRET=true go run ./cmd/evydence-api ``` -For an in-process demo, leave `EVYDENCE_DATABASE_URL` unset. +Expected result: -## Start The API +- The process prints a one-time JSON object containing `tenant_id`, `api_key`, and `secret`. +- The API listens on `http://localhost:8080` unless `EVYDENCE_ADDR` says otherwise. +- Because `EVYDENCE_DATABASE_URL` is unset, the tutorial uses in-process state. + +In terminal 2, store the printed secret: ```sh -cp .api.env.example .api.env -set -a; . ./.api.env; set +a -EVYDENCE_PRINT_BOOTSTRAP_SECRET=true go run ./cmd/evydence-api +export EVYDENCE_URL=http://localhost:8080 +export EVYDENCE_API_KEY='evy_replace_with_printed_secret' +``` + +Verify the process is reachable: + +```sh +curl -sS "$EVYDENCE_URL/v1/ready" | jq . +``` + +Expected status is `200` with `data.status` set to `ok`. + +## Create Core Release Records + +Create a product: + +```sh +curl -sS -X POST "$EVYDENCE_URL/v1/products" \ + -H "Authorization: Bearer $EVYDENCE_API_KEY" \ + -H "Idempotency-Key: tutorial-product" \ + -H "Content-Type: application/json" \ + --data '{"name":"Payments API","slug":"payments-api"}' \ + | tee /tmp/evydence-product.json | jq . + +export PRODUCT_ID=$(jq -r '.data.id' /tmp/evydence-product.json) +``` + +Expected status is `201`. Reusing `tutorial-product` with the same body returns the original response; reusing it with different JSON returns `409`. + +Create a project and release: + +```sh +curl -sS -X POST "$EVYDENCE_URL/v1/projects" \ + -H "Authorization: Bearer $EVYDENCE_API_KEY" \ + -H "Idempotency-Key: tutorial-project" \ + -H "Content-Type: application/json" \ + --data "{\"product_id\":\"$PRODUCT_ID\",\"name\":\"api\"}" \ + | tee /tmp/evydence-project.json | jq . + +export PROJECT_ID=$(jq -r '.data.id' /tmp/evydence-project.json) + +curl -sS -X POST "$EVYDENCE_URL/v1/releases" \ + -H "Authorization: Bearer $EVYDENCE_API_KEY" \ + -H "Idempotency-Key: tutorial-release" \ + -H "Content-Type: application/json" \ + --data "{\"product_id\":\"$PRODUCT_ID\",\"version\":\"1.0.0\"}" \ + | tee /tmp/evydence-release.json | jq . + +export RELEASE_ID=$(jq -r '.data.id' /tmp/evydence-release.json) +``` + +Both creates should return `201`. + +## Upload Representative Evidence + +Register an artifact: + +```sh +export ARTIFACT_DIGEST=sha256:ca978112ca1bbdcafac231b39a23dc4da786eff8147c4e72b9807785afee48bb + +curl -sS -X POST "$EVYDENCE_URL/v1/artifacts" \ + -H "Authorization: Bearer $EVYDENCE_API_KEY" \ + -H "Idempotency-Key: tutorial-artifact" \ + -H "Content-Type: application/json" \ + --data "{\"name\":\"api.tar.gz\",\"media_type\":\"application/gzip\",\"digest\":\"$ARTIFACT_DIGEST\",\"size\":42}" \ + | tee /tmp/evydence-artifact.json | jq . + +export ARTIFACT_ID=$(jq -r '.data.id' /tmp/evydence-artifact.json) +``` + +Upload a CycloneDX SBOM: + +```sh +curl -sS -X POST "$EVYDENCE_URL/v1/sboms" \ + -H "Authorization: Bearer $EVYDENCE_API_KEY" \ + -H "Idempotency-Key: tutorial-sbom" \ + -H "Content-Type: application/json" \ + --data "{ + \"release_id\":\"$RELEASE_ID\", + \"artifact_id\":\"$ARTIFACT_ID\", + \"payload\":{ + \"bomFormat\":\"CycloneDX\", + \"specVersion\":\"1.6\", + \"components\":[{\"name\":\"openssl\",\"purl\":\"pkg:apk/openssl@3.1.0\"}] + } + }" | jq . +``` + +Upload a vulnerability scan with one open critical finding: + +```sh +curl -sS -X POST "$EVYDENCE_URL/v1/vulnerability-scans" \ + -H "Authorization: Bearer $EVYDENCE_API_KEY" \ + -H "Idempotency-Key: tutorial-vuln-scan" \ + -H "Content-Type: application/json" \ + --data "{ + \"scanner\":\"grype\", + \"target_ref\":\"pkg:oci/payments-api\", + \"release_id\":\"$RELEASE_ID\", + \"findings\":[{ + \"vulnerability\":\"CVE-2026-0099\", + \"component\":\"pkg:apk/openssl@3.1.0\", + \"severity\":\"critical\", + \"state\":\"open\" + }] + }" | tee /tmp/evydence-scan.json | jq . + +export FINDING_ID=$(jq -r '.data.findings[0].id' /tmp/evydence-scan.json) ``` -Use the printed one-time bootstrap secret as a bearer token. Create and action requests require `Idempotency-Key`. +Expected status is `201`. The scan organizes scanner evidence; it does not make the scanner authoritative. + +Record a generic build evidence item: + +```sh +curl -sS -X POST "$EVYDENCE_URL/v1/evidence" \ + -H "Authorization: Bearer $EVYDENCE_API_KEY" \ + -H "Idempotency-Key: tutorial-build-evidence" \ + -H "Content-Type: application/json" \ + --data "{ + \"product_id\":\"$PRODUCT_ID\", + \"project_id\":\"$PROJECT_ID\", + \"release_id\":\"$RELEASE_ID\", + \"type\":\"build\", + \"subtype\":\"log\", + \"title\":\"Local tutorial build evidence\", + \"payload_hash\":\"$ARTIFACT_DIGEST\", + \"tags\":[\"tutorial\"], + \"limitations\":[\"Local tutorial evidence is representative only.\"] + }" | jq . +``` + +Expected status is `201`. + +## Readiness And Review Output -## Validate +Create a release bundle: ```sh -make test -make openapi-check +curl -sS -X POST "$EVYDENCE_URL/v1/release-bundles" \ + -H "Authorization: Bearer $EVYDENCE_API_KEY" \ + -H "Idempotency-Key: tutorial-release-bundle" \ + -H "Content-Type: application/json" \ + --data "{\"release_id\":\"$RELEASE_ID\"}" \ + | tee /tmp/evydence-bundle.json | jq . ``` -The local flow organizes technical evidence and reports gaps, assumptions, and limitations. It does not make legal compliance or secure-release conclusions. +Read release readiness: + +```sh +curl -sS "$EVYDENCE_URL/v1/reports/release-readiness?release_id=$RELEASE_ID" \ + -H "Authorization: Bearer $EVYDENCE_API_KEY" \ + | jq . +``` + +Expected status is `200`. In this minimal flow, `data.result` is expected to be +`failed` because the release is missing some readiness inputs and has an +unhandled open critical finding. The report should include checks, reviewer +question sections, missing evidence, failed policy checks, gaps, assumptions, +limitations, non-claims, and blocking findings. + +Record a decision for the tutorial finding: + +```sh +curl -sS -X POST "$EVYDENCE_URL/v1/vulnerability-findings/$FINDING_ID/decisions" \ + -H "Authorization: Bearer $EVYDENCE_API_KEY" \ + -H "Idempotency-Key: tutorial-vuln-decision" \ + -H "Content-Type: application/json" \ + --data '{"status":"not_affected","justification":"tutorial example; vulnerable code path is not present"}' \ + | jq . +``` + +Expected status is `201`. Readiness may still report gaps until build provenance, attestation, signed bundle, and other required evidence are present. + +Search the tutorial evidence: + +```sh +curl -sS "$EVYDENCE_URL/v1/evidence/search?release_id=$RELEASE_ID&type=build&tag=tutorial&limit=10" \ + -H "Authorization: Bearer $EVYDENCE_API_KEY" \ + | jq . +``` + +Expected status is `200` with the build evidence item under `data.items`. + +## Cleanup + +Stop the API process with `Ctrl-C`. For a durable local run, use PostgreSQL and object storage through [Install and operate](../how-to/install-and-operate.md) and [Configuration](../reference/configuration.md). + +This tutorial demonstrates evidence capture and review output. It does not make legal compliance conclusions, grant certification, prove SBOM completeness, treat scanner output as authoritative, or guarantee release security. diff --git a/examples/customer-cve-review-demo/README.md b/examples/customer-cve-review-demo/README.md new file mode 100644 index 0000000..0b6771c --- /dev/null +++ b/examples/customer-cve-review-demo/README.md @@ -0,0 +1,77 @@ +# Customer CVE Review Demo + +This fixture answers the customer-review question that Evydence is optimized +for: + +> This CVE appears in your SBOM. Are you affected, why, who approved it, and +> what evidence can we verify? + +The demo is local and deterministic. It uses checked sample files, the local +CLI verifier, and no external services. It does not contain secrets, raw +evidence payload bytes, object-store paths, customer data, private keys, bearer +tokens, token hashes, or provider credentials. + +## Files + +- `customer-cve-review-story.json`: one release, one artifact digest, one SBOM + component, one scanner finding, one VEX-style fixed decision, one approval, + one readiness report, one release bundle reference, and one customer package + fixture. +- `expected-verification-output.txt`: exact expected offline verifier output. +- `run-demo.sh`: checked local script that verifies the sample manifest and + ZIP archive, validates the story fixture, and writes output under + `tmp/customer-cve-review-demo/`. + +The customer package manifest and archive live in +`examples/end-to-end-release-evidence/` so this demo reuses the same checked +package fixture as the package viewer and reviewer workflow. + +## Run + +From the repository root: + +```sh +examples/customer-cve-review-demo/run-demo.sh +``` + +Expected output: + +```text +customer CVE review demo passed +question=This CVE appears in your SBOM. Are you affected, why, who approved it, and what evidence can we verify? +product=Payments API +release=1.0.0 +vulnerability=CVE-2026-0002 +component=pkg:apk/openssl@3.1.0 +decision=fixed +approval=approved_for_customer_package +package_id=csp_example +verification_output=verification-output.txt +``` + +The script writes the verifier output to: + +```text +tmp/customer-cve-review-demo/verification-output.txt +``` + +That file should match `expected-verification-output.txt`. + +## What The Demo Shows + +- The SBOM component is linked to the scanner finding for `CVE-2026-0002`. +- The customer-visible decision records the finding as fixed in this release. +- The approval record explains that the package can be used for a customer + review path. +- The readiness report result is `passed` for this package-scope fixture. +- The release bundle metadata includes signature references, and the customer + package manifest/archive verify offline with the local CLI. + +## Limitations + +This fixture supports local product evaluation and reviewer orientation only. +It is not legal compliance proof, certification, complete SBOM proof, an +authoritative vulnerability result, regulator acceptance, or a secure-release +guarantee. For a live API flow that creates tenant-scoped records, use +`examples/end-to-end-release-evidence/run-local-demo.sh` after starting a local +Evydence API with a scoped key. diff --git a/examples/customer-cve-review-demo/customer-cve-review-story.json b/examples/customer-cve-review-demo/customer-cve-review-story.json new file mode 100644 index 0000000..98dd3f1 --- /dev/null +++ b/examples/customer-cve-review-demo/customer-cve-review-story.json @@ -0,0 +1,105 @@ +{ + "schema_version": "evydence-customer-cve-review-demo.v1.0.0", + "question": "This CVE appears in your SBOM. Are you affected, why, who approved it, and what evidence can we verify?", + "product": { + "id": "prod_example", + "name": "Payments API" + }, + "release": { + "id": "rel_example", + "version": "1.0.0", + "state": "approved" + }, + "artifact": { + "id": "art_example", + "name": "payments-api.tar.gz", + "digest": "sha256:ca978112ca1bbdcafac231b39a23dc4da786eff8147c4e72b9807785afee48bb" + }, + "sbom": { + "id": "sbom_example", + "format": "cyclonedx", + "spec_version": "1.6", + "component": { + "name": "openssl", + "purl": "pkg:apk/openssl@3.1.0" + } + }, + "scanner_finding": { + "id": "finding_example", + "scanner": "example-scanner", + "vulnerability": "CVE-2026-0002", + "component": "pkg:apk/openssl@3.1.0", + "severity": "high", + "state": "fixed_by_decision" + }, + "vulnerability_decision": { + "id": "vd_example", + "status": "fixed", + "justification": "fixed_in_release", + "impact_statement": "The finding is fixed in this release artifact.", + "action_statement": "Upgrade to this release after normal operator review.", + "source": "vex", + "reviewed_at": "2026-05-27T12:00:00Z", + "review_due_at": "2026-08-25T12:00:00Z" + }, + "approval": { + "id": "approval_example", + "type": "release_risk_decision", + "actor": "security-reviewer@example.test", + "decision": "approved_for_customer_package", + "approved_at": "2026-05-27T12:00:00Z" + }, + "readiness_report": { + "result": "passed", + "checks": [ + { + "name": "release_requires_sbom", + "result": "passed" + }, + { + "name": "release_requires_vulnerability_decision", + "result": "passed" + }, + { + "name": "release_requires_release_bundle", + "result": "passed" + } + ], + "limitations": [ + "Readiness is derived from recorded package-scope evidence only.", + "Readiness output is not a compliance, certification, or secure-release conclusion." + ] + }, + "release_bundle": { + "id": "rb_example", + "state": "generated", + "manifest_hash": "sha256:example", + "signature_refs": [ + "sig_example" + ] + }, + "customer_package": { + "id": "csp_example", + "manifest": "../end-to-end-release-evidence/sample-customer-package-manifest.json", + "archive": "../end-to-end-release-evidence/sample-customer-package.zip", + "manifest_hash": "sha256:83dfd287d47855753cfe22dcc842b485efabe759a25eef946b2c41df248917ed" + }, + "offline_verification": { + "commands": [ + "go run ./cmd/evydence package verify --manifest examples/end-to-end-release-evidence/sample-customer-package-manifest.json --expected-package-id csp_example --expected-product-id prod_example --expected-release-id rel_example", + "go run ./cmd/evydence package verify --archive examples/end-to-end-release-evidence/sample-customer-package.zip --expected-package-id csp_example --expected-product-id prod_example --expected-release-id rel_example" + ], + "expected_output": "expected-verification-output.txt" + }, + "answer_summary": [ + "The package links the affected component to SBOM metadata and a scanner finding.", + "The active customer-visible decision records the finding as fixed in this release.", + "The package includes release-bundle metadata and offline package manifest/archive verification.", + "The package states assumptions and limitations and avoids compliance or security guarantees." + ], + "limitations": [ + "This is a non-sensitive fixture for local evaluation.", + "It does not include raw tenant payloads, object-store paths, bearer tokens, token hashes, private keys, or customer data.", + "The fixture is not legal compliance proof, certification, complete SBOM proof, an authoritative vulnerability result, or a secure-release guarantee." + ] +} diff --git a/examples/customer-cve-review-demo/expected-verification-output.txt b/examples/customer-cve-review-demo/expected-verification-output.txt new file mode 100644 index 0000000..1ad6638 --- /dev/null +++ b/examples/customer-cve-review-demo/expected-verification-output.txt @@ -0,0 +1,15 @@ +manifest verification: +customer package verified +package_id: csp_example +product_id: prod_example +release_id: rel_example +manifest_hash: sha256:83dfd287d47855753cfe22dcc842b485efabe759a25eef946b2c41df248917ed +evidence_bundle: not supplied + +archive verification: +customer package verified +package_id: csp_example +product_id: prod_example +release_id: rel_example +manifest_hash: sha256:83dfd287d47855753cfe22dcc842b485efabe759a25eef946b2c41df248917ed +evidence_bundle: not supplied diff --git a/examples/customer-cve-review-demo/run-demo.sh b/examples/customer-cve-review-demo/run-demo.sh new file mode 100755 index 0000000..5bd4f85 --- /dev/null +++ b/examples/customer-cve-review-demo/run-demo.sh @@ -0,0 +1,127 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$ROOT" + +fail() { + printf '%s\n' "customer-cve-review-demo: $*" >&2 + exit 1 +} + +workdir="${EVYDENCE_CUSTOMER_CVE_DEMO_DIR:-tmp/customer-cve-review-demo}" +case "$workdir" in + tmp/*) ;; + *) fail "EVYDENCE_CUSTOMER_CVE_DEMO_DIR must stay under tmp/" ;; +esac + +story="examples/customer-cve-review-demo/customer-cve-review-story.json" +expected="examples/customer-cve-review-demo/expected-verification-output.txt" +manifest="examples/end-to-end-release-evidence/sample-customer-package-manifest.json" +archive="examples/end-to-end-release-evidence/sample-customer-package.zip" + +for path in "$story" "$expected" "$manifest" "$archive"; do + [[ -f "$path" ]] || fail "missing $path" +done + +rm -rf -- "$workdir" +mkdir -p -- "$workdir" + +{ + printf '%s\n' "manifest verification:" + go run ./cmd/evydence package verify \ + --manifest "$manifest" \ + --expected-package-id csp_example \ + --expected-product-id prod_example \ + --expected-release-id rel_example + printf '\n%s\n' "archive verification:" + go run ./cmd/evydence package verify \ + --archive "$archive" \ + --expected-package-id csp_example \ + --expected-product-id prod_example \ + --expected-release-id rel_example +} >"$workdir/verification-output.txt" + +diff -u "$expected" "$workdir/verification-output.txt" >/dev/null || { + diff -u "$expected" "$workdir/verification-output.txt" >&2 || true + fail "verification output drifted" +} + +python3 - "$story" "$manifest" "$workdir" <<'PY' +import json +import pathlib +import sys + +story_path = pathlib.Path(sys.argv[1]) +manifest_path = pathlib.Path(sys.argv[2]) +workdir = pathlib.Path(sys.argv[3]) + +story = json.loads(story_path.read_text()) +manifest = json.loads(manifest_path.read_text()) + +required = [ + "product", + "release", + "artifact", + "sbom", + "scanner_finding", + "vulnerability_decision", + "approval", + "readiness_report", + "release_bundle", + "customer_package", + "offline_verification", +] +missing = [key for key in required if key not in story] +if missing: + raise SystemExit(f"story missing {', '.join(missing)}") + +if story["product"]["id"] != manifest["product_id"]: + raise SystemExit("story product does not match package manifest") +if story["release"]["id"] != manifest["release_id"]: + raise SystemExit("story release does not match package manifest") +if story["customer_package"]["id"] != manifest["package_id"]: + raise SystemExit("story package does not match package manifest") + +finding = story["scanner_finding"] +decision = story["vulnerability_decision"] +if finding["vulnerability"] != decision.get("vulnerability", finding["vulnerability"]): + raise SystemExit("decision vulnerability mismatch") +if finding["component"] != story["sbom"]["component"]["purl"]: + raise SystemExit("finding component is not tied to SBOM component") +if decision["status"] not in {"fixed", "not_affected"}: + raise SystemExit("demo decision must be fixed or not_affected") +if story["readiness_report"]["result"] != "passed": + raise SystemExit("demo readiness must pass") +if not story["release_bundle"].get("signature_refs"): + raise SystemExit("release bundle signature refs missing") + +serialized = json.dumps({"story": story, "manifest": manifest}, sort_keys=True).lower() +for forbidden in ( + "payload_ref", + "object_key", + "private_key", + "token_hash", + "api_key", + "internal note", + "customer_secret", +): + if forbidden in serialized: + raise SystemExit(f"demo leaked forbidden marker {forbidden}") + +summary = [ + "customer CVE review demo passed", + f"question={story['question']}", + f"product={story['product']['name']}", + f"release={story['release']['version']}", + f"vulnerability={finding['vulnerability']}", + f"component={finding['component']}", + f"decision={decision['status']}", + f"approval={story['approval']['decision']}", + f"package_id={story['customer_package']['id']}", + "verification_output=verification-output.txt", +] +(workdir / "summary.txt").write_text("\n".join(summary) + "\n") +PY + +cat "$workdir/summary.txt" diff --git a/examples/end-to-end-release-evidence/README.md b/examples/end-to-end-release-evidence/README.md new file mode 100644 index 0000000..bd3116c --- /dev/null +++ b/examples/end-to-end-release-evidence/README.md @@ -0,0 +1,161 @@ +# End-To-End Release Evidence Example + +This example shows the VEX-first product path Evydence should make easy to +evaluate: + +1. Create a product, release, and artifact. +2. Upload SBOM evidence for the artifact. +3. Upload vulnerability scan evidence for the release. +4. Record a VEX-style decision or approved exception for a blocking finding. +5. Generate a release-readiness report. +6. Create a signed release bundle and customer-safe package or evidence bundle. +7. View the package locally and verify the bundle, package manifest, and audit + chain. + +The files in this directory are non-sensitive fixtures. They are not legal +compliance proof, certification, complete SBOM proof, authoritative +vulnerability results, or a secure-release guarantee. + +## Start With Release Verification + +Before running a local API flow, verify the current public release-candidate +assets from a clean temporary directory: + +```sh +make public-release-verify TAG=v0.1.0-rc.7 +``` + +This proves the downloaded public archives, OpenAPI checksum, migration +checksum, in-toto provenance statement shape, and signed release manifest before +you use source-checkout commands for local development. + +## Files + +- `release-evidence-manifest.json`: example CLI bulk-upload manifest. +- `sample-readiness-report.json`: representative readiness output with gaps and + limitations. +- `sample-security-summary.json`: representative release security quick-check + output without raw tenant payload bytes. +- `sample-customer-package-manifest.json`: representative customer package + manifest with a reviewer checklist and without raw tenant payload bytes. +- `sample-customer-package.zip`: downloadable fixture with `manifest.json`, + `package.json`, `verification.json`, `vulnerability-decisions.json`, and + `README.txt`. +- `sample-audit-chain-verification.json`: representative audit-chain + verification result. + +Customer package ZIP exports generated by the API also include `report.html`, +a self-contained static report rendered from the redacted manifest. + +## Run Against A Local API + +Start Evydence with the getting-started tutorial or the production-like Compose +stack, then set: + +```sh +export EVYDENCE_URL=http://localhost:8080 +export EVYDENCE_API_KEY='evy_replace_with_scoped_secret' +``` + +Upload the manifest with the local CLI: + +```sh +go run ./cmd/evydence upload manifest \ + --api-url "$EVYDENCE_URL" \ + --api-key "$EVYDENCE_API_KEY" \ + --manifest examples/end-to-end-release-evidence/release-evidence-manifest.json +``` + +The manifest uses placeholder IDs. Replace `prod_example`, `proj_example`, +`rel_example`, and `art_example` with IDs created in your local tenant before +running it. + +For a complete local API flow that creates the IDs for you and writes example +outputs under `tmp/end-to-end-release-evidence/`, run: + +```sh +EVYDENCE_URL=http://localhost:8080 \ +EVYDENCE_API_KEY='evy_replace_with_scoped_secret' \ +examples/end-to-end-release-evidence/run-local-demo.sh +``` + +The script creates a product, release, artifact, SBOM, vulnerability scan, +vulnerability decision, release bundle, redaction profile, customer package, +readiness report, and audit-chain verification output. It expects a local API +that already has a tenant and scoped API key. + +To exercise the GitHub Actions-style CI path without external services, run: + +```sh +make local-ci-simulation-check +``` + +That checked target starts a temporary loopback API, creates the required +product, project, release, and artifact IDs, uploads build provenance and a +structural DSSE/in-toto attestation, runs `evydence ci preflight`, uploads the +manifest, and verifies readiness, customer-package, and audit-chain outputs +under `tmp/local-ci-simulation/`. + +## Inspect The Result + +The generated files under `tmp/end-to-end-release-evidence/` map the buyer +question to concrete Evydence records: + +- `sbom.json`: which SBOM was used for the release answer. +- `vulnerability-scan.json`: which scanner result raised the finding. +- `vulnerability-decision.json`: why the finding is treated as not affected in + this demo, including any same-release supporting record references. +- `release-readiness.json`: the readiness result, gaps, assumptions, and + limitations. +- `release-security-summary.json`: the compact release security status for + review surfaces. +- `release-bundle.json`: the signed release bundle metadata. +- `customer-package.json`: the customer-safe package manifest, including the + proof-path checklist and customer-safe decision supporting refs when present. +- `audit-chain-verification.json`: audit-chain continuity verification. + +To visually inspect the customer-safe output, open +`site/package-viewer/index.html` and load `customer-package.json` from the demo +output directory. To verify the package manifest offline, run: + +```sh +go run ./cmd/evydence package verify \ + --manifest tmp/end-to-end-release-evidence/customer-package.json +``` + +To inspect the checked sample without running the API, open +`sample-customer-package-manifest.json` in the viewer or verify the downloadable +fixture: + +```sh +go run ./cmd/evydence package verify \ + --archive examples/end-to-end-release-evidence/sample-customer-package.zip \ + --expected-package-id csp_example +``` + +The deterministic reviewer workflow is checked by: + +```sh +make reviewer-package-workflow-check +``` + +That target verifies the manifest checksum, validates the manifest and archive, +safely extracts the ZIP under `tmp/`, and checks the generated `report.html` +for verification and limitations sections without exposing raw payload refs, +object-store keys, token hashes, private-key markers, internal notes, or script +tags. + +To verify the release bundle through the API, read the bundle id from +`release-bundle.json` and call: + +```sh +bundle_id="$(jq -r '.data.id' tmp/end-to-end-release-evidence/release-bundle.json)" +curl -fsS \ + -H "Authorization: Bearer $EVYDENCE_API_KEY" \ + "$EVYDENCE_URL/v1/release-bundles/${bundle_id}/verify" +``` + +Expected result: the API returns verification checks for the tenant-scoped +bundle. The demo output remains review evidence with explicit limitations; it +is not legal compliance proof, certification, complete SBOM proof, an +authoritative scanner result, or a secure-release guarantee. diff --git a/examples/end-to-end-release-evidence/release-evidence-manifest.json b/examples/end-to-end-release-evidence/release-evidence-manifest.json new file mode 100644 index 0000000..d52700c --- /dev/null +++ b/examples/end-to-end-release-evidence/release-evidence-manifest.json @@ -0,0 +1,54 @@ +{ + "requests": [ + { + "path": "/v1/evidence", + "idempotency_key": "example-build-log", + "payload": { + "product_id": "prod_example", + "project_id": "proj_example", + "release_id": "rel_example", + "type": "build", + "subtype": "ci-log", + "title": "Example CI build log", + "payload_hash": "sha256:ca978112ca1bbdcafac231b39a23dc4da786eff8147c4e72b9807785afee48bb", + "tags": ["example", "ci"], + "limitations": ["Example fixture; replace with real CI output."] + } + }, + { + "path": "/v1/sboms", + "idempotency_key": "example-sbom", + "payload": { + "release_id": "rel_example", + "artifact_id": "art_example", + "payload": { + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "components": [ + { + "name": "openssl", + "purl": "pkg:apk/openssl@3.1.0" + } + ] + } + } + }, + { + "path": "/v1/vulnerability-scans", + "idempotency_key": "example-vulnerability-scan", + "payload": { + "release_id": "rel_example", + "scanner": "grype", + "target_ref": "pkg:oci/example-api", + "findings": [ + { + "vulnerability": "CVE-2026-0099", + "component": "pkg:apk/openssl@3.1.0", + "severity": "critical", + "state": "open" + } + ] + } + } + ] +} diff --git a/examples/end-to-end-release-evidence/run-local-demo.sh b/examples/end-to-end-release-evidence/run-local-demo.sh new file mode 100755 index 0000000..2bda029 --- /dev/null +++ b/examples/end-to-end-release-evidence/run-local-demo.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env sh +set -eu + +: "${EVYDENCE_URL:=http://localhost:8080}" +: "${EVYDENCE_API_KEY:?EVYDENCE_API_KEY is required}" + +outdir="${EVYDENCE_DEMO_OUTDIR:-tmp/end-to-end-release-evidence}" +mkdir -p "$outdir" + +api() { + method="$1" + path="$2" + idem="$3" + body="$4" + if [ "$method" = "GET" ]; then + curl -fsS "${EVYDENCE_URL}${path}" \ + -H "Authorization: Bearer ${EVYDENCE_API_KEY}" + else + curl -fsS -X "$method" "${EVYDENCE_URL}${path}" \ + -H "Authorization: Bearer ${EVYDENCE_API_KEY}" \ + -H "Idempotency-Key: ${idem}" \ + -H "Content-Type: application/json" \ + --data "$body" + fi +} + +product="$(api POST /v1/products demo-product '{"name":"Demo Payments API","slug":"demo-payments-api"}')" +printf '%s\n' "$product" > "$outdir/product.json" +product_id="$(printf '%s' "$product" | jq -er '.data.id')" + +release="$(api POST /v1/releases demo-release "{\"product_id\":\"$product_id\",\"version\":\"1.0.0\"}")" +printf '%s\n' "$release" > "$outdir/release.json" +release_id="$(printf '%s' "$release" | jq -er '.data.id')" + +artifact="$(api POST /v1/artifacts demo-artifact '{"name":"payments-api.tar.gz","media_type":"application/gzip","digest":"sha256:ca978112ca1bbdcafac231b39a23dc4da786eff8147c4e72b9807785afee48bb","size":42}')" +printf '%s\n' "$artifact" > "$outdir/artifact.json" +artifact_id="$(printf '%s' "$artifact" | jq -er '.data.id')" + +sbom="$(api POST /v1/sboms demo-sbom "{\"release_id\":\"$release_id\",\"artifact_id\":\"$artifact_id\",\"payload\":{\"bomFormat\":\"CycloneDX\",\"specVersion\":\"1.6\",\"components\":[{\"name\":\"openssl\",\"purl\":\"pkg:apk/openssl@3.1.0\"}]}}")" +printf '%s\n' "$sbom" > "$outdir/sbom.json" + +scan="$(api POST /v1/vulnerability-scans demo-scan "{\"release_id\":\"$release_id\",\"scanner\":\"grype\",\"target_ref\":\"pkg:oci/payments-api\",\"findings\":[{\"vulnerability\":\"CVE-2026-0099\",\"component\":\"pkg:apk/openssl@3.1.0\",\"severity\":\"critical\",\"state\":\"open\"}]}")" +printf '%s\n' "$scan" > "$outdir/vulnerability-scan.json" +finding_id="$(printf '%s' "$scan" | jq -er '.data.findings[0].id')" + +decision="$(api POST "/v1/vulnerability-findings/${finding_id}/decisions" demo-decision '{"status":"not_affected","justification":"Demo decision; replace with release-specific technical analysis."}')" +printf '%s\n' "$decision" > "$outdir/vulnerability-decision.json" + +bundle="$(api POST /v1/release-bundles demo-release-bundle "{\"release_id\":\"$release_id\"}")" +printf '%s\n' "$bundle" > "$outdir/release-bundle.json" + +profile="$(api POST /v1/redaction-profiles demo-redaction-profile '{"name":"Demo customer redaction","allowed_types":["sbom","vulnerability_scan","release_bundle"],"excluded_fields":["raw_payload","secrets"]}')" +printf '%s\n' "$profile" > "$outdir/redaction-profile.json" +profile_id="$(printf '%s' "$profile" | jq -er '.data.id')" + +package="$(api POST /v1/customer-packages demo-customer-package "{\"product_id\":\"$product_id\",\"release_id\":\"$release_id\",\"redaction_profile_id\":\"$profile_id\",\"title\":\"Demo customer release evidence\",\"expires_at\":\"2026-06-30T00:00:00Z\"}")" +printf '%s\n' "$package" > "$outdir/customer-package.json" + +readiness="$(api GET "/v1/reports/release-readiness?release_id=${release_id}" "" "")" +printf '%s\n' "$readiness" > "$outdir/release-readiness.json" + +security_summary="$(api GET "/v1/releases/${release_id}/security-summary" "" "")" +printf '%s\n' "$security_summary" > "$outdir/release-security-summary.json" + +audit="$(api GET /v1/audit-chain/verify "" "")" +printf '%s\n' "$audit" > "$outdir/audit-chain-verification.json" + +printf 'wrote demo evidence outputs to %s\n' "$outdir" +printf 'release_id=%s\n' "$release_id" diff --git a/examples/end-to-end-release-evidence/sample-audit-chain-verification.json b/examples/end-to-end-release-evidence/sample-audit-chain-verification.json new file mode 100644 index 0000000..5250e56 --- /dev/null +++ b/examples/end-to-end-release-evidence/sample-audit-chain-verification.json @@ -0,0 +1,19 @@ +{ + "data": { + "id": "ver_example_audit_chain", + "subject_type": "audit_chain", + "subject_id": "tenant-example", + "result": "passed", + "checked_at": "2026-05-31T09:00:00Z", + "checks": [ + { + "name": "audit_chain_hash_continuity", + "result": "passed", + "detail": "Every entry links to the previous hash for the tenant range." + } + ], + "limitations": [ + "Example output for documentation; verify a real tenant chain in the target deployment." + ] + } +} diff --git a/examples/end-to-end-release-evidence/sample-customer-package-manifest.json b/examples/end-to-end-release-evidence/sample-customer-package-manifest.json new file mode 100644 index 0000000..b879f0a --- /dev/null +++ b/examples/end-to-end-release-evidence/sample-customer-package-manifest.json @@ -0,0 +1,255 @@ +{ + "schema_version": "customer-security-package.v2.0.0", + "package_version": "customer-security-package.v2.0.0", + "package_id": "csp_example", + "id": "csp_example", + "title": "Example customer review package", + "generated_at": "2026-05-27T12:00:00Z", + "tenant": { + "id": "ten_example", + "name": "Example Tenant" + }, + "organization": { + "records": [ + { + "id": "org_example", + "name": "Example Org", + "slug": "example-org", + "status": "active", + "created": "2026-05-27T12:00:00Z" + } + ], + "limitations": [ + "Organization records are included only as tenant metadata; product-to-organization ownership is not inferred by this package schema." + ] + }, + "product": { + "id": "prod_example", + "name": "Payments API", + "slug": "payments-api", + "created_at": "2026-05-27T12:00:00Z" + }, + "product_id": "prod_example", + "release": { + "id": "rel_example", + "product_id": "prod_example", + "version": "1.0.0", + "state": "approved", + "created_at": "2026-05-27T12:00:00Z" + }, + "release_id": "rel_example", + "redaction_profile_id": "rp_example", + "redaction_profile": { + "id": "rp_example", + "name": "customer review", + "allowed_types": [ + "artifact", + "sbom", + "vulnerability_scan", + "vex", + "vulnerability_decision", + "release_bundle" + ], + "excluded_fields": [ + "internal_notes" + ], + "schema_version": "redaction-profile.v1.0.0" + }, + "evidence_ids": [ + "ev_sbom", + "ev_scan", + "ev_vex" + ], + "artifact_digests": [ + { + "id": "art_example", + "name": "payments-api.tar.gz", + "media_type": "application/gzip", + "size": 123, + "digest": "sha256:ca978112ca1bbdcafac231b39a23dc4da786eff8147c4e72b9807785afee48bb", + "created_at": "2026-05-27T12:00:00Z" + } + ], + "sboms": [ + { + "id": "sbom_example", + "evidence_id": "ev_sbom", + "release_id": "rel_example", + "artifact_id": "art_example", + "format": "cyclonedx", + "spec_version": "1.6", + "component_count": 42, + "created_at": "2026-05-27T12:00:00Z" + } + ], + "vulnerability_scans": [ + { + "id": "scan_example", + "evidence_id": "ev_scan", + "release_id": "rel_example", + "scanner": "example-scanner", + "target_ref": "pkg:oci/payments-api", + "summary": { + "critical": 0, + "high": 1 + }, + "finding_count": 1, + "findings": [ + { + "id": "finding_example", + "vulnerability": "CVE-2026-0002", + "component": "pkg:apk/openssl@3.1.0", + "severity": "high", + "state": "fixed_by_decision" + } + ], + "created_at": "2026-05-27T12:00:00Z" + } + ], + "vex_documents": [ + { + "id": "vex_example", + "evidence_id": "ev_vex", + "release_id": "rel_example", + "artifact_id": "art_example", + "format": "openvex", + "author": "security@example.test", + "statement_count": 1, + "status_summary": { + "fixed": 1 + }, + "schema_version": "vex-document.v1.0.0", + "created_at": "2026-05-27T12:00:00Z" + } + ], + "vulnerability_decisions": [ + { + "id": "vd_example", + "finding_id": "finding_example", + "scan_id": "scan_example", + "release_id": "rel_example", + "vulnerability": "CVE-2026-0002", + "component": "pkg:apk/openssl@3.1.0", + "sbom_id": "sbom_example", + "sbom_component_purl": "pkg:apk/openssl@3.1.0", + "sbom_component_name": "openssl", + "status": "fixed", + "justification": "fixed_in_release", + "impact_statement": "The finding is fixed in this release artifact.", + "action_statement": "Upgrade to this release after normal operator review.", + "source": "vex", + "evidence_id": "ev_vex", + "vex_document_id": "vex_example", + "supporting_refs": [ + { + "type": "exception", + "id": "exc_example" + }, + { + "type": "release_bundle", + "id": "bundle_example" + } + ], + "reviewed_at": "2026-05-27T12:00:00Z", + "review_due_at": "2026-08-25T12:00:00Z", + "created_at": "2026-05-27T12:00:00Z" + } + ], + "customer_decision_export": { + "file": "vulnerability-decisions.json", + "schema_version": "customer-vulnerability-decisions.v1.0.0", + "decision_count": 1, + "scope": "package" + }, + "readiness_summary": { + "result": "passed", + "checks": [ + { + "name": "release_requires_sbom", + "result": "passed", + "severity": "high", + "missing": [], + "explanation": "sbom evidence exists" + } + ], + "gaps": [], + "limitations": [ + "Readiness is derived from recorded package-scope evidence only.", + "Readiness output is not a compliance, certification, or secure-release conclusion." + ] + }, + "verification_material": { + "hash_algorithm": "sha256", + "canonicalization": "canonicalization-profile.v1.0.0", + "manifest_hash_field": "manifest_hash", + "release_bundles": [ + { + "id": "rb_example", + "state": "generated", + "manifest_hash": "sha256:example", + "signature_refs": [ + "sig_example" + ], + "created_at": "2026-05-27T12:00:00Z" + } + ], + "audit_chain": { + "result": "passed", + "latest_sequence": 12, + "head_hash": "sha256:example", + "checks": [ + { + "name": "audit_chain", + "result": "passed" + } + ] + } + }, + "reviewer_checklist": [ + { + "id": "package_scope", + "label": "Package scope", + "status": "included", + "detail": "Scoped to product prod_example, release rel_example, redaction profile rp_example, and package csp_example." + }, + { + "id": "included_evidence", + "label": "Included evidence", + "status": "included", + "detail": "Includes artifact digest, SBOM metadata, vulnerability scan summary, OpenVEX metadata, active customer-visible decision, release bundle metadata, and audit-chain summary." + }, + { + "id": "excluded_evidence", + "label": "Excluded evidence", + "status": "documented", + "detail": "Excludes raw tenant evidence bytes, storage references, bearer credentials, signing material, API key hashes, SSO or session hashes, and internal decision notes." + }, + { + "id": "hash_signature_verification", + "label": "Hash and signature verification", + "status": "available", + "detail": "Verify the package archive or manifest hash with evydence package verify, then inspect release bundle and audit-chain verification material." + }, + { + "id": "non_claims", + "label": "Non-claims", + "status": "documented", + "detail": "The package supports technical evidence review and compliance readiness only; it is not legal compliance proof, certification, complete SBOM proof, an authoritative vulnerability result, or a secure-release guarantee." + }, + { + "id": "escalation_path", + "label": "Escalation path", + "status": "documented", + "detail": "Ask the package owner for missing release-specific evidence, a regenerated package with a broader approved redaction profile, or deployment-specific assumptions." + } + ], + "limitations": [ + "Package contents are scoped by product, release, redaction profile, and package expiry.", + "Raw tenant evidence payload bytes, object-store payload references, bearer tokens, private keys, API key hashes, SSO/session token hashes, and internal decision notes are not included.", + "Package data reflects records present in this Evydence instance at generation time." + ], + "non_claims": [ + "This package supports technical evidence review and compliance readiness only.", + "It is not legal compliance proof, certification, complete SBOM proof, an authoritative vulnerability result, regulator acceptance, or a secure-release guarantee." + ] +} diff --git a/examples/end-to-end-release-evidence/sample-customer-package-manifest.sha256 b/examples/end-to-end-release-evidence/sample-customer-package-manifest.sha256 new file mode 100644 index 0000000..b54ae64 --- /dev/null +++ b/examples/end-to-end-release-evidence/sample-customer-package-manifest.sha256 @@ -0,0 +1 @@ +cb4ddb298d888c0d7512eddfcfc38d617fe463d90020f012a0210d6aa238bcb1 sample-customer-package-manifest.json diff --git a/examples/end-to-end-release-evidence/sample-customer-package.zip b/examples/end-to-end-release-evidence/sample-customer-package.zip new file mode 100644 index 0000000..5c275c2 Binary files /dev/null and b/examples/end-to-end-release-evidence/sample-customer-package.zip differ diff --git a/examples/end-to-end-release-evidence/sample-readiness-report.json b/examples/end-to-end-release-evidence/sample-readiness-report.json new file mode 100644 index 0000000..e31fa4b --- /dev/null +++ b/examples/end-to-end-release-evidence/sample-readiness-report.json @@ -0,0 +1,133 @@ +{ + "data": { + "report_type": "release_readiness", + "template_version": "release-readiness.v1.0.0", + "release_id": "rel_example", + "result": "failed", + "policy_set": "policy-set.v1.0.0", + "summary": { + "headline": "Recorded release evidence has readiness blockers or missing inputs.", + "result": "failed", + "human_summary": "The report found 1 failed policy check, 1 missing evidence input, and 1 blocking finding in the recorded release evidence.", + "policy_set": "policy-set.v1.0.0" + }, + "checks": [ + { + "name": "release_requires_sbom", + "result": "passed", + "severity": "high", + "explanation": "sbom evidence exists" + }, + { + "name": "critical_exploitable_blocks_release", + "result": "failed", + "severity": "critical", + "missing": [ + "vulnerability_decision" + ], + "explanation": "open critical finding requires remediation, a valid VEX decision, or an approved unexpired exception", + "remediation": "Record a fixed or not_affected vulnerability decision, upload VEX evidence, remediate the finding, or approve an unexpired scoped exception." + } + ], + "sections": [ + { + "id": "release_evidence", + "title": "Release Evidence", + "status": "passed", + "summary": "Recorded evidence satisfies this section.", + "questions": [ + { + "id": "sbom", + "question": "Is there an SBOM for this release?", + "answer": "SBOM evidence is recorded for this release.", + "status": "passed", + "checks": [ + "release_requires_sbom" + ] + } + ] + }, + { + "id": "risk_decisions", + "title": "Vulnerability Decisions", + "status": "missing", + "summary": "One or more reviewer questions is missing evidence.", + "questions": [ + { + "id": "critical_findings_triaged", + "question": "Are open critical findings triaged?", + "answer": "One or more open critical findings require a valid decision, remediation, or approved unexpired exception.", + "status": "missing_evidence", + "checks": [ + "critical_exploitable_blocks_release" + ], + "missing_evidence": [ + "vulnerability_decision" + ], + "failed_policies": [ + "critical_exploitable_blocks_release" + ] + } + ] + }, + { + "id": "gaps_and_limitations", + "title": "Gaps And Limitations", + "status": "failed", + "summary": "One or more reviewer questions failed a policy check.", + "questions": [ + { + "id": "remaining_gaps", + "question": "What gaps remain?", + "answer": "The report lists missing evidence and failed policy checks that should be reviewed.", + "status": "failed_policy", + "missing_evidence": [ + "vulnerability_decision" + ], + "failed_policies": [ + "critical_exploitable_blocks_release" + ] + } + ] + } + ], + "blocking_findings": [ + { + "finding_id": "finding_example", + "scan_id": "scan_example", + "release_id": "rel_example", + "vulnerability": "CVE-2026-0099", + "component": "pkg:apk/openssl@3.1.0", + "severity": "critical", + "state": "open" + } + ], + "gaps": [ + "vulnerability_decision" + ], + "missing_evidence": [ + "vulnerability_decision" + ], + "failed_policies": [ + "critical_exploitable_blocks_release" + ], + "known_limitations": [ + "Readiness is based only on evidence, decisions, exceptions, bundles, and build records stored in this Evydence instance.", + "SBOM presence does not prove component inventory completeness.", + "Scanner output is submitted evidence and is not treated as authoritative vulnerability truth." + ], + "non_claims": [ + "This report supports technical evidence review and compliance readiness only.", + "It is not legal compliance proof, certification, complete SBOM proof, an authoritative vulnerability result, regulator acceptance, or a secure-release guarantee." + ], + "assumptions": [ + "This report supports compliance readiness and technical evidence review; it is not a legal compliance conclusion." + ], + "limitations": [ + "Readiness is based only on evidence, decisions, exceptions, bundles, and build records stored in this Evydence instance.", + "SBOM presence does not prove component inventory completeness.", + "Scanner output is submitted evidence and is not treated as authoritative vulnerability truth." + ], + "generated_at": "2026-05-27T12:00:00Z" + } +} diff --git a/examples/end-to-end-release-evidence/sample-security-summary.json b/examples/end-to-end-release-evidence/sample-security-summary.json new file mode 100644 index 0000000..cf9d960 --- /dev/null +++ b/examples/end-to-end-release-evidence/sample-security-summary.json @@ -0,0 +1,56 @@ +{ + "data": { + "product": { + "id": "prod_example", + "name": "Demo Payments API", + "slug": "demo-payments-api" + }, + "release": { + "id": "rel_example", + "version": "1.0.0", + "state": "draft" + }, + "artifact_count": 1, + "sbom_status": "present", + "vulnerability_scan_status": "present", + "open_findings_by_severity": { + "critical": 1 + }, + "decisions_by_status": { + "not_affected": 1 + }, + "approval_summary": { + "total": 0, + "approved": 0 + }, + "exception_summary": { + "total": 0, + "approved_unexpired": 0, + "unapproved": 0, + "expired": 0 + }, + "readiness_status": "passed", + "package_status": "generated", + "counts": { + "artifact_refs": 1, + "build_attestations": 1, + "customer_packages": 1, + "passed_builds": 1, + "release_bundles": 1, + "sboms": 1, + "vex_documents": 0, + "vulnerability_decisions": 1, + "vulnerability_scans": 1 + }, + "assumptions": [ + "Summary values are derived only from evidence, decisions, exceptions, approvals, bundles, packages, and build records in this Evydence tenant.", + "Open finding counts reflect uploaded scanner evidence and recorded decisions or exceptions; scanner results are not treated as complete or authoritative coverage." + ], + "limitations": [ + "This summary supports technical review and compliance readiness, not legal compliance conclusions, certification, or release security guarantees.", + "Raw SBOM, scanner, VEX, build, and package payload bytes are intentionally excluded from the summary." + ], + "schema_version": "release-security-summary.v1.0.0", + "generated_at": "2026-05-27T12:00:00Z" + } +} diff --git a/examples/sdk/go/main.go b/examples/sdk/go/main.go new file mode 100644 index 0000000..b876df4 --- /dev/null +++ b/examples/sdk/go/main.go @@ -0,0 +1,131 @@ +package main + +import ( + "context" + "fmt" + "os" + + "github.com/aatuh/evydence/sdk/go/evydence" +) + +func main() { + client := evydence.Client{ + BaseURL: env("EVYDENCE_URL", "http://localhost:8080"), + APIKey: os.Getenv("EVYDENCE_API_KEY"), + } + + ctx := context.Background() + + var product map[string]any + if err := client.CreateProduct(context.Background(), "example-go-product", evydence.CreateProductRequest{ + Name: "Example API", + Slug: "example-api", + }, &product); err != nil { + panic(err) + } + productID := id(product) + + var release map[string]any + if err := client.CreateRelease(ctx, "example-go-release", evydence.CreateReleaseRequest{ + ProductID: productID, + Version: "1.0.0", + }, &release); err != nil { + panic(err) + } + releaseID := id(release) + + var artifact map[string]any + if err := client.RegisterArtifact(ctx, "example-go-artifact", evydence.RegisterArtifactRequest{ + ReleaseID: releaseID, + Name: "example-api.tar.gz", + MediaType: "application/gzip", + Digest: "sha256:ca978112ca1bbdcafac231b39a23dc4da786eff8147c4e72b9807785afee48bb", + Size: 42, + }, &artifact); err != nil { + panic(err) + } + artifactID := id(artifact) + + if err := client.Post(ctx, "/v1/sboms", "example-go-sbom", map[string]any{ + "release_id": releaseID, + "artifact_id": artifactID, + "payload": map[string]any{ + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "components": []map[string]string{{ + "name": "openssl", + "purl": "pkg:apk/openssl@3.1.0", + }}, + }, + }, nil); err != nil { + panic(err) + } + + var scan map[string]any + if err := client.Post(ctx, "/v1/vulnerability-scans", "example-go-scan", map[string]any{ + "release_id": releaseID, + "scanner": "grype", + "target_ref": "pkg:oci/example-api", + "findings": []map[string]string{{ + "vulnerability": "CVE-2026-0099", + "component": "pkg:apk/openssl@3.1.0", + "severity": "critical", + "state": "open", + }}, + }, &scan); err != nil { + panic(err) + } + findingID := nestedID(scan, "findings") + + if err := client.Post(ctx, "/v1/vulnerability-findings/"+findingID+"/decisions", "example-go-decision", map[string]any{ + "status": "not_affected", + "justification": "Example decision; replace with real technical analysis.", + }, nil); err != nil { + panic(err) + } + + var readiness map[string]any + if err := client.ReleaseReadiness(ctx, releaseID, &readiness); err != nil { + panic(err) + } + fmt.Printf("release readiness: %#v\n", readiness["data"]) +} + +func env(key, fallback string) string { + if value := os.Getenv(key); value != "" { + return value + } + return fallback +} + +func id(envelope map[string]any) string { + data, ok := envelope["data"].(map[string]any) + if !ok { + panic("missing data envelope") + } + value, ok := data["id"].(string) + if !ok || value == "" { + panic("missing id") + } + return value +} + +func nestedID(envelope map[string]any, field string) string { + data, ok := envelope["data"].(map[string]any) + if !ok { + panic("missing data envelope") + } + items, ok := data[field].([]any) + if !ok || len(items) == 0 { + panic("missing nested items") + } + first, ok := items[0].(map[string]any) + if !ok { + panic("nested item has unexpected shape") + } + value, ok := first["id"].(string) + if !ok || value == "" { + panic("missing nested id") + } + return value +} diff --git a/examples/sdk/go/main_test.go b/examples/sdk/go/main_test.go new file mode 100644 index 0000000..940d9ac --- /dev/null +++ b/examples/sdk/go/main_test.go @@ -0,0 +1,130 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" +) + +func TestMainRunsCoreEvidenceFlow(t *testing.T) { + type observedRequest struct { + method string + path string + idempotencyKey string + } + var observed []observedRequest + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Authorization"); got != "Bearer example-secret" { + t.Fatalf("unexpected auth header %q", got) + } + observed = append(observed, observedRequest{method: r.Method, path: r.URL.RequestURI(), idempotencyKey: r.Header.Get("Idempotency-Key")}) + if r.Method == http.MethodPost && strings.TrimSpace(r.Header.Get("Idempotency-Key")) == "" { + t.Fatalf("missing idempotency key for %s", r.URL.Path) + } + + var body map[string]any + if r.Body != nil { + if err := json.NewDecoder(r.Body).Decode(&body); err != nil && r.Method != http.MethodGet { + t.Fatalf("decode request for %s: %v", r.URL.Path, err) + } + } + switch { + case r.Method == http.MethodPost && r.URL.Path == "/v1/products": + assertField(t, body, "slug", "example-api") + writeJSON(w, http.StatusCreated, map[string]any{"data": map[string]any{"id": "prod_1"}}) + case r.Method == http.MethodPost && r.URL.Path == "/v1/releases": + assertField(t, body, "product_id", "prod_1") + writeJSON(w, http.StatusCreated, map[string]any{"data": map[string]any{"id": "rel_1"}}) + case r.Method == http.MethodPost && r.URL.Path == "/v1/artifacts": + assertField(t, body, "release_id", "rel_1") + writeJSON(w, http.StatusCreated, map[string]any{"data": map[string]any{"id": "art_1"}}) + case r.Method == http.MethodPost && r.URL.Path == "/v1/sboms": + assertField(t, body, "artifact_id", "art_1") + writeJSON(w, http.StatusCreated, map[string]any{"data": map[string]any{"id": "sbom_1"}}) + case r.Method == http.MethodPost && r.URL.Path == "/v1/vulnerability-scans": + if _, ok := body["scanned_at"]; ok { + t.Fatal("example sent unsupported scanned_at field") + } + assertField(t, body, "release_id", "rel_1") + writeJSON(w, http.StatusCreated, map[string]any{"data": map[string]any{ + "id": "scan_1", + "findings": []map[string]any{{"id": "finding_1"}}, + }}) + case r.Method == http.MethodPost && r.URL.Path == "/v1/vulnerability-findings/finding_1/decisions": + assertField(t, body, "status", "not_affected") + writeJSON(w, http.StatusCreated, map[string]any{"data": map[string]any{"id": "decision_1"}}) + case r.Method == http.MethodGet && r.URL.Path == "/v1/reports/release-readiness": + if got := r.URL.Query().Get("release_id"); got != "rel_1" { + t.Fatalf("unexpected release_id query %q", got) + } + writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"result": "failed"}}) + default: + t.Fatalf("unexpected request %s %s", r.Method, r.URL.RequestURI()) + } + })) + defer server.Close() + + t.Setenv("EVYDENCE_URL", server.URL) + t.Setenv("EVYDENCE_API_KEY", "example-secret") + + stdout := os.Stdout + devNull, err := os.Open(os.DevNull) + if err != nil { + t.Fatal(err) + } + defer devNull.Close() + os.Stdout = devNull + defer func() { os.Stdout = stdout }() + + main() + + wantPaths := []string{ + "/v1/products", + "/v1/releases", + "/v1/artifacts", + "/v1/sboms", + "/v1/vulnerability-scans", + "/v1/vulnerability-findings/finding_1/decisions", + "/v1/reports/release-readiness?release_id=rel_1", + } + if len(observed) != len(wantPaths) { + t.Fatalf("observed %d requests, want %d: %#v", len(observed), len(wantPaths), observed) + } + for i, want := range wantPaths { + if observed[i].path != want { + t.Fatalf("request %d path = %q, want %q", i, observed[i].path, want) + } + } +} + +func TestEnvelopeIDHelpersRejectUnexpectedShapes(t *testing.T) { + assertPanics(t, func() { _ = id(map[string]any{}) }) + assertPanics(t, func() { _ = nestedID(map[string]any{"data": map[string]any{"findings": []any{}}}, "findings") }) +} + +func assertField(t *testing.T, body map[string]any, field, want string) { + t.Helper() + got, _ := body[field].(string) + if got != want { + t.Fatalf("%s = %q, want %q", field, got, want) + } +} + +func writeJSON(w http.ResponseWriter, status int, payload map[string]any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(payload) +} + +func assertPanics(t *testing.T, fn func()) { + t.Helper() + defer func() { + if recover() == nil { + t.Fatal("expected panic") + } + }() + fn() +} diff --git a/examples/sdk/python/example.py b/examples/sdk/python/example.py new file mode 100644 index 0000000..98d455e --- /dev/null +++ b/examples/sdk/python/example.py @@ -0,0 +1,77 @@ +import os + +from evydence_client import EvydenceClient + + +client = EvydenceClient( + base_url=os.environ.get("EVYDENCE_URL", "http://localhost:8080"), + api_key=os.environ["EVYDENCE_API_KEY"], +) + +product = client.create_product( + "example-python-product", + {"name": "Example API", "slug": "example-api"}, +) +product_id = product["data"]["id"] + +release = client.create_release( + "example-python-release", + {"product_id": product_id, "version": "1.0.0"}, +) +release_id = release["data"]["id"] + +artifact = client.register_artifact( + "example-python-artifact", + { + "release_id": release_id, + "name": "example-api.tar.gz", + "media_type": "application/gzip", + "digest": "sha256:ca978112ca1bbdcafac231b39a23dc4da786eff8147c4e72b9807785afee48bb", + "size": 42, + }, +) +artifact_id = artifact["data"]["id"] + +client.post( + "/v1/sboms", + "example-python-sbom", + { + "release_id": release_id, + "artifact_id": artifact_id, + "payload": { + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "components": [{"name": "openssl", "purl": "pkg:apk/openssl@3.1.0"}], + }, + }, +) + +scan = client.post( + "/v1/vulnerability-scans", + "example-python-scan", + { + "release_id": release_id, + "scanner": "grype", + "target_ref": "pkg:oci/example-api", + "findings": [ + { + "vulnerability": "CVE-2026-0099", + "component": "pkg:apk/openssl@3.1.0", + "severity": "critical", + "state": "open", + } + ], + }, +) +finding_id = scan["data"]["findings"][0]["id"] + +client.post( + f"/v1/vulnerability-findings/{finding_id}/decisions", + "example-python-decision", + { + "status": "not_affected", + "justification": "Example decision; replace with real technical analysis.", + }, +) + +print(client.release_readiness(release_id)["data"]) diff --git a/examples/sdk/typescript/example.ts b/examples/sdk/typescript/example.ts new file mode 100644 index 0000000..3cc228a --- /dev/null +++ b/examples/sdk/typescript/example.ts @@ -0,0 +1,64 @@ +import { EvydenceClient } from "../../sdk/typescript/client"; + +const client = new EvydenceClient({ + baseUrl: process.env.EVYDENCE_URL ?? "http://localhost:8080", + apiKey: process.env.EVYDENCE_API_KEY ?? "", +}); + +const product = await client.createProduct<{ data: { id: string } }>( + "example-typescript-product", + { name: "Example API", slug: "example-api" }, +); + +const release = await client.createRelease<{ data: { id: string } }>( + "example-typescript-release", + { product_id: product.data.id, version: "1.0.0" }, +); + +const artifact = await client.registerArtifact<{ data: { id: string } }>( + "example-typescript-artifact", + { + release_id: release.data.id, + name: "example-api.tar.gz", + media_type: "application/gzip", + digest: "sha256:ca978112ca1bbdcafac231b39a23dc4da786eff8147c4e72b9807785afee48bb", + size: 42, + }, +); + +await client.post("/v1/sboms", "example-typescript-sbom", { + release_id: release.data.id, + artifact_id: artifact.data.id, + payload: { + bomFormat: "CycloneDX", + specVersion: "1.6", + components: [{ name: "openssl", purl: "pkg:apk/openssl@3.1.0" }], + }, +}); + +const scan = await client.post<{ data: { findings: Array<{ id: string }> } }>( + "/v1/vulnerability-scans", + "example-typescript-scan", + { + release_id: release.data.id, + scanner: "grype", + target_ref: "pkg:oci/example-api", + findings: [{ + vulnerability: "CVE-2026-0099", + component: "pkg:apk/openssl@3.1.0", + severity: "critical", + state: "open", + }], + }, +); + +await client.post( + `/v1/vulnerability-findings/${scan.data.findings[0].id}/decisions`, + "example-typescript-decision", + { + status: "not_affected", + justification: "Example decision; replace with real technical analysis.", + }, +); + +console.log(await client.releaseReadiness(release.data.id)); diff --git a/go.mod b/go.mod index 5646298..ffe059e 100644 --- a/go.mod +++ b/go.mod @@ -1,14 +1,30 @@ module github.com/aatuh/evydence -go 1.25.0 +go 1.25.10 require ( github.com/aatuh/api-toolkit/v3 v3.1.2 - github.com/getkin/kin-openapi v0.133.0 + github.com/aws/aws-sdk-go-v2/config v1.32.20 + github.com/aws/aws-sdk-go-v2/service/kms v1.52.2 + github.com/getkin/kin-openapi v0.139.0 github.com/jackc/pgx/v5 v5.9.2 + github.com/minio/minio-go/v7 v7.2.1 ) require ( + github.com/aws/aws-sdk-go-v2 v1.41.9 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.19.19 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.25 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.25 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.25 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.26 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.10 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.25 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.1.1 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.30.19 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.2 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.42.3 // indirect + github.com/aws/smithy-go v1.26.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect @@ -24,21 +40,21 @@ require ( github.com/mailru/easyjson v0.7.7 // indirect github.com/minio/crc64nvme v1.1.1 // indirect github.com/minio/md5-simd v1.1.2 // indirect - github.com/minio/minio-go/v7 v7.2.0 // indirect github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect - github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect - github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect + github.com/oasdiff/yaml v0.1.0 // indirect + github.com/oasdiff/yaml3 v0.0.13 // indirect github.com/perimeterx/marshmallow v1.1.5 // indirect github.com/philhofer/fwd v1.2.0 // indirect github.com/rs/xid v1.6.0 // indirect + github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect github.com/tinylib/msgp v1.6.1 // indirect github.com/woodsbury/decimal128 v1.3.0 // indirect github.com/zeebo/xxh3 v1.1.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.51.0 // indirect - golang.org/x/net v0.53.0 // indirect + golang.org/x/crypto v0.52.0 // indirect + golang.org/x/net v0.55.0 // indirect golang.org/x/sync v0.20.0 // indirect - golang.org/x/sys v0.44.0 // indirect + golang.org/x/sys v0.45.0 // indirect golang.org/x/text v0.37.0 // indirect gopkg.in/ini.v1 v1.67.2 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index 73ff4b8..0837f4b 100644 --- a/go.sum +++ b/go.sum @@ -1,14 +1,46 @@ github.com/aatuh/api-toolkit/v3 v3.1.2 h1:QT/gH6L/rP9G2Defl1Zj71cQYQ0AJ0kp+NjoBvRs6gs= github.com/aatuh/api-toolkit/v3 v3.1.2/go.mod h1:aZrzzBRyglFi5KaHpAomD+R9I7qp3rL8vmBi7xlloMw= +github.com/aws/aws-sdk-go-v2 v1.41.9 h1:/rYeyO2+HrMztAmxAq9++XJtFMqSIpSsNA0yDGALYq4= +github.com/aws/aws-sdk-go-v2 v1.41.9/go.mod h1:+HsoOEX80qAVUitj1A2DhCNTjmb3edVyuDypb6LNEeo= +github.com/aws/aws-sdk-go-v2/config v1.32.20 h1:8VMDnWc/kEzxsI/1ngGM9mG81a8IGmIHD8KLcYGwagc= +github.com/aws/aws-sdk-go-v2/config v1.32.20/go.mod h1:PuwEpciweIXGULWeOeSTXtSbH4CW9mWdWrhdCKQI1sM= +github.com/aws/aws-sdk-go-v2/credentials v1.19.19 h1:yuFzSV1U0aRNYCQGVaTY2zW2M/L93pYHnXnrJUphYhU= +github.com/aws/aws-sdk-go-v2/credentials v1.19.19/go.mod h1:7y63L1kGzeoDlJaQ3Z578KrnmfBut96JjvJUzGwR+YE= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.25 h1:0w6dCiO8iez+YKwRhRBlL1CH/E3GTfdkuzrwj1by8vo= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.25/go.mod h1:9FDWUothyr5RCRAHc45XOiVCzUR8n/IhCYX+uVqw6vk= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.25 h1:Uii3frf9ztec/ABM2/FSH9/z7PLzxfpG8h4RpkUFflQ= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.25/go.mod h1:G6kntsA2GorAxDPbap6xgB2F+amSLUF8GJTi7PUoX44= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.25 h1:r1+/l6m+WaUJF9HISEsNOLHSNj5EXYQxK8VX6Cz9NlA= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.25/go.mod h1:cKf+D+NMDK1LndD7BowHbBZPgR9V0/5HubH0PFWvA+c= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.26 h1:A1PmWU2zfkIm9EyFlJncFXL4W4phML+h8KjltUsCvNQ= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.26/go.mod h1:dY4MRzXEizrD4hqtpKvWVGPX7QleSGGVY+EBolo1RmM= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.10 h1:d5/908OJ4bXg8lyjeMPvXetEKqoDoLi5Owy1zNue3yg= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.10/go.mod h1:a57l7Hwh+FWI+we50g5NPJHYUKeJKfXbc4w8SyXu8Ig= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.25 h1:dD3dhHNglpd98gs72my22Ndqi1hqQGllFFg1F+twfxg= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.25/go.mod h1:0yAbjPfd64gG7mj85RW+fMEYdfBgCRZw8g/oWcL1pjc= +github.com/aws/aws-sdk-go-v2/service/kms v1.52.2 h1:J0TorhKhXYPjIgLWd/J+q2bJVGywwyFApA+6Iqf2808= +github.com/aws/aws-sdk-go-v2/service/kms v1.52.2/go.mod h1:oqZYP0JN0ih1JTsoiT10Un/Ivg8LeVOMTK+UDNBq3sU= +github.com/aws/aws-sdk-go-v2/service/signin v1.1.1 h1:1VwbP3qMNfxUDEXWki4rCE5iA+44VA1lokTz9HasGzw= +github.com/aws/aws-sdk-go-v2/service/signin v1.1.1/go.mod h1:vUtyoSj0OPji3kjIVSc/GlKuWEiL33f/WFxl6dmpy/A= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.19 h1:N6pIsdFOW1Kd9S4KyFKXdGRBojPPxkP32+uHFWLv4Hc= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.19/go.mod h1:3gt5WJArFooNmyLONS+h/R4J+o86II8du38IgCwj9dE= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.2 h1:hc+lBYiiTr8Zk4MTzIsQ92MeDWCIDvWGmzKUWOaBcOg= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.2/go.mod h1:hU6fqB3OJA6/ePheD47LQnxvjYk6br6PtQxs+Q9ojvk= +github.com/aws/aws-sdk-go-v2/service/sts v1.42.3 h1:ErklX/7uhSbkAAeyQD/Y1OoQ9hO3SJXQNEgksORW3Js= +github.com/aws/aws-sdk-go-v2/service/sts v1.42.3/go.mod h1:ULe4HCzfKPiR6R3HEurE3b1upEkuk8AkMrOKtaOxKO8= +github.com/aws/smithy-go v1.26.0 h1:9ouqbi+NyKP7fV3Te7UElCwdAb6Y8uk7LGwPE5tVe/s= +github.com/aws/smithy-go v1.26.0/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8= +github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz6k8PQ= -github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE= +github.com/getkin/kin-openapi v0.139.0 h1:pBFXcZJFwz9J1X64jzxlOoNgFm+TF7kNrs9+HJVN6Ic= +github.com/getkin/kin-openapi v0.139.0/go.mod h1:NGxPfE4PwS/TRXEbyx2RrxDFPZvxcWw31Tw8XXjPZLs= github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= @@ -44,25 +76,26 @@ github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg= github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM= -github.com/minio/minio-go/v7 v7.2.0 h1:RCJM0R1XOsRs+A3x3UCaf3ZYbByDaLjFeAi+YCQEPhs= -github.com/minio/minio-go/v7 v7.2.0/go.mod h1:EU9hENAStx/xXduNdrGO5e4X5vk19NtgB+RIPjZO8o0= +github.com/minio/minio-go/v7 v7.2.1 h1:PfBfwvKB/MmqyN8Vb1G9voWisaM9OrLv+WwOvMwS9Dw= +github.com/minio/minio-go/v7 v7.2.1/go.mod h1:EU9hENAStx/xXduNdrGO5e4X5vk19NtgB+RIPjZO8o0= github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= -github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY= -github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw= -github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c= -github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o= +github.com/oasdiff/yaml v0.1.0 h1:0bqZjfKc/8S9urj4JuwepX41WX9EoA6ifhU3SV06cXg= +github.com/oasdiff/yaml v0.1.0/go.mod h1:kOlRmMdL2X3vucLCEQO5u61SU22RysnfXvcttrZA1O0= +github.com/oasdiff/yaml3 v0.0.13 h1:06svmvOHOVBqF81+sY2EUScvUI/iS/vl2VIeUUxZQwg= +github.com/oasdiff/yaml3 v0.0.13/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o= github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= -github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -80,22 +113,20 @@ github.com/ugorji/go/codec v1.2.7 h1:YPXUKf7fYbp/y8xloBqZOw2qaVggbfwMlI8WM3wZUJ0 github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY= github.com/woodsbury/decimal128 v1.3.0 h1:8pffMNWIlC0O5vbyHWFZAt5yWvWcrHA+3ovIIjVWss0= github.com/woodsbury/decimal128 v1.3.0/go.mod h1:C5UTmyTjW3JftjUFzOVhC20BEQa2a4ZKOB5I6Zjb+ds= +github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= +github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= -golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= -golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= -golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= -golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= -golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= +golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= -golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= -golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/adapters/httpapi/future_handlers.go b/internal/adapters/httpapi/future_handlers.go new file mode 100644 index 0000000..20d4b12 --- /dev/null +++ b/internal/adapters/httpapi/future_handlers.go @@ -0,0 +1,239 @@ +package httpapi + +import ( + "net/http" + + "github.com/aatuh/evydence/internal/app" + "github.com/aatuh/evydence/internal/domain" +) + +func (s *Server) createEvidenceSummary(w http.ResponseWriter, r *http.Request) { + var req struct { + SubjectType string `json:"subject_type"` + SubjectID string `json:"subject_id"` + EvidenceIDs []string `json:"evidence_ids"` + } + s.create(w, r, func(ctx requestContext, actor domain.Actor, body []byte) (int, any, error) { + if err := decodeJSON(body, &req); err != nil { + return 0, nil, err + } + summary, err := s.ledger.CreateEvidenceSummary(ctx, actor, app.CreateEvidenceSummaryInput{SubjectType: req.SubjectType, SubjectID: req.SubjectID, EvidenceIDs: req.EvidenceIDs}) + return http.StatusCreated, summary, err + }) +} + +func (s *Server) createQuestionnaireDraft(w http.ResponseWriter, r *http.Request) { + var req struct { + TemplateID string `json:"template_id"` + ProductID string `json:"product_id"` + ReleaseID string `json:"release_id"` + } + s.create(w, r, func(ctx requestContext, actor domain.Actor, body []byte) (int, any, error) { + if err := decodeJSON(body, &req); err != nil { + return 0, nil, err + } + draft, err := s.ledger.CreateQuestionnaireDraft(ctx, actor, app.CreateQuestionnaireDraftInput{TemplateID: req.TemplateID, ProductID: req.ProductID, ReleaseID: req.ReleaseID}) + return http.StatusCreated, draft, err + }) +} + +func (s *Server) createGraphSnapshot(w http.ResponseWriter, r *http.Request) { + var req struct { + ProductID string `json:"product_id"` + ReleaseID string `json:"release_id"` + } + s.create(w, r, func(ctx requestContext, actor domain.Actor, body []byte) (int, any, error) { + if err := decodeJSON(body, &req); err != nil { + return 0, nil, err + } + graph, err := s.ledger.CreateGraphSnapshot(ctx, actor, app.CreateGraphSnapshotInput{ProductID: req.ProductID, ReleaseID: req.ReleaseID}) + return http.StatusCreated, graph, err + }) +} + +func (s *Server) createSaaSEditionProfile(w http.ResponseWriter, r *http.Request) { + var req struct { + Name string `json:"name"` + Region string `json:"region"` + AdminTenantID string `json:"admin_tenant_id"` + IsolationModel string `json:"isolation_model"` + } + s.create(w, r, func(ctx requestContext, actor domain.Actor, body []byte) (int, any, error) { + if err := decodeJSON(body, &req); err != nil { + return 0, nil, err + } + profile, err := s.ledger.CreateSaaSEditionProfile(ctx, actor, app.CreateSaaSEditionProfileInput{Name: req.Name, Region: req.Region, AdminTenantID: req.AdminTenantID, IsolationModel: req.IsolationModel}) + return http.StatusCreated, profile, err + }) +} + +func (s *Server) createPublicTransparencyLog(w http.ResponseWriter, r *http.Request) { + var req struct { + Name string `json:"name"` + Endpoint string `json:"endpoint"` + PublicKey string `json:"public_key"` + } + s.create(w, r, func(ctx requestContext, actor domain.Actor, body []byte) (int, any, error) { + if err := decodeJSON(body, &req); err != nil { + return 0, nil, err + } + log, err := s.ledger.CreatePublicTransparencyLog(ctx, actor, app.CreatePublicTransparencyLogInput{Name: req.Name, Endpoint: req.Endpoint, PublicKey: req.PublicKey}) + return http.StatusCreated, log, err + }) +} + +func (s *Server) publishPublicTransparencyLogEntry(w http.ResponseWriter, r *http.Request) { + var req struct { + LogID string `json:"log_id"` + CheckpointID string `json:"checkpoint_id"` + ExternalID string `json:"external_id"` + } + s.create(w, r, func(ctx requestContext, actor domain.Actor, body []byte) (int, any, error) { + if err := decodeJSON(body, &req); err != nil { + return 0, nil, err + } + entry, err := s.ledger.PublishPublicTransparencyLogEntry(ctx, actor, app.PublishPublicTransparencyLogEntryInput{LogID: req.LogID, CheckpointID: req.CheckpointID, ExternalID: req.ExternalID}) + return http.StatusCreated, entry, err + }) +} + +func (s *Server) verifyPublicTransparencyLogEntry(w http.ResponseWriter, r *http.Request) { + var req struct { + LeafHash string `json:"leaf_hash"` + RootHash string `json:"root_hash"` + LeafIndex int `json:"leaf_index"` + TreeSize int `json:"tree_size"` + InclusionProof []string `json:"inclusion_proof"` + } + s.create(w, r, func(ctx requestContext, actor domain.Actor, body []byte) (int, any, error) { + if err := decodeJSON(body, &req); err != nil { + return 0, nil, err + } + entry, err := s.ledger.VerifyPublicTransparencyLogEntry(ctx, actor, r.PathValue("id"), app.VerifyPublicTransparencyLogEntryInput{ + LeafHash: req.LeafHash, + RootHash: req.RootHash, + LeafIndex: req.LeafIndex, + TreeSize: req.TreeSize, + InclusionProof: req.InclusionProof, + }) + return http.StatusOK, entry, err + }) +} + +func (s *Server) fetchPublicTransparencyLogEntryProof(w http.ResponseWriter, r *http.Request) { + s.create(w, r, func(ctx requestContext, actor domain.Actor, _ []byte) (int, any, error) { + entry, err := s.ledger.FetchAndVerifyPublicTransparencyLogEntry(ctx, actor, r.PathValue("id")) + return http.StatusOK, entry, err + }) +} + +func (s *Server) createMarketplaceCollector(w http.ResponseWriter, r *http.Request) { + var req struct { + Name string `json:"name"` + Provider string `json:"provider"` + Version string `json:"version"` + Publisher string `json:"publisher"` + ManifestHash string `json:"manifest_hash"` + SignatureID string `json:"signature_id"` + SBOMID string `json:"sbom_id"` + ScanID string `json:"scan_id"` + } + s.create(w, r, func(ctx requestContext, actor domain.Actor, body []byte) (int, any, error) { + if err := decodeJSON(body, &req); err != nil { + return 0, nil, err + } + collector, err := s.ledger.CreateMarketplaceCollector(ctx, actor, app.CreateMarketplaceCollectorInput{Name: req.Name, Provider: req.Provider, Version: req.Version, Publisher: req.Publisher, ManifestHash: req.ManifestHash, SignatureID: req.SignatureID, SBOMID: req.SBOMID, ScanID: req.ScanID}) + return http.StatusCreated, collector, err + }) +} + +func (s *Server) listMarketplaceCollectors(w http.ResponseWriter, r *http.Request) { + actor, ok := s.authenticate(w, r) + if !ok { + return + } + collectors, err := s.ledger.ListMarketplaceCollectors(r.Context(), actor) + if err != nil { + writeProblem(w, r, err) + return + } + writeData(w, http.StatusOK, collectors) +} + +func (s *Server) marketplaceCollectorHealth(w http.ResponseWriter, r *http.Request) { + actor, ok := s.authenticate(w, r) + if !ok { + return + } + report, err := s.ledger.MarketplaceCollectorHealth(r.Context(), actor, r.PathValue("id")) + if err != nil { + writeProblem(w, r, err) + return + } + writeData(w, http.StatusOK, report) +} + +func (s *Server) createPDFReportPackage(w http.ResponseWriter, r *http.Request) { + var req struct { + ReportType string `json:"report_type"` + ProductID string `json:"product_id"` + ReleaseID string `json:"release_id"` + Title string `json:"title"` + } + s.create(w, r, func(ctx requestContext, actor domain.Actor, body []byte) (int, any, error) { + if err := decodeJSON(body, &req); err != nil { + return 0, nil, err + } + pkg, err := s.ledger.CreatePDFReportPackage(ctx, actor, app.CreatePDFReportPackageInput{ReportType: req.ReportType, ProductID: req.ProductID, ReleaseID: req.ReleaseID, Title: req.Title}) + return http.StatusCreated, pkg, err + }) +} + +func (s *Server) generateAnomalyReport(w http.ResponseWriter, r *http.Request) { + var req struct { + SubjectType string `json:"subject_type"` + SubjectID string `json:"subject_id"` + } + s.create(w, r, func(ctx requestContext, actor domain.Actor, body []byte) (int, any, error) { + if err := decodeJSON(body, &req); err != nil { + return 0, nil, err + } + report, err := s.ledger.GenerateAnomalyReport(ctx, actor, app.AnomalyReportInput{SubjectType: req.SubjectType, SubjectID: req.SubjectID}) + return http.StatusCreated, report, err + }) +} + +func (s *Server) createSigningOperation(w http.ResponseWriter, r *http.Request) { + var req struct { + ProviderID string `json:"provider_id"` + SubjectType string `json:"subject_type"` + SubjectID string `json:"subject_id"` + PayloadHash string `json:"payload_hash"` + ExternalSignature string `json:"external_signature"` + } + s.create(w, r, func(ctx requestContext, actor domain.Actor, body []byte) (int, any, error) { + if err := decodeJSON(body, &req); err != nil { + return 0, nil, err + } + op, err := s.ledger.CreateSigningOperation(ctx, actor, app.CreateSigningOperationInput{ProviderID: req.ProviderID, SubjectType: req.SubjectType, SubjectID: req.SubjectID, PayloadHash: req.PayloadHash, ExternalSignature: req.ExternalSignature}) + return http.StatusCreated, op, err + }) +} + +func (s *Server) verifyProviderIdentity(w http.ResponseWriter, r *http.Request) { + var req struct { + ProviderType string `json:"provider_type"` + ProviderID string `json:"provider_id"` + Subject string `json:"subject"` + IDToken string `json:"id_token"` + SAMLAssertion string `json:"saml_assertion"` + AccessToken string `json:"access_token"` + } + s.create(w, r, func(ctx requestContext, actor domain.Actor, body []byte) (int, any, error) { + if err := decodeJSON(body, &req); err != nil { + return 0, nil, err + } + record, err := s.ledger.VerifyProviderIdentity(ctx, actor, app.VerifyProviderIdentityInput{ProviderType: req.ProviderType, ProviderID: req.ProviderID, Subject: req.Subject, IDToken: req.IDToken, SAMLAssertion: req.SAMLAssertion, AccessToken: req.AccessToken}) + return http.StatusCreated, record, err + }) +} diff --git a/internal/adapters/httpapi/identity_handlers.go b/internal/adapters/httpapi/identity_handlers.go new file mode 100644 index 0000000..d580571 --- /dev/null +++ b/internal/adapters/httpapi/identity_handlers.go @@ -0,0 +1,231 @@ +package httpapi + +import ( + "net/http" + "time" + + "github.com/aatuh/evydence/internal/app" + "github.com/aatuh/evydence/internal/domain" +) + +const ssoSessionCookieName = "evydence_session" + +func (s *Server) instanceAdminSnapshot(w http.ResponseWriter, r *http.Request) { + actor, ok := s.authenticate(w, r) + if !ok { + return + } + snapshot, err := s.ledger.InstanceAdminSnapshot(r.Context(), actor) + if err != nil { + writeProblem(w, r, err) + return + } + writeData(w, http.StatusOK, snapshot) +} + +func (s *Server) createOrganization(w http.ResponseWriter, r *http.Request) { + var req struct { + Name string `json:"name"` + Slug string `json:"slug"` + } + s.create(w, r, func(ctx requestContext, actor domain.Actor, body []byte) (int, any, error) { + if err := decodeJSON(body, &req); err != nil { + return 0, nil, err + } + org, err := s.ledger.CreateOrganization(ctx, actor, app.CreateOrganizationInput{Name: req.Name, Slug: req.Slug}) + return http.StatusCreated, org, err + }) +} + +func (s *Server) createUser(w http.ResponseWriter, r *http.Request) { + var req struct { + OrganizationID string `json:"organization_id"` + Email string `json:"email"` + DisplayName string `json:"display_name"` + } + s.create(w, r, func(ctx requestContext, actor domain.Actor, body []byte) (int, any, error) { + if err := decodeJSON(body, &req); err != nil { + return 0, nil, err + } + user, err := s.ledger.CreateUser(ctx, actor, app.CreateUserInput{OrganizationID: req.OrganizationID, Email: req.Email, DisplayName: req.DisplayName}) + return http.StatusCreated, user, err + }) +} + +func (s *Server) deactivateUser(w http.ResponseWriter, r *http.Request) { + s.create(w, r, func(ctx requestContext, actor domain.Actor, _ []byte) (int, any, error) { + user, err := s.ledger.DeactivateUser(ctx, actor, r.PathValue("id")) + return http.StatusOK, user, err + }) +} + +func (s *Server) createRoleBinding(w http.ResponseWriter, r *http.Request) { + var req struct { + SubjectType string `json:"subject_type"` + SubjectID string `json:"subject_id"` + Role string `json:"role"` + ResourceType string `json:"resource_type"` + ResourceID string `json:"resource_id"` + } + s.create(w, r, func(ctx requestContext, actor domain.Actor, body []byte) (int, any, error) { + if err := decodeJSON(body, &req); err != nil { + return 0, nil, err + } + binding, err := s.ledger.CreateRoleBinding(ctx, actor, app.CreateRoleBindingInput{SubjectType: req.SubjectType, SubjectID: req.SubjectID, Role: req.Role, ResourceType: req.ResourceType, ResourceID: req.ResourceID}) + return http.StatusCreated, binding, err + }) +} + +func (s *Server) listRoleBindings(w http.ResponseWriter, r *http.Request) { + actor, ok := s.authenticate(w, r) + if !ok { + return + } + bindings, err := s.ledger.ListRoleBindings(r.Context(), actor) + if err != nil { + writeProblem(w, r, err) + return + } + writeData(w, http.StatusOK, bindings) +} + +func (s *Server) createSSOProvider(w http.ResponseWriter, r *http.Request) { + var req struct { + Name string `json:"name"` + Type string `json:"type"` + Issuer string `json:"issuer"` + ClientID string `json:"client_id"` + GroupsClaim string `json:"groups_claim"` + RoleMapping map[string]string `json:"role_mapping"` + JWKS map[string]any `json:"jwks"` + SAMLSigningCertificates []string `json:"saml_signing_certificates"` + } + s.create(w, r, func(ctx requestContext, actor domain.Actor, body []byte) (int, any, error) { + if err := decodeJSON(body, &req); err != nil { + return 0, nil, err + } + provider, err := s.ledger.CreateSSOProvider(ctx, actor, app.CreateSSOProviderInput{Name: req.Name, Type: req.Type, Issuer: req.Issuer, ClientID: req.ClientID, GroupsClaim: req.GroupsClaim, RoleMapping: req.RoleMapping, JWKS: req.JWKS, SAMLSigningCertificates: req.SAMLSigningCertificates}) + return http.StatusCreated, provider, err + }) +} + +func (s *Server) updateSSOProviderTrustMaterial(w http.ResponseWriter, r *http.Request) { + var req struct { + JWKS map[string]any `json:"jwks"` + SAMLSigningCertificates []string `json:"saml_signing_certificates"` + } + s.create(w, r, func(ctx requestContext, actor domain.Actor, body []byte) (int, any, error) { + if err := decodeJSON(body, &req); err != nil { + return 0, nil, err + } + provider, err := s.ledger.UpdateSSOProviderTrustMaterial(ctx, actor, r.PathValue("id"), app.UpdateSSOProviderTrustMaterialInput{JWKS: req.JWKS, SAMLSigningCertificates: req.SAMLSigningCertificates}) + return http.StatusOK, provider, err + }) +} + +func (s *Server) refreshSSOProviderOIDCTrustMaterial(w http.ResponseWriter, r *http.Request) { + s.create(w, r, func(ctx requestContext, actor domain.Actor, _ []byte) (int, any, error) { + provider, err := s.ledger.RefreshSSOProviderOIDCTrustMaterial(ctx, actor, r.PathValue("id")) + return http.StatusOK, provider, err + }) +} + +func (s *Server) linkSSOIdentity(w http.ResponseWriter, r *http.Request) { + var req struct { + UserID string `json:"user_id"` + ProviderID string `json:"provider_id"` + Subject string `json:"subject"` + Email string `json:"email"` + Verified bool `json:"verified"` + } + s.create(w, r, func(ctx requestContext, actor domain.Actor, body []byte) (int, any, error) { + if err := decodeJSON(body, &req); err != nil { + return 0, nil, err + } + link, err := s.ledger.LinkSSOIdentity(ctx, actor, app.LinkSSOIdentityInput{UserID: req.UserID, ProviderID: req.ProviderID, Subject: req.Subject, Email: req.Email, Verified: req.Verified}) + return http.StatusCreated, link, err + }) +} + +func (s *Server) createSSOSession(w http.ResponseWriter, r *http.Request) { + var req struct { + UserID string `json:"user_id"` + ProviderID string `json:"provider_id"` + ExpiresAt time.Time `json:"expires_at"` + } + s.create(w, r, func(ctx requestContext, actor domain.Actor, body []byte) (int, any, error) { + if err := decodeJSON(body, &req); err != nil { + return 0, nil, err + } + session, secret, err := s.ledger.CreateSSOSession(ctx, actor, app.CreateSSOSessionInput{UserID: req.UserID, ProviderID: req.ProviderID, ExpiresAt: req.ExpiresAt}) + return http.StatusCreated, map[string]any{"session": session, "secret": secret}, err + }) +} + +func (s *Server) exchangeSSOCredential(w http.ResponseWriter, r *http.Request) { + var req struct { + ProviderID string `json:"provider_id"` + Subject string `json:"subject"` + IDToken string `json:"id_token"` + SAMLAssertion string `json:"saml_assertion"` + ExpiresAt time.Time `json:"expires_at"` + } + body, err := readBody(r) + if err != nil { + writeProblem(w, r, err) + return + } + if err := decodeJSON(body, &req); err != nil { + writeProblem(w, r, err) + return + } + verification, session, secret, err := s.ledger.ExchangeSSOCredential(r.Context(), app.ExchangeSSOCredentialInput{ProviderID: req.ProviderID, Subject: req.Subject, IDToken: req.IDToken, SAMLAssertion: req.SAMLAssertion, ExpiresAt: req.ExpiresAt}) + if err != nil { + writeProblem(w, r, err) + return + } + setSSOSessionCookie(w, secret, session.ExpiresAt) + writeData(w, http.StatusCreated, map[string]any{"verification": verification, "session": session, "secret": secret}) +} + +func (s *Server) revokeSSOSession(w http.ResponseWriter, r *http.Request) { + s.create(w, r, func(ctx requestContext, actor domain.Actor, _ []byte) (int, any, error) { + session, err := s.ledger.RevokeSSOSession(ctx, actor, r.PathValue("id")) + return http.StatusOK, session, err + }) +} + +func (s *Server) logoutSSOSession(w http.ResponseWriter, r *http.Request) { + s.create(w, r, func(ctx requestContext, actor domain.Actor, _ []byte) (int, any, error) { + session, err := s.ledger.RevokeCurrentSSOSession(ctx, actor) + if err == nil { + clearSSOSessionCookie(w) + } + return http.StatusOK, session, err + }) +} + +func setSSOSessionCookie(w http.ResponseWriter, secret string, expiresAt time.Time) { + http.SetCookie(w, &http.Cookie{ + Name: ssoSessionCookieName, + Value: secret, + Path: "/v1", + Expires: expiresAt.UTC(), + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteStrictMode, + }) +} + +func clearSSOSessionCookie(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{ + Name: ssoSessionCookieName, + Value: "", + Path: "/v1", + Expires: time.Unix(0, 0).UTC(), + MaxAge: -1, + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteStrictMode, + }) +} diff --git a/internal/adapters/httpapi/openapi.go b/internal/adapters/httpapi/openapi.go index 40d64c3..0b69f34 100644 --- a/internal/adapters/httpapi/openapi.go +++ b/internal/adapters/httpapi/openapi.go @@ -18,7 +18,2491 @@ func NewSpecRegistry() *specs.Registry { "detail": map[string]any{"type": "string"}, "instance": map[string]any{"type": "string"}, "code": map[string]any{"type": "string"}, + "request_id": map[string]any{ + "type": "string", + "description": "Request identifier mirrored from the X-Request-ID response header.", + }, }, }) + registerCriticalSchemas(registry) return registry } + +func registerCriticalSchemas(registry *specs.Registry) { + registry.RegisterSchema("DataEnvelope", objectSchema(map[string]any{ + "data": map[string]any{}, + "meta": objectSchema(map[string]any{ + "api_version": map[string]any{"type": "string"}, + }, "api_version"), + }, "data", "meta")) + registry.RegisterSchema("EmptyObject", objectSchema(map[string]any{})) + registry.RegisterSchema("HealthStatus", objectSchema(map[string]any{ + "status": map[string]any{"type": "string", "enum": []string{"ok"}}, + }, "status")) + registry.RegisterSchema("HealthStatusEnvelope", dataEnvelopeSchema("#/components/schemas/HealthStatus")) + registry.RegisterSchema("VersionInfo", objectSchema(map[string]any{ + "version": map[string]any{"type": "string"}, + }, "version")) + registry.RegisterSchema("VersionInfoEnvelope", dataEnvelopeSchema("#/components/schemas/VersionInfo")) + registry.RegisterSchema("MetricsSnapshot", objectSchema(map[string]any{ + "tenant_id": map[string]any{"type": "string"}, + "resource_counts": map[string]any{"type": "object", "additionalProperties": map[string]any{"type": "integer"}}, + "customer_portal_failed_access_count": map[string]any{"type": "integer"}, + "customer_portal_revoked_access_count": map[string]any{"type": "integer"}, + }, "tenant_id", "resource_counts", "customer_portal_failed_access_count", "customer_portal_revoked_access_count")) + registry.RegisterSchema("MetricsSnapshotEnvelope", dataEnvelopeSchema("#/components/schemas/MetricsSnapshot")) + registry.RegisterSchema("OpenAPIDocument", map[string]any{ + "type": "object", + "additionalProperties": true, + "properties": map[string]any{ + "openapi": map[string]any{"type": "string"}, + "info": map[string]any{"type": "object", "additionalProperties": true}, + "paths": map[string]any{"type": "object", "additionalProperties": true}, + "components": map[string]any{"type": "object", "additionalProperties": true}, + }, + "required": []string{"openapi", "info", "paths"}, + }) + registry.RegisterSchema("InstanceAdminSnapshot", objectSchema(map[string]any{ + "report_type": map[string]any{"type": "string"}, + "tenant_count": map[string]any{"type": "integer"}, + "resource_counts": map[string]any{"type": "object", "additionalProperties": map[string]any{"type": "integer"}}, + "limitations": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "generated_at": map[string]any{"type": "string", "format": "date-time"}, + }, "report_type", "tenant_count", "resource_counts", "limitations", "generated_at")) + registry.RegisterSchema("InstanceAdminSnapshotEnvelope", dataEnvelopeSchema("#/components/schemas/InstanceAdminSnapshot")) + registry.RegisterSchema("CreateSSOSessionRequest", objectSchema(map[string]any{ + "user_id": map[string]any{"type": "string"}, + "provider_id": map[string]any{"type": "string"}, + "expires_at": map[string]any{"type": "string", "format": "date-time"}, + }, "user_id", "provider_id", "expires_at")) + registry.RegisterSchema("ExchangeSSOCredentialRequest", objectSchema(map[string]any{ + "provider_id": map[string]any{"type": "string"}, + "subject": map[string]any{"type": "string"}, + "id_token": map[string]any{"type": "string", "description": "OIDC ID token verified locally against configured public JWKS trust material."}, + "saml_assertion": map[string]any{"type": "string", "description": "SAML assertion verified locally against configured SAML signing certificates."}, + "expires_at": map[string]any{"type": "string", "format": "date-time", "description": "Optional session expiry, capped by the server."}, + }, "provider_id", "subject")) + registry.RegisterSchema("CreateSSOProviderRequest", objectSchema(map[string]any{ + "name": map[string]any{"type": "string"}, + "type": map[string]any{"type": "string", "enum": []string{"oidc", "saml"}}, + "issuer": map[string]any{"type": "string"}, + "client_id": map[string]any{"type": "string"}, + "groups_claim": map[string]any{"type": "string"}, + "role_mapping": map[string]any{"type": "object", "additionalProperties": map[string]any{"type": "string"}}, + "jwks": map[string]any{"type": "object", "description": "Optional static JWKS public-key material for local OIDC ID-token verification. Private keys and provider secrets must not be supplied."}, + "saml_signing_certificates": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "Optional PEM-encoded SAML assertion signing certificates. Private keys and provider secrets must not be supplied."}, + }, "name", "type", "issuer", "client_id")) + registry.RegisterSchema("UpdateSSOProviderTrustMaterialRequest", objectSchema(map[string]any{ + "jwks": map[string]any{"type": "object", "description": "OIDC public JWKS material. Private keys and provider secrets must not be supplied."}, + "saml_signing_certificates": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "PEM-encoded SAML assertion signing certificates. Private keys and provider secrets must not be supplied."}, + })) + registry.RegisterSchema("VerifyProviderIdentityRequest", objectSchema(map[string]any{ + "provider_type": map[string]any{"type": "string", "enum": []string{"oidc", "saml"}}, + "provider_id": map[string]any{"type": "string"}, + "subject": map[string]any{"type": "string"}, + "id_token": map[string]any{"type": "string", "description": "Optional OIDC ID token verified locally against the provider's configured static JWKS."}, + "saml_assertion": map[string]any{"type": "string", "description": "Optional SAML assertion verified locally against configured SAML signing certificates."}, + "access_token": map[string]any{"type": "string", "description": "Optional OIDC access token used only for live UserInfo validation. It is not persisted and must not be supplied for SAML providers."}, + }, "provider_type", "provider_id", "subject")) + registry.RegisterSchema("VerifyCheck", objectSchema(map[string]any{ + "name": map[string]any{"type": "string"}, + "result": map[string]any{"type": "string", "enum": []string{"passed", "failed", "warning", "skipped"}}, + "detail": map[string]any{"type": "string"}, + }, "name", "result")) + registry.RegisterSchema("AuditChainEntry", objectSchema(map[string]any{ + "id": map[string]any{"type": "string"}, + "tenant_id": map[string]any{"type": "string"}, + "sequence": map[string]any{"type": "integer", "format": "int64"}, + "entry_type": map[string]any{"type": "string"}, + "subject_type": map[string]any{"type": "string"}, + "subject_id": map[string]any{"type": "string"}, + "actor_type": map[string]any{"type": "string"}, + "actor_id": map[string]any{"type": "string"}, + "occurred_at": map[string]any{"type": "string", "format": "date-time"}, + "request_id": map[string]any{"type": "string"}, + "idempotency_key": map[string]any{"type": "string"}, + "payload_hash": map[string]any{"type": "string"}, + "canonical_entry_hash": map[string]any{"type": "string", "pattern": "^sha256:"}, + "previous_entry_hash": map[string]any{"type": "string"}, + "entry_hash": map[string]any{"type": "string", "pattern": "^sha256:"}, + "signature_ref": map[string]any{"type": "string"}, + "metadata": map[string]any{"type": "object", "additionalProperties": true}, + "schema_version": map[string]any{"type": "string"}, + }, "id", "tenant_id", "sequence", "entry_type", "subject_type", "subject_id", "actor_type", "actor_id", "occurred_at", "canonical_entry_hash", "previous_entry_hash", "entry_hash", "schema_version")) + registry.RegisterSchema("AuditChainEntryListEnvelope", dataArrayEnvelopeSchema("#/components/schemas/AuditChainEntry")) + registry.RegisterSchema("ReadinessStatus", objectSchema(map[string]any{ + "status": map[string]any{"type": "string"}, + "checks": map[string]any{"type": "array", "items": objectSchema(map[string]any{ + "name": map[string]any{"type": "string"}, + "status": map[string]any{"type": "string"}, + }, "name", "status")}, + }, "status", "checks")) + registry.RegisterSchema("ReadinessStatusEnvelope", dataEnvelopeSchema("#/components/schemas/ReadinessStatus")) + registry.RegisterSchema("BackupManifest", objectSchema(map[string]any{ + "id": map[string]any{"type": "string"}, + "tenant_id": map[string]any{"type": "string"}, + "state_hash": map[string]any{"type": "string", "pattern": "^sha256:"}, + "resource_counts": map[string]any{"type": "object", "additionalProperties": map[string]any{"type": "integer"}}, + "consistency_checks": map[string]any{"type": "array", "items": map[string]any{"$ref": "#/components/schemas/VerifyCheck"}}, + "limitations": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "schema_version": map[string]any{"type": "string"}, + "created_at": map[string]any{"type": "string", "format": "date-time"}, + }, "id", "tenant_id", "state_hash", "resource_counts", "consistency_checks", "limitations", "schema_version", "created_at")) + registry.RegisterSchema("BackupManifestEnvelope", dataEnvelopeSchema("#/components/schemas/BackupManifest")) + registry.RegisterSchema("VerificationResult", objectSchema(map[string]any{ + "id": map[string]any{"type": "string"}, + "tenant_id": map[string]any{"type": "string"}, + "subject_type": map[string]any{"type": "string"}, + "subject_id": map[string]any{"type": "string"}, + "result": map[string]any{"type": "string", "enum": []string{"passed", "failed"}}, + "checks": map[string]any{"type": "array", "items": map[string]any{"$ref": "#/components/schemas/VerifyCheck"}}, + "verified_at": map[string]any{"type": "string", "format": "date-time"}, + }, "id", "tenant_id", "subject_type", "subject_id", "result", "checks", "verified_at")) + registry.RegisterSchema("VerificationResultEnvelope", dataEnvelopeSchema("#/components/schemas/VerificationResult")) + registry.RegisterSchema("CreateMerkleBatchRequest", objectSchema(map[string]any{ + "from_sequence": map[string]any{"type": "integer", "format": "int64"}, + "to_sequence": map[string]any{"type": "integer", "format": "int64"}, + })) + registry.RegisterSchema("MerkleBatch", objectSchema(map[string]any{ + "id": map[string]any{"type": "string"}, + "tenant_id": map[string]any{"type": "string"}, + "from_sequence": map[string]any{"type": "integer", "format": "int64"}, + "to_sequence": map[string]any{"type": "integer", "format": "int64"}, + "entry_count": map[string]any{"type": "integer"}, + "leaf_hashes": map[string]any{"type": "array", "items": map[string]any{"type": "string", "pattern": "^sha256:"}}, + "root_hash": map[string]any{"type": "string", "pattern": "^sha256:"}, + "signature_refs": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "schema_version": map[string]any{"type": "string"}, + "created_at": map[string]any{"type": "string", "format": "date-time"}, + }, "id", "tenant_id", "from_sequence", "to_sequence", "entry_count", "leaf_hashes", "root_hash", "schema_version", "created_at")) + registry.RegisterSchema("MerkleBatchEnvelope", dataEnvelopeSchema("#/components/schemas/MerkleBatch")) + registry.RegisterSchema("CreateTransparencyCheckpointRequest", objectSchema(map[string]any{ + "batch_id": map[string]any{"type": "string"}, + "provider": map[string]any{"type": "string"}, + "external_url": map[string]any{"type": "string"}, + "external_id": map[string]any{"type": "string"}, + }, "batch_id", "provider")) + registry.RegisterSchema("TransparencyCheckpoint", objectSchema(map[string]any{ + "id": map[string]any{"type": "string"}, + "tenant_id": map[string]any{"type": "string"}, + "batch_id": map[string]any{"type": "string"}, + "provider": map[string]any{"type": "string"}, + "external_url": map[string]any{"type": "string"}, + "external_id": map[string]any{"type": "string"}, + "timestamp_hash": map[string]any{"type": "string", "pattern": "^sha256:"}, + "state": map[string]any{"type": "string"}, + "schema_version": map[string]any{"type": "string"}, + "created_at": map[string]any{"type": "string", "format": "date-time"}, + }, "id", "tenant_id", "batch_id", "provider", "timestamp_hash", "state", "schema_version", "created_at")) + registry.RegisterSchema("TransparencyCheckpointEnvelope", dataEnvelopeSchema("#/components/schemas/TransparencyCheckpoint")) + registry.RegisterSchema("CreateObjectRetentionPolicyRequest", objectSchema(map[string]any{ + "name": map[string]any{"type": "string"}, + "object_prefix": map[string]any{"type": "string"}, + "object_key": map[string]any{"type": "string", "description": "Optional tenant-prefixed sample object key used for object-level retention verification when supported by the object store."}, + "require_legal_hold": map[string]any{"type": "boolean", "description": "When true, the sample object key must have provider-reported legal hold enabled."}, + "mode": map[string]any{"type": "string", "enum": []string{"governance", "compliance"}}, + "retention_days": map[string]any{"type": "integer", "minimum": 1}, + }, "name", "mode", "retention_days")) + registry.RegisterSchema("ObjectRetentionPolicy", objectSchema(map[string]any{ + "id": map[string]any{"type": "string"}, + "tenant_id": map[string]any{"type": "string"}, + "name": map[string]any{"type": "string"}, + "object_prefix": map[string]any{"type": "string"}, + "object_key": map[string]any{"type": "string"}, + "require_legal_hold": map[string]any{"type": "boolean"}, + "mode": map[string]any{"type": "string"}, + "retention_days": map[string]any{"type": "integer"}, + "status": map[string]any{"type": "string"}, + "verified_at": map[string]any{"type": "string", "format": "date-time"}, + "verification_hash": map[string]any{"type": "string", "pattern": "^sha256:"}, + "verification_checks": map[string]any{ + "type": "array", + "items": map[string]any{"$ref": "#/components/schemas/VerifyCheck"}, + }, + "verification_limitations": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "schema_version": map[string]any{"type": "string"}, + "created_at": map[string]any{"type": "string", "format": "date-time"}, + }, "id", "tenant_id", "name", "object_prefix", "mode", "retention_days", "status", "schema_version", "created_at")) + registry.RegisterSchema("ObjectRetentionPolicyEnvelope", dataEnvelopeSchema("#/components/schemas/ObjectRetentionPolicy")) + registry.RegisterSchema("CreateLegalHoldRequest", objectSchema(map[string]any{ + "scope_type": map[string]any{"type": "string"}, + "scope_id": map[string]any{"type": "string"}, + "reason": map[string]any{"type": "string"}, + "owner": map[string]any{"type": "string"}, + }, "scope_type", "scope_id", "reason", "owner")) + registry.RegisterSchema("LegalHold", objectSchema(map[string]any{ + "id": map[string]any{"type": "string"}, + "tenant_id": map[string]any{"type": "string"}, + "scope_type": map[string]any{"type": "string"}, + "scope_id": map[string]any{"type": "string"}, + "reason": map[string]any{"type": "string"}, + "owner": map[string]any{"type": "string"}, + "released_at": map[string]any{"type": "string", "format": "date-time"}, + "schema_version": map[string]any{"type": "string"}, + "created_at": map[string]any{"type": "string", "format": "date-time"}, + }, "id", "tenant_id", "scope_type", "scope_id", "reason", "owner", "schema_version", "created_at")) + registry.RegisterSchema("LegalHoldEnvelope", dataEnvelopeSchema("#/components/schemas/LegalHold")) + registry.RegisterSchema("CreateRetentionOverrideRequest", objectSchema(map[string]any{ + "scope_type": map[string]any{"type": "string"}, + "scope_id": map[string]any{"type": "string"}, + "retention_until": map[string]any{"type": "string", "format": "date-time"}, + "reason": map[string]any{"type": "string"}, + "owner": map[string]any{"type": "string"}, + }, "scope_type", "scope_id", "retention_until", "reason", "owner")) + registry.RegisterSchema("RetentionOverride", objectSchema(map[string]any{ + "id": map[string]any{"type": "string"}, + "tenant_id": map[string]any{"type": "string"}, + "scope_type": map[string]any{"type": "string"}, + "scope_id": map[string]any{"type": "string"}, + "retention_until": map[string]any{"type": "string", "format": "date-time"}, + "reason": map[string]any{"type": "string"}, + "owner": map[string]any{"type": "string"}, + "schema_version": map[string]any{"type": "string"}, + "created_at": map[string]any{"type": "string", "format": "date-time"}, + }, "id", "tenant_id", "scope_type", "scope_id", "retention_until", "reason", "owner", "schema_version", "created_at")) + registry.RegisterSchema("RetentionOverrideEnvelope", dataEnvelopeSchema("#/components/schemas/RetentionOverride")) + registry.RegisterSchema("RetentionReport", objectSchema(map[string]any{ + "report_type": map[string]any{"type": "string"}, + "scope_type": map[string]any{"type": "string"}, + "scope_id": map[string]any{"type": "string"}, + "legal_holds": map[string]any{"type": "array", "items": map[string]any{"$ref": "#/components/schemas/LegalHold"}}, + "retention_overrides": map[string]any{"type": "array", "items": map[string]any{"$ref": "#/components/schemas/RetentionOverride"}}, + "limitations": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "generated_at": map[string]any{"type": "string", "format": "date-time"}, + }, "report_type", "legal_holds", "retention_overrides", "limitations", "generated_at")) + registry.RegisterSchema("RetentionReportEnvelope", dataEnvelopeSchema("#/components/schemas/RetentionReport")) + registry.RegisterSchema("PolicyCheck", objectSchema(map[string]any{ + "name": map[string]any{"type": "string"}, + "result": map[string]any{"type": "string", "enum": []string{"passed", "failed", "warning", "skipped"}}, + "severity": map[string]any{"type": "string"}, + "missing": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "explanation": map[string]any{"type": "string"}, + "remediation": map[string]any{"type": "string"}, + }, "name", "result", "severity", "explanation")) + registry.RegisterSchema("PolicyEvaluation", objectSchema(map[string]any{ + "id": map[string]any{"type": "string"}, + "tenant_id": map[string]any{"type": "string"}, + "release_id": map[string]any{"type": "string"}, + "result": map[string]any{"type": "string", "enum": []string{"passed", "failed"}}, + "policy_set": map[string]any{"type": "string"}, + "checks": map[string]any{"type": "array", "items": map[string]any{"$ref": "#/components/schemas/PolicyCheck"}}, + "created_at": map[string]any{"type": "string", "format": "date-time"}, + }, "id", "tenant_id", "release_id", "result", "policy_set", "checks", "created_at")) + registry.RegisterSchema("PolicyEvaluationEnvelope", dataEnvelopeSchema("#/components/schemas/PolicyEvaluation")) + registry.RegisterSchema("EvaluatePolicyRequest", objectSchema(map[string]any{ + "release_id": map[string]any{"type": "string"}, + }, "release_id")) + registry.RegisterSchema("CreateVulnerabilityDecisionRequest", objectSchema(map[string]any{ + "status": map[string]any{"type": "string", "enum": []string{"affected", "not_affected", "fixed", "under_investigation"}}, + "justification": map[string]any{"type": "string"}, + "impact_statement": map[string]any{"type": "string"}, + "action_statement": map[string]any{"type": "string"}, + "customer_visible": map[string]any{"type": "boolean"}, + "internal_notes": map[string]any{"type": "string", "description": "Tenant-internal notes; excluded from customer-safe package summaries."}, + "evidence_ids": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "supporting_refs": map[string]any{"type": "array", "items": map[string]any{"$ref": "#/components/schemas/SubjectRef"}, "description": "Optional tenant-scoped first-class decision support records from the same release. Supported types are approval, exception, waiver, remediation_task, release_bundle, and incident."}, + "vex_document_id": map[string]any{"type": "string", "description": "Optional tenant-scoped VEX document from the same release to link to this manual decision."}, + "reviewed_at": map[string]any{"type": "string", "format": "date-time", "description": "Optional UTC time when the decision was reviewed. Defaults to creation time."}, + "review_due_at": map[string]any{"type": "string", "format": "date-time", "description": "Optional UTC time when this decision should be reviewed again."}, + }, "status", "justification")) + registry.RegisterSchema("VulnerabilityDecision", objectSchema(map[string]any{ + "id": map[string]any{"type": "string"}, + "tenant_id": map[string]any{"type": "string"}, + "finding_id": map[string]any{"type": "string"}, + "scan_id": map[string]any{"type": "string"}, + "release_id": map[string]any{"type": "string"}, + "vulnerability": map[string]any{"type": "string"}, + "component": map[string]any{"type": "string"}, + "sbom_id": map[string]any{"type": "string", "description": "Same-release SBOM that contained the matched component, when available."}, + "sbom_component_purl": map[string]any{"type": "string"}, + "sbom_component_name": map[string]any{"type": "string"}, + "status": map[string]any{"type": "string"}, + "justification": map[string]any{"type": "string"}, + "impact_statement": map[string]any{"type": "string"}, + "action_statement": map[string]any{"type": "string"}, + "customer_visible": map[string]any{"type": "boolean"}, + "internal_notes": map[string]any{"type": "string", "description": "Tenant-internal notes; do not include in customer-safe exports."}, + "source": map[string]any{"type": "string"}, + "evidence_id": map[string]any{"type": "string"}, + "evidence_ids": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "supporting_refs": map[string]any{"type": "array", "items": map[string]any{"$ref": "#/components/schemas/SubjectRef"}}, + "vex_document_id": map[string]any{"type": "string"}, + "supersedes": map[string]any{"type": "string"}, + "superseded_by": map[string]any{"type": "string"}, + "approved_by": map[string]any{"type": "string"}, + "reviewed_at": map[string]any{"type": "string", "format": "date-time"}, + "review_due_at": map[string]any{"type": "string", "format": "date-time"}, + "schema_version": map[string]any{"type": "string"}, + "created_at": map[string]any{"type": "string", "format": "date-time"}, + }, "id", "tenant_id", "finding_id", "scan_id", "vulnerability", "status", "justification", "source", "schema_version", "created_at")) + registry.RegisterSchema("VulnerabilityDecisionEnvelope", dataEnvelopeSchema("#/components/schemas/VulnerabilityDecision")) + registry.RegisterSchema("VulnerabilityDecisionListEnvelope", dataArrayEnvelopeSchema("#/components/schemas/VulnerabilityDecision")) + registry.RegisterSchema("VulnerabilityDecisionCustomerSummary", objectSchema(map[string]any{ + "id": map[string]any{"type": "string"}, + "finding_id": map[string]any{"type": "string"}, + "scan_id": map[string]any{"type": "string"}, + "release_id": map[string]any{"type": "string"}, + "vulnerability": map[string]any{"type": "string"}, + "component": map[string]any{"type": "string"}, + "sbom_id": map[string]any{"type": "string", "description": "Same-release SBOM that contained the matched component, when available."}, + "sbom_component_purl": map[string]any{"type": "string"}, + "sbom_component_name": map[string]any{"type": "string"}, + "status": map[string]any{"type": "string"}, + "justification": map[string]any{"type": "string"}, + "impact_statement": map[string]any{"type": "string"}, + "action_statement": map[string]any{"type": "string"}, + "source": map[string]any{"type": "string"}, + "evidence_id": map[string]any{"type": "string"}, + "evidence_ids": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "supporting_refs": map[string]any{"type": "array", "items": map[string]any{"$ref": "#/components/schemas/SubjectRef"}}, + "vex_document_id": map[string]any{"type": "string"}, + "reviewed_at": map[string]any{"type": "string", "format": "date-time"}, + "review_due_at": map[string]any{"type": "string", "format": "date-time"}, + "created_at": map[string]any{"type": "string", "format": "date-time"}, + }, "id", "finding_id", "scan_id", "release_id", "vulnerability", "status", "impact_statement", "source", "created_at")) + registry.RegisterSchema("VulnerabilityDecisionSummaryReport", objectSchema(map[string]any{ + "report_type": map[string]any{"type": "string"}, + "template_version": map[string]any{"type": "string"}, + "product_id": map[string]any{"type": "string"}, + "release_id": map[string]any{"type": "string"}, + "decisions": map[string]any{"type": "array", "items": map[string]any{"$ref": "#/components/schemas/VulnerabilityDecisionCustomerSummary"}}, + "assumptions": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "limitations": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "generated_at": map[string]any{"type": "string", "format": "date-time"}, + }, "report_type", "template_version", "product_id", "release_id", "decisions", "assumptions", "limitations", "generated_at")) + registry.RegisterSchema("VulnerabilityDecisionSummaryReportEnvelope", dataEnvelopeSchema("#/components/schemas/VulnerabilityDecisionSummaryReport")) + registry.RegisterSchema("RecordVulnerabilityWorkflowRequest", objectSchema(map[string]any{ + "action": map[string]any{"type": "string"}, + "reason": map[string]any{"type": "string"}, + }, "action", "reason")) + registry.RegisterSchema("VulnerabilityWorkflowRecord", objectSchema(map[string]any{ + "id": map[string]any{"type": "string"}, + "tenant_id": map[string]any{"type": "string"}, + "finding_id": map[string]any{"type": "string"}, + "release_id": map[string]any{"type": "string"}, + "action": map[string]any{"type": "string"}, + "reason": map[string]any{"type": "string"}, + "actor_id": map[string]any{"type": "string"}, + "schema_version": map[string]any{"type": "string"}, + "created_at": map[string]any{"type": "string", "format": "date-time"}, + }, "id", "tenant_id", "finding_id", "action", "reason", "actor_id", "schema_version", "created_at")) + registry.RegisterSchema("VulnerabilityWorkflowRecordEnvelope", dataEnvelopeSchema("#/components/schemas/VulnerabilityWorkflowRecord")) + registry.RegisterSchema("CreateExceptionRequest", objectSchema(map[string]any{ + "release_id": map[string]any{"type": "string"}, + "finding_id": map[string]any{"type": "string"}, + "control_id": map[string]any{"type": "string"}, + "reason": map[string]any{"type": "string"}, + "owner": map[string]any{"type": "string"}, + "expires_at": map[string]any{"type": "string", "format": "date-time"}, + }, "release_id", "reason", "owner", "expires_at")) + registry.RegisterSchema("Exception", objectSchema(map[string]any{ + "id": map[string]any{"type": "string"}, + "tenant_id": map[string]any{"type": "string"}, + "release_id": map[string]any{"type": "string"}, + "finding_id": map[string]any{"type": "string"}, + "control_id": map[string]any{"type": "string"}, + "reason": map[string]any{"type": "string"}, + "owner": map[string]any{"type": "string"}, + "expires_at": map[string]any{"type": "string", "format": "date-time"}, + "approved": map[string]any{"type": "boolean"}, + "approved_by": map[string]any{"type": "string"}, + "approved_at": map[string]any{"type": "string", "format": "date-time"}, + "created_at": map[string]any{"type": "string", "format": "date-time"}, + }, "id", "tenant_id", "release_id", "reason", "owner", "expires_at", "approved", "created_at")) + registry.RegisterSchema("ExceptionEnvelope", dataEnvelopeSchema("#/components/schemas/Exception")) + registry.RegisterSchema("ExceptionListEnvelope", dataArrayEnvelopeSchema("#/components/schemas/Exception")) + registry.RegisterSchema("PolicyRule", objectSchema(map[string]any{ + "name": map[string]any{"type": "string"}, + "evidence_type": map[string]any{"type": "string"}, + "severity": map[string]any{"type": "string"}, + "required": map[string]any{"type": "boolean"}, + }, "name", "severity", "required")) + registry.RegisterSchema("CreateCustomPolicyRequest", objectSchema(map[string]any{ + "name": map[string]any{"type": "string"}, + "version": map[string]any{"type": "string"}, + "description": map[string]any{"type": "string"}, + "rules": map[string]any{"type": "array", "items": map[string]any{"$ref": "#/components/schemas/PolicyRule"}}, + }, "name", "version", "rules")) + registry.RegisterSchema("CustomPolicy", objectSchema(map[string]any{ + "id": map[string]any{"type": "string"}, + "tenant_id": map[string]any{"type": "string"}, + "name": map[string]any{"type": "string"}, + "version": map[string]any{"type": "string"}, + "description": map[string]any{"type": "string"}, + "rules": map[string]any{"type": "array", "items": map[string]any{"$ref": "#/components/schemas/PolicyRule"}}, + "schema_version": map[string]any{"type": "string"}, + "created_at": map[string]any{"type": "string", "format": "date-time"}, + }, "id", "tenant_id", "name", "version", "rules", "schema_version", "created_at")) + registry.RegisterSchema("CustomPolicyEnvelope", dataEnvelopeSchema("#/components/schemas/CustomPolicy")) + registry.RegisterSchema("CustomPolicyEvaluation", objectSchema(map[string]any{ + "id": map[string]any{"type": "string"}, + "tenant_id": map[string]any{"type": "string"}, + "policy_id": map[string]any{"type": "string"}, + "release_id": map[string]any{"type": "string"}, + "result": map[string]any{"type": "string", "enum": []string{"passed", "failed"}}, + "checks": map[string]any{"type": "array", "items": map[string]any{"$ref": "#/components/schemas/PolicyCheck"}}, + "input_hash": map[string]any{"type": "string", "pattern": "^sha256:"}, + "schema_version": map[string]any{"type": "string"}, + "created_at": map[string]any{"type": "string", "format": "date-time"}, + }, "id", "tenant_id", "policy_id", "release_id", "result", "checks", "input_hash", "schema_version", "created_at")) + registry.RegisterSchema("CustomPolicyEvaluationEnvelope", dataEnvelopeSchema("#/components/schemas/CustomPolicyEvaluation")) + registry.RegisterSchema("CreateWaiverRequest", objectSchema(map[string]any{ + "scope_type": map[string]any{"type": "string"}, + "scope_id": map[string]any{"type": "string"}, + "control_id": map[string]any{"type": "string"}, + "policy_id": map[string]any{"type": "string"}, + "owner": map[string]any{"type": "string"}, + "risk": map[string]any{"type": "string"}, + "reason": map[string]any{"type": "string"}, + "expires_at": map[string]any{"type": "string", "format": "date-time"}, + "supersedes": map[string]any{"type": "string"}, + }, "scope_type", "scope_id", "owner", "risk", "reason", "expires_at")) + registry.RegisterSchema("Waiver", objectSchema(map[string]any{ + "id": map[string]any{"type": "string"}, + "tenant_id": map[string]any{"type": "string"}, + "scope_type": map[string]any{"type": "string"}, + "scope_id": map[string]any{"type": "string"}, + "control_id": map[string]any{"type": "string"}, + "policy_id": map[string]any{"type": "string"}, + "owner": map[string]any{"type": "string"}, + "risk": map[string]any{"type": "string"}, + "reason": map[string]any{"type": "string"}, + "expires_at": map[string]any{"type": "string", "format": "date-time"}, + "approved": map[string]any{"type": "boolean"}, + "approved_by": map[string]any{"type": "string"}, + "approved_at": map[string]any{"type": "string", "format": "date-time"}, + "supersedes": map[string]any{"type": "string"}, + "superseded_by": map[string]any{"type": "string"}, + "schema_version": map[string]any{"type": "string"}, + "created_at": map[string]any{"type": "string", "format": "date-time"}, + }, "id", "tenant_id", "scope_type", "scope_id", "owner", "risk", "reason", "expires_at", "approved", "schema_version", "created_at")) + registry.RegisterSchema("WaiverEnvelope", dataEnvelopeSchema("#/components/schemas/Waiver")) + registry.RegisterSchema("CreateApprovalRequest", objectSchema(map[string]any{ + "subject_type": map[string]any{"type": "string"}, + "subject_id": map[string]any{"type": "string"}, + "decision": map[string]any{"type": "string", "enum": []string{"approved", "rejected", "accepted"}}, + "reason": map[string]any{"type": "string"}, + "evidence_id": map[string]any{"type": "string"}, + }, "subject_type", "subject_id", "decision", "reason")) + registry.RegisterSchema("ApprovalRecord", objectSchema(map[string]any{ + "id": map[string]any{"type": "string"}, + "tenant_id": map[string]any{"type": "string"}, + "subject_type": map[string]any{"type": "string"}, + "subject_id": map[string]any{"type": "string"}, + "decision": map[string]any{"type": "string"}, + "reason": map[string]any{"type": "string"}, + "approver_id": map[string]any{"type": "string"}, + "evidence_id": map[string]any{"type": "string"}, + "schema_version": map[string]any{"type": "string"}, + "created_at": map[string]any{"type": "string", "format": "date-time"}, + }, "id", "tenant_id", "subject_type", "subject_id", "decision", "reason", "approver_id", "schema_version", "created_at")) + registry.RegisterSchema("ApprovalRecordEnvelope", dataEnvelopeSchema("#/components/schemas/ApprovalRecord")) + registry.RegisterSchema("UploadOpenAPIContractRequest", objectSchema(map[string]any{ + "product_id": map[string]any{"type": "string"}, + "release_id": map[string]any{"type": "string"}, + "version": map[string]any{"type": "string"}, + "spec": map[string]any{"type": "object", "additionalProperties": true}, + }, "product_id", "release_id", "version", "spec")) + registry.RegisterSchema("OpenAPIOperationRecord", objectSchema(map[string]any{ + "path": map[string]any{"type": "string"}, + "method": map[string]any{"type": "string"}, + "operation_id": map[string]any{"type": "string"}, + "deprecated": map[string]any{"type": "boolean"}, + "request_body_required": map[string]any{"type": "boolean"}, + "required_request_fields": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "response_statuses": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + }, "path", "method")) + registry.RegisterSchema("OpenAPIContract", objectSchema(map[string]any{ + "id": map[string]any{"type": "string"}, + "tenant_id": map[string]any{"type": "string"}, + "product_id": map[string]any{"type": "string"}, + "release_id": map[string]any{"type": "string"}, + "version": map[string]any{"type": "string"}, + "hash": map[string]any{"type": "string", "pattern": "^sha256:"}, + "path_count": map[string]any{"type": "integer"}, + "operations": map[string]any{"type": "array", "items": map[string]any{"$ref": "#/components/schemas/OpenAPIOperationRecord"}}, + "evidence_id": map[string]any{"type": "string"}, + "created_at": map[string]any{"type": "string", "format": "date-time"}, + }, "id", "tenant_id", "product_id", "version", "hash", "path_count", "evidence_id", "created_at")) + registry.RegisterSchema("OpenAPIContractEnvelope", dataEnvelopeSchema("#/components/schemas/OpenAPIContract")) + registry.RegisterSchema("CreateOpenAPIDiffRequest", objectSchema(map[string]any{ + "base_contract_id": map[string]any{"type": "string"}, + "target_contract_id": map[string]any{"type": "string"}, + "release_id": map[string]any{"type": "string"}, + }, "base_contract_id", "target_contract_id", "release_id")) + registry.RegisterSchema("ContractDiff", objectSchema(map[string]any{ + "id": map[string]any{"type": "string"}, + "tenant_id": map[string]any{"type": "string"}, + "base_contract_id": map[string]any{"type": "string"}, + "target_contract_id": map[string]any{"type": "string"}, + "product_id": map[string]any{"type": "string"}, + "release_id": map[string]any{"type": "string"}, + "result": map[string]any{"type": "string"}, + "breaking_changes": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "non_breaking_changes": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "schema_version": map[string]any{"type": "string"}, + "created_at": map[string]any{"type": "string", "format": "date-time"}, + }, "id", "tenant_id", "base_contract_id", "target_contract_id", "product_id", "result", "schema_version", "created_at")) + registry.RegisterSchema("ContractDiffEnvelope", dataEnvelopeSchema("#/components/schemas/ContractDiff")) + registry.RegisterSchema("SigningKey", objectSchema(map[string]any{ + "id": map[string]any{"type": "string"}, + "tenant_id": map[string]any{"type": "string"}, + "kid": map[string]any{"type": "string"}, + "algorithm": map[string]any{"type": "string"}, + "status": map[string]any{"type": "string"}, + "public_key": map[string]any{"type": "string"}, + "created_at": map[string]any{"type": "string", "format": "date-time"}, + "revoked_at": map[string]any{"type": "string", "format": "date-time"}, + }, "id", "tenant_id", "kid", "algorithm", "status", "public_key", "created_at")) + registry.RegisterSchema("SigningKeyEnvelope", dataEnvelopeSchema("#/components/schemas/SigningKey")) + registry.RegisterSchema("SigningKeyListEnvelope", dataArrayEnvelopeSchema("#/components/schemas/SigningKey")) + registry.RegisterSchema("SigningKeyTransitionRequest", objectSchema(map[string]any{ + "reason": map[string]any{"type": "string"}, + })) + registry.RegisterSchema("CreateSigningProviderRequest", objectSchema(map[string]any{ + "name": map[string]any{"type": "string"}, + "type": map[string]any{"type": "string"}, + "key_ref": map[string]any{"type": "string"}, + "encrypted": map[string]any{"type": "boolean"}, + }, "name", "type", "key_ref")) + registry.RegisterSchema("SigningProvider", objectSchema(map[string]any{ + "id": map[string]any{"type": "string"}, + "tenant_id": map[string]any{"type": "string"}, + "name": map[string]any{"type": "string"}, + "type": map[string]any{"type": "string"}, + "status": map[string]any{"type": "string"}, + "key_ref": map[string]any{"type": "string"}, + "encrypted": map[string]any{"type": "boolean"}, + "schema_version": map[string]any{"type": "string"}, + "created_at": map[string]any{"type": "string", "format": "date-time"}, + }, "id", "tenant_id", "name", "type", "status", "key_ref", "encrypted", "schema_version", "created_at")) + registry.RegisterSchema("SigningProviderEnvelope", dataEnvelopeSchema("#/components/schemas/SigningProvider")) + registry.RegisterSchema("SigningCustodyReviewReport", objectSchema(map[string]any{ + "report_type": map[string]any{"type": "string"}, + "tenant_id": map[string]any{"type": "string"}, + "signing_providers": map[string]any{ + "type": "array", + "items": map[string]any{"$ref": "#/components/schemas/SigningProvider"}, + }, + "object_retention_policies": map[string]any{ + "type": "array", + "items": map[string]any{"$ref": "#/components/schemas/ObjectRetentionPolicy"}, + }, + "checks": map[string]any{ + "type": "array", + "items": map[string]any{"$ref": "#/components/schemas/VerifyCheck"}, + }, + "assumptions": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "limitations": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "generated_at": map[string]any{"type": "string", "format": "date-time"}, + }, "report_type", "tenant_id", "checks", "assumptions", "limitations", "generated_at")) + registry.RegisterSchema("SigningCustodyReviewReportEnvelope", dataEnvelopeSchema("#/components/schemas/SigningCustodyReviewReport")) + registry.RegisterSchema("CreateSigningOperationRequest", objectSchema(map[string]any{ + "provider_id": map[string]any{"type": "string"}, + "subject_type": map[string]any{"type": "string"}, + "subject_id": map[string]any{"type": "string"}, + "payload_hash": map[string]any{"type": "string", "pattern": "^sha256:"}, + "external_signature": map[string]any{"type": "string", "description": "Optional when a server-side signing executor is configured. The executor signs payload_hash and Evydence records only the returned signature receipt."}, + }, "provider_id", "subject_type", "subject_id", "payload_hash")) + registry.RegisterSchema("SigningOperation", objectSchema(map[string]any{ + "id": map[string]any{"type": "string"}, + "tenant_id": map[string]any{"type": "string"}, + "provider_id": map[string]any{"type": "string"}, + "subject_type": map[string]any{"type": "string"}, + "subject_id": map[string]any{"type": "string"}, + "payload_hash": map[string]any{"type": "string", "pattern": "^sha256:"}, + "signature_ref": map[string]any{"type": "string"}, + "result": map[string]any{"type": "string"}, + "checks": map[string]any{"type": "array", "items": map[string]any{"$ref": "#/components/schemas/VerifyCheck"}}, + "schema_version": map[string]any{"type": "string"}, + "created_at": map[string]any{"type": "string", "format": "date-time"}, + }, "id", "tenant_id", "provider_id", "subject_type", "subject_id", "payload_hash", "result", "checks", "schema_version", "created_at")) + registry.RegisterSchema("SigningOperationEnvelope", dataEnvelopeSchema("#/components/schemas/SigningOperation")) + registry.RegisterSchema("CreateArtifactSignatureRequest", objectSchema(map[string]any{ + "artifact_id": map[string]any{"type": "string"}, + "algorithm": map[string]any{"type": "string"}, + "key_id": map[string]any{"type": "string"}, + "signature": map[string]any{"type": "string"}, + "payload": map[string]any{"type": "object", "additionalProperties": true}, + "payload_media_type": map[string]any{"type": "string"}, + }, "artifact_id", "algorithm", "signature")) + registry.RegisterSchema("ArtifactSignature", objectSchema(map[string]any{ + "id": map[string]any{"type": "string"}, + "tenant_id": map[string]any{"type": "string"}, + "artifact_id": map[string]any{"type": "string"}, + "subject_digest": map[string]any{"type": "string"}, + "algorithm": map[string]any{"type": "string"}, + "key_id": map[string]any{"type": "string"}, + "signature": map[string]any{"type": "string"}, + "payload_ref": map[string]any{"type": "string"}, + "payload_hash": map[string]any{"type": "string", "pattern": "^sha256:"}, + "verification_status": map[string]any{"type": "string"}, + "schema_version": map[string]any{"type": "string"}, + "created_at": map[string]any{"type": "string", "format": "date-time"}, + }, "id", "tenant_id", "artifact_id", "subject_digest", "algorithm", "signature", "verification_status", "schema_version", "created_at")) + registry.RegisterSchema("ArtifactSignatureEnvelope", dataEnvelopeSchema("#/components/schemas/ArtifactSignature")) + registry.RegisterSchema("VerifyCosignSignatureRequest", objectSchema(map[string]any{ + "rekor_uuid": map[string]any{"type": "string"}, + "rekor_log_index": map[string]any{"type": "string"}, + "certificate_identity": map[string]any{"type": "string"}, + "certificate_issuer": map[string]any{"type": "string"}, + })) + registry.RegisterSchema("CosignVerification", objectSchema(map[string]any{ + "id": map[string]any{"type": "string"}, + "tenant_id": map[string]any{"type": "string"}, + "artifact_id": map[string]any{"type": "string"}, + "container_image_id": map[string]any{"type": "string"}, + "artifact_signature_id": map[string]any{"type": "string"}, + "subject_digest": map[string]any{"type": "string"}, + "rekor_uuid": map[string]any{"type": "string"}, + "rekor_log_index": map[string]any{"type": "string"}, + "certificate_identity": map[string]any{"type": "string"}, + "certificate_issuer": map[string]any{"type": "string"}, + "result": map[string]any{"type": "string", "enum": []string{"passed", "failed"}}, + "checks": map[string]any{"type": "array", "items": map[string]any{"$ref": "#/components/schemas/VerifyCheck"}}, + "schema_version": map[string]any{"type": "string"}, + "created_at": map[string]any{"type": "string", "format": "date-time"}, + }, "id", "tenant_id", "artifact_signature_id", "subject_digest", "result", "checks", "schema_version", "created_at")) + registry.RegisterSchema("CosignVerificationEnvelope", dataEnvelopeSchema("#/components/schemas/CosignVerification")) + registry.RegisterSchema("DSSEEnvelope", objectSchema(map[string]any{ + "payloadType": map[string]any{"type": "string"}, + "payload": map[string]any{"type": "string"}, + "signatures": map[string]any{"type": "array", "items": objectSchema(map[string]any{ + "keyid": map[string]any{"type": "string"}, + "sig": map[string]any{"type": "string"}, + }, "sig")}, + }, "payloadType", "payload", "signatures")) + registry.RegisterSchema("BuildAttestation", objectSchema(map[string]any{ + "id": map[string]any{"type": "string"}, + "tenant_id": map[string]any{"type": "string"}, + "build_id": map[string]any{"type": "string"}, + "evidence_id": map[string]any{"type": "string"}, + "payload_ref": map[string]any{"type": "string"}, + "payload_hash": map[string]any{"type": "string", "pattern": "^sha256:"}, + "payload_size": map[string]any{"type": "integer"}, + "payload_type": map[string]any{"type": "string"}, + "predicate_type": map[string]any{"type": "string"}, + "subject_digests": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "builder_id": map[string]any{"type": "string"}, + "build_type": map[string]any{"type": "string"}, + "materials_count": map[string]any{"type": "integer"}, + "signature_count": map[string]any{"type": "integer"}, + "verification_status": map[string]any{"type": "string"}, + "schema_version": map[string]any{"type": "string"}, + "created_at": map[string]any{"type": "string", "format": "date-time"}, + }, "id", "tenant_id", "build_id", "evidence_id", "payload_hash", "payload_size", "payload_type", "predicate_type", "subject_digests", "signature_count", "verification_status", "schema_version", "created_at")) + registry.RegisterSchema("BuildAttestationEnvelope", dataEnvelopeSchema("#/components/schemas/BuildAttestation")) + registry.RegisterSchema("CreateDSSETrustRootRequest", objectSchema(map[string]any{ + "name": map[string]any{"type": "string"}, + "key_id": map[string]any{"type": "string"}, + "algorithm": map[string]any{"type": "string", "enum": []string{"Ed25519"}}, + "public_key": map[string]any{"type": "string", "description": "Base64-encoded Ed25519 public key."}, + }, "name", "key_id", "algorithm", "public_key")) + registry.RegisterSchema("DSSETrustRoot", objectSchema(map[string]any{ + "id": map[string]any{"type": "string"}, + "tenant_id": map[string]any{"type": "string"}, + "name": map[string]any{"type": "string"}, + "key_id": map[string]any{"type": "string"}, + "algorithm": map[string]any{"type": "string"}, + "public_key": map[string]any{"type": "string"}, + "status": map[string]any{"type": "string"}, + "schema_version": map[string]any{"type": "string"}, + "created_at": map[string]any{"type": "string", "format": "date-time"}, + }, "id", "tenant_id", "name", "key_id", "algorithm", "public_key", "status", "schema_version", "created_at")) + registry.RegisterSchema("DSSETrustRootEnvelope", dataEnvelopeSchema("#/components/schemas/DSSETrustRoot")) + registry.RegisterSchema("CreateReleaseCandidateRequest", objectSchema(map[string]any{ + "release_id": map[string]any{"type": "string"}, + "name": map[string]any{"type": "string"}, + "build_ids": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "artifact_ids": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "sbom_ids": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "scan_ids": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "vex_ids": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "contract_ids": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "bundle_ids": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + }, "release_id", "name")) + registry.RegisterSchema("ReleaseCandidateTransitionRequest", objectSchema(map[string]any{ + "reason": map[string]any{"type": "string"}, + })) + registry.RegisterSchema("ReleaseCandidate", objectSchema(map[string]any{ + "id": map[string]any{"type": "string"}, + "tenant_id": map[string]any{"type": "string"}, + "release_id": map[string]any{"type": "string"}, + "name": map[string]any{"type": "string"}, + "state": map[string]any{"type": "string"}, + "build_ids": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "artifact_ids": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "sbom_ids": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "scan_ids": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "vex_ids": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "contract_ids": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "bundle_ids": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "snapshot_hash": map[string]any{"type": "string", "pattern": "^sha256:"}, + "schema_version": map[string]any{"type": "string"}, + "created_at": map[string]any{"type": "string", "format": "date-time"}, + "promoted_at": map[string]any{"type": "string", "format": "date-time"}, + "rejected_at": map[string]any{"type": "string", "format": "date-time"}, + }, "id", "tenant_id", "release_id", "name", "state", "snapshot_hash", "schema_version", "created_at")) + registry.RegisterSchema("ReleaseCandidateEnvelope", dataEnvelopeSchema("#/components/schemas/ReleaseCandidate")) + registry.RegisterSchema("ReleaseCandidateListEnvelope", dataArrayEnvelopeSchema("#/components/schemas/ReleaseCandidate")) + registry.RegisterSchema("SupersedeEvidenceRequest", objectSchema(map[string]any{ + "replacement_evidence_id": map[string]any{"type": "string"}, + "reason": map[string]any{"type": "string"}, + }, "replacement_evidence_id", "reason")) + registry.RegisterSchema("LinkEvidenceRequest", objectSchema(map[string]any{ + "target_type": map[string]any{"type": "string"}, + "target_id": map[string]any{"type": "string"}, + }, "target_type", "target_id")) + registry.RegisterSchema("RecordEvidenceLifecycleEventRequest", objectSchema(map[string]any{ + "action": map[string]any{"type": "string"}, + "reason": map[string]any{"type": "string"}, + "details": map[string]any{"type": "object", "additionalProperties": true}, + "replacement_id": map[string]any{"type": "string"}, + }, "action", "reason")) + registry.RegisterSchema("EvidenceLifecycleEvent", objectSchema(map[string]any{ + "id": map[string]any{"type": "string"}, + "tenant_id": map[string]any{"type": "string"}, + "evidence_id": map[string]any{"type": "string"}, + "action": map[string]any{"type": "string"}, + "reason": map[string]any{"type": "string"}, + "details": map[string]any{"type": "object", "additionalProperties": true}, + "replacement_id": map[string]any{"type": "string"}, + "actor_id": map[string]any{"type": "string"}, + "schema_version": map[string]any{"type": "string"}, + "created_at": map[string]any{"type": "string", "format": "date-time"}, + }, "id", "tenant_id", "evidence_id", "action", "reason", "actor_id", "schema_version", "created_at")) + registry.RegisterSchema("EvidenceLifecycleEventEnvelope", dataEnvelopeSchema("#/components/schemas/EvidenceLifecycleEvent")) + registry.RegisterSchema("EvidenceLifecycleEventListEnvelope", dataArrayEnvelopeSchema("#/components/schemas/EvidenceLifecycleEvent")) + registry.RegisterSchema("CreateSourceRepositoryRequest", objectSchema(map[string]any{ + "project_id": map[string]any{"type": "string"}, + "provider": map[string]any{"type": "string"}, + "full_name": map[string]any{"type": "string"}, + "clone_url": map[string]any{"type": "string"}, + "default_branch": map[string]any{"type": "string"}, + }, "provider", "full_name")) + registry.RegisterSchema("SourceRepository", objectSchema(map[string]any{ + "id": map[string]any{"type": "string"}, + "tenant_id": map[string]any{"type": "string"}, + "project_id": map[string]any{"type": "string"}, + "provider": map[string]any{"type": "string"}, + "full_name": map[string]any{"type": "string"}, + "clone_url": map[string]any{"type": "string"}, + "default_branch": map[string]any{"type": "string"}, + "schema_version": map[string]any{"type": "string"}, + "created_at": map[string]any{"type": "string", "format": "date-time"}, + }, "id", "tenant_id", "provider", "full_name", "schema_version", "created_at")) + registry.RegisterSchema("SourceRepositoryEnvelope", dataEnvelopeSchema("#/components/schemas/SourceRepository")) + registry.RegisterSchema("SourceRepositoryListEnvelope", dataArrayEnvelopeSchema("#/components/schemas/SourceRepository")) + registry.RegisterSchema("RecordSourceCommitRequest", objectSchema(map[string]any{ + "repository_id": map[string]any{"type": "string"}, + "sha": map[string]any{"type": "string"}, + "author": map[string]any{"type": "string"}, + "message": map[string]any{"type": "string", "description": "Commit message is hashed before storage."}, + "committed_at": map[string]any{"type": "string", "format": "date-time"}, + }, "repository_id", "sha", "committed_at")) + registry.RegisterSchema("SourceCommit", objectSchema(map[string]any{ + "id": map[string]any{"type": "string"}, + "tenant_id": map[string]any{"type": "string"}, + "repository_id": map[string]any{"type": "string"}, + "sha": map[string]any{"type": "string"}, + "author": map[string]any{"type": "string"}, + "message_hash": map[string]any{"type": "string", "pattern": "^sha256:"}, + "committed_at": map[string]any{"type": "string", "format": "date-time"}, + "schema_version": map[string]any{"type": "string"}, + "created_at": map[string]any{"type": "string", "format": "date-time"}, + }, "id", "tenant_id", "repository_id", "sha", "committed_at", "schema_version", "created_at")) + registry.RegisterSchema("SourceCommitEnvelope", dataEnvelopeSchema("#/components/schemas/SourceCommit")) + registry.RegisterSchema("UpsertSourceBranchRequest", objectSchema(map[string]any{ + "repository_id": map[string]any{"type": "string"}, + "name": map[string]any{"type": "string"}, + "head_commit_id": map[string]any{"type": "string"}, + "protected": map[string]any{"type": "boolean"}, + "protection_hash": map[string]any{"type": "string"}, + }, "repository_id", "name")) + registry.RegisterSchema("SourceBranch", objectSchema(map[string]any{ + "id": map[string]any{"type": "string"}, + "tenant_id": map[string]any{"type": "string"}, + "repository_id": map[string]any{"type": "string"}, + "name": map[string]any{"type": "string"}, + "head_commit_id": map[string]any{"type": "string"}, + "protected": map[string]any{"type": "boolean"}, + "protection_hash": map[string]any{"type": "string"}, + "schema_version": map[string]any{"type": "string"}, + "created_at": map[string]any{"type": "string", "format": "date-time"}, + }, "id", "tenant_id", "repository_id", "name", "protected", "schema_version", "created_at")) + registry.RegisterSchema("SourceBranchEnvelope", dataEnvelopeSchema("#/components/schemas/SourceBranch")) + registry.RegisterSchema("RecordPullRequestRequest", objectSchema(map[string]any{ + "repository_id": map[string]any{"type": "string"}, + "provider": map[string]any{"type": "string"}, + "provider_id": map[string]any{"type": "string"}, + "title": map[string]any{"type": "string"}, + "state": map[string]any{"type": "string"}, + "source_branch": map[string]any{"type": "string"}, + "target_branch": map[string]any{"type": "string"}, + "head_commit_id": map[string]any{"type": "string"}, + "review_decision": map[string]any{"type": "string"}, + }, "repository_id", "provider", "provider_id", "title", "state")) + registry.RegisterSchema("PullRequest", objectSchema(map[string]any{ + "id": map[string]any{"type": "string"}, + "tenant_id": map[string]any{"type": "string"}, + "repository_id": map[string]any{"type": "string"}, + "provider": map[string]any{"type": "string"}, + "provider_id": map[string]any{"type": "string"}, + "title": map[string]any{"type": "string"}, + "state": map[string]any{"type": "string"}, + "source_branch": map[string]any{"type": "string"}, + "target_branch": map[string]any{"type": "string"}, + "head_commit_id": map[string]any{"type": "string"}, + "review_decision": map[string]any{"type": "string"}, + "schema_version": map[string]any{"type": "string"}, + "created_at": map[string]any{"type": "string", "format": "date-time"}, + }, "id", "tenant_id", "repository_id", "provider", "provider_id", "title", "state", "schema_version", "created_at")) + registry.RegisterSchema("PullRequestEnvelope", dataEnvelopeSchema("#/components/schemas/PullRequest")) + registry.RegisterSchema("CreateDeploymentEnvironmentRequest", objectSchema(map[string]any{ + "product_id": map[string]any{"type": "string"}, + "name": map[string]any{"type": "string"}, + "kind": map[string]any{"type": "string"}, + }, "product_id", "name", "kind")) + registry.RegisterSchema("DeploymentEnvironment", objectSchema(map[string]any{ + "id": map[string]any{"type": "string"}, + "tenant_id": map[string]any{"type": "string"}, + "product_id": map[string]any{"type": "string"}, + "name": map[string]any{"type": "string"}, + "kind": map[string]any{"type": "string"}, + "schema_version": map[string]any{"type": "string"}, + "created_at": map[string]any{"type": "string", "format": "date-time"}, + }, "id", "tenant_id", "product_id", "name", "kind", "schema_version", "created_at")) + registry.RegisterSchema("DeploymentEnvironmentEnvelope", dataEnvelopeSchema("#/components/schemas/DeploymentEnvironment")) + registry.RegisterSchema("DeploymentEnvironmentListEnvelope", dataArrayEnvelopeSchema("#/components/schemas/DeploymentEnvironment")) + registry.RegisterSchema("RecordDeploymentRequest", objectSchema(map[string]any{ + "environment_id": map[string]any{"type": "string"}, + "release_id": map[string]any{"type": "string"}, + "artifact_ids": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "status": map[string]any{"type": "string"}, + "started_at": map[string]any{"type": "string", "format": "date-time"}, + "finished_at": map[string]any{"type": "string", "format": "date-time"}, + "rollback_of": map[string]any{"type": "string"}, + }, "environment_id", "release_id", "status", "started_at")) + registry.RegisterSchema("DeploymentEvent", objectSchema(map[string]any{ + "id": map[string]any{"type": "string"}, + "tenant_id": map[string]any{"type": "string"}, + "environment_id": map[string]any{"type": "string"}, + "release_id": map[string]any{"type": "string"}, + "artifact_ids": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "status": map[string]any{"type": "string"}, + "started_at": map[string]any{"type": "string", "format": "date-time"}, + "finished_at": map[string]any{"type": "string", "format": "date-time"}, + "rollback_of": map[string]any{"type": "string"}, + "evidence_id": map[string]any{"type": "string"}, + "schema_version": map[string]any{"type": "string"}, + "created_at": map[string]any{"type": "string", "format": "date-time"}, + }, "id", "tenant_id", "environment_id", "release_id", "status", "started_at", "schema_version", "created_at")) + registry.RegisterSchema("DeploymentEventEnvelope", dataEnvelopeSchema("#/components/schemas/DeploymentEvent")) + registry.RegisterSchema("DeploymentEventListEnvelope", dataArrayEnvelopeSchema("#/components/schemas/DeploymentEvent")) + registry.RegisterSchema("RecordCollectorReleaseRequest", objectSchema(map[string]any{ + "version": map[string]any{"type": "string"}, + "artifact_digest": map[string]any{"type": "string"}, + "signature_id": map[string]any{"type": "string"}, + "sbom_id": map[string]any{"type": "string"}, + "scan_id": map[string]any{"type": "string"}, + "pinned": map[string]any{"type": "boolean"}, + }, "version", "artifact_digest")) + registry.RegisterSchema("CollectorRelease", objectSchema(map[string]any{ + "id": map[string]any{"type": "string"}, + "tenant_id": map[string]any{"type": "string"}, + "collector_id": map[string]any{"type": "string"}, + "version": map[string]any{"type": "string"}, + "artifact_digest": map[string]any{"type": "string"}, + "signature_id": map[string]any{"type": "string"}, + "sbom_id": map[string]any{"type": "string"}, + "scan_id": map[string]any{"type": "string"}, + "pinned": map[string]any{"type": "boolean"}, + "verification_status": map[string]any{"type": "string"}, + "health_status": map[string]any{"type": "string"}, + "limitations": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "schema_version": map[string]any{"type": "string"}, + "created_at": map[string]any{"type": "string", "format": "date-time"}, + }, "id", "tenant_id", "collector_id", "version", "artifact_digest", "pinned", "verification_status", "health_status", "schema_version", "created_at")) + registry.RegisterSchema("CollectorReleaseEnvelope", dataEnvelopeSchema("#/components/schemas/CollectorRelease")) + registry.RegisterSchema("CollectorHealthReport", objectSchema(map[string]any{ + "report_type": map[string]any{"type": "string"}, + "collector_id": map[string]any{"type": "string"}, + "collector_status": map[string]any{"type": "string"}, + "version": map[string]any{"type": "string"}, + "pinned_release_id": map[string]any{"type": "string"}, + "supply_chain_status": map[string]any{"type": "string"}, + "checks": map[string]any{"type": "array", "items": map[string]any{"$ref": "#/components/schemas/VerifyCheck"}}, + "latest_release": map[string]any{"$ref": "#/components/schemas/CollectorRelease"}, + "assumptions": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "limitations": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "generated_at": map[string]any{"type": "string", "format": "date-time"}, + }, "report_type", "collector_id", "collector_status", "supply_chain_status", "checks", "assumptions", "limitations", "generated_at")) + registry.RegisterSchema("CollectorHealthReportEnvelope", dataEnvelopeSchema("#/components/schemas/CollectorHealthReport")) + registry.RegisterSchema("CreateCommercialCollectorRequest", objectSchema(map[string]any{ + "name": map[string]any{"type": "string"}, + "provider": map[string]any{"type": "string"}, + "version": map[string]any{"type": "string"}, + "manifest_hash": map[string]any{"type": "string", "pattern": "^sha256:"}, + "allowed_scopes": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + }, "name", "provider", "version", "manifest_hash")) + registry.RegisterSchema("CommercialCollectorDefinition", objectSchema(map[string]any{ + "id": map[string]any{"type": "string"}, + "tenant_id": map[string]any{"type": "string"}, + "name": map[string]any{"type": "string"}, + "provider": map[string]any{"type": "string"}, + "version": map[string]any{"type": "string"}, + "manifest_hash": map[string]any{"type": "string", "pattern": "^sha256:"}, + "allowed_scopes": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "status": map[string]any{"type": "string"}, + "schema_version": map[string]any{"type": "string"}, + "created_at": map[string]any{"type": "string", "format": "date-time"}, + }, "id", "tenant_id", "name", "provider", "version", "manifest_hash", "allowed_scopes", "status", "schema_version", "created_at")) + registry.RegisterSchema("CommercialCollectorDefinitionEnvelope", dataEnvelopeSchema("#/components/schemas/CommercialCollectorDefinition")) + registry.RegisterSchema("CommercialCollectorDefinitionListEnvelope", dataArrayEnvelopeSchema("#/components/schemas/CommercialCollectorDefinition")) + registry.RegisterSchema("CreateMarketplaceCollectorRequest", objectSchema(map[string]any{ + "name": map[string]any{"type": "string"}, + "provider": map[string]any{"type": "string"}, + "version": map[string]any{"type": "string"}, + "publisher": map[string]any{"type": "string"}, + "manifest_hash": map[string]any{"type": "string", "pattern": "^sha256:"}, + "signature_id": map[string]any{"type": "string"}, + "sbom_id": map[string]any{"type": "string"}, + "scan_id": map[string]any{"type": "string"}, + }, "name", "provider", "version", "publisher", "manifest_hash")) + registry.RegisterSchema("MarketplaceCollector", objectSchema(map[string]any{ + "id": map[string]any{"type": "string"}, + "tenant_id": map[string]any{"type": "string"}, + "name": map[string]any{"type": "string"}, + "provider": map[string]any{"type": "string"}, + "version": map[string]any{"type": "string"}, + "publisher": map[string]any{"type": "string"}, + "manifest_hash": map[string]any{"type": "string", "pattern": "^sha256:"}, + "signature_id": map[string]any{"type": "string"}, + "sbom_id": map[string]any{"type": "string"}, + "scan_id": map[string]any{"type": "string"}, + "state": map[string]any{"type": "string"}, + "limitations": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "schema_version": map[string]any{"type": "string"}, + "created_at": map[string]any{"type": "string", "format": "date-time"}, + }, "id", "tenant_id", "name", "provider", "version", "publisher", "manifest_hash", "state", "schema_version", "created_at")) + registry.RegisterSchema("MarketplaceCollectorEnvelope", dataEnvelopeSchema("#/components/schemas/MarketplaceCollector")) + registry.RegisterSchema("MarketplaceCollectorListEnvelope", dataArrayEnvelopeSchema("#/components/schemas/MarketplaceCollector")) + registry.RegisterSchema("MarketplaceCollectorHealthReport", objectSchema(map[string]any{ + "report_type": map[string]any{"type": "string"}, + "collector_id": map[string]any{"type": "string"}, + "name": map[string]any{"type": "string"}, + "provider": map[string]any{"type": "string"}, + "version": map[string]any{"type": "string"}, + "supply_chain_status": map[string]any{"type": "string"}, + "checks": map[string]any{"type": "array", "items": map[string]any{"$ref": "#/components/schemas/VerifyCheck"}}, + "collector": map[string]any{"$ref": "#/components/schemas/MarketplaceCollector"}, + "assumptions": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "limitations": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "generated_at": map[string]any{"type": "string", "format": "date-time"}, + }, "report_type", "collector_id", "name", "provider", "version", "supply_chain_status", "checks", "collector", "assumptions", "limitations", "generated_at")) + registry.RegisterSchema("MarketplaceCollectorHealthReportEnvelope", dataEnvelopeSchema("#/components/schemas/MarketplaceCollectorHealthReport")) + registry.RegisterSchema("ControlFrameworkTemplatePack", objectSchema(map[string]any{ + "id": map[string]any{"type": "string"}, + "name": map[string]any{"type": "string"}, + "slug": map[string]any{"type": "string"}, + "version": map[string]any{"type": "string"}, + "description": map[string]any{"type": "string"}, + "controls": map[string]any{"type": "array", "items": map[string]any{"$ref": "#/components/schemas/SecurityControl"}}, + "schema_version": map[string]any{"type": "string"}, + }, "id", "name", "slug", "version", "controls", "schema_version")) + registry.RegisterSchema("ControlFrameworkTemplatePackListEnvelope", dataArrayEnvelopeSchema("#/components/schemas/ControlFrameworkTemplatePack")) + registry.RegisterSchema("RegisterContainerImageRequest", objectSchema(map[string]any{ + "artifact_id": map[string]any{"type": "string"}, + "repository": map[string]any{"type": "string"}, + "tag": map[string]any{"type": "string"}, + "digest": map[string]any{"type": "string"}, + "platform": map[string]any{"type": "string"}, + }, "repository", "digest")) + registry.RegisterSchema("ContainerImage", objectSchema(map[string]any{ + "id": map[string]any{"type": "string"}, + "tenant_id": map[string]any{"type": "string"}, + "artifact_id": map[string]any{"type": "string"}, + "repository": map[string]any{"type": "string"}, + "tag": map[string]any{"type": "string"}, + "digest": map[string]any{"type": "string"}, + "platform": map[string]any{"type": "string"}, + "schema_version": map[string]any{"type": "string"}, + "created_at": map[string]any{"type": "string", "format": "date-time"}, + }, "id", "tenant_id", "repository", "digest", "schema_version", "created_at")) + registry.RegisterSchema("ContainerImageEnvelope", dataEnvelopeSchema("#/components/schemas/ContainerImage")) + registry.RegisterSchema("CreateRedactionProfileRequest", objectSchema(map[string]any{ + "name": map[string]any{"type": "string"}, + "description": map[string]any{"type": "string"}, + "preset": map[string]any{"type": "string", "enum": []string{"customer_safe", "security_review"}, "description": "Optional standard profile preset. When set, allowed_types and excluded_fields are server-defined and must be omitted."}, + "allowed_types": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "excluded_fields": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + })) + registry.RegisterSchema("RedactionProfile", objectSchema(map[string]any{ + "id": map[string]any{"type": "string"}, + "tenant_id": map[string]any{"type": "string"}, + "name": map[string]any{"type": "string"}, + "description": map[string]any{"type": "string"}, + "allowed_types": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "excluded_fields": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "schema_version": map[string]any{"type": "string"}, + "created_at": map[string]any{"type": "string", "format": "date-time"}, + }, "id", "tenant_id", "name", "schema_version", "created_at")) + registry.RegisterSchema("RedactionProfileEnvelope", dataEnvelopeSchema("#/components/schemas/RedactionProfile")) + registry.RegisterSchema("CreateCustomerPackageRequest", objectSchema(map[string]any{ + "product_id": map[string]any{"type": "string"}, + "release_id": map[string]any{"type": "string"}, + "redaction_profile_id": map[string]any{"type": "string"}, + "title": map[string]any{"type": "string"}, + "expires_at": map[string]any{"type": "string", "format": "date-time"}, + }, "product_id", "redaction_profile_id", "title", "expires_at")) + registry.RegisterSchema("CustomerSecurityPackage", objectSchema(map[string]any{ + "id": map[string]any{"type": "string"}, + "tenant_id": map[string]any{"type": "string"}, + "product_id": map[string]any{"type": "string"}, + "release_id": map[string]any{"type": "string"}, + "redaction_profile_id": map[string]any{"type": "string"}, + "title": map[string]any{"type": "string"}, + "state": map[string]any{"type": "string"}, + "manifest": map[string]any{"type": "object", "additionalProperties": true}, + "manifest_hash": map[string]any{"type": "string", "pattern": "^sha256:"}, + "distribution_watermark": map[string]any{"type": "string"}, + "expires_at": map[string]any{"type": "string", "format": "date-time"}, + "access_count": map[string]any{"type": "integer"}, + "schema_version": map[string]any{"type": "string"}, + "created_at": map[string]any{"type": "string", "format": "date-time"}, + }, "id", "tenant_id", "product_id", "redaction_profile_id", "title", "state", "manifest", "manifest_hash", "expires_at", "access_count", "schema_version", "created_at")) + registry.RegisterSchema("CustomerSecurityPackageEnvelope", dataEnvelopeSchema("#/components/schemas/CustomerSecurityPackage")) + registry.RegisterSchema("SecurityReviewPackageReport", objectSchema(map[string]any{ + "report_type": map[string]any{"type": "string"}, + "template_version": map[string]any{"type": "string"}, + "package_id": map[string]any{"type": "string"}, + "product_id": map[string]any{"type": "string"}, + "release_id": map[string]any{"type": "string"}, + "evidence_ids": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "assumptions": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "limitations": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "generated_at": map[string]any{"type": "string", "format": "date-time"}, + }, "report_type", "template_version", "package_id", "product_id", "evidence_ids", "assumptions", "limitations", "generated_at")) + registry.RegisterSchema("SecurityReviewPackageReportEnvelope", dataEnvelopeSchema("#/components/schemas/SecurityReviewPackageReport")) + registry.RegisterSchema("HTMLReportPackage", objectSchema(map[string]any{ + "id": map[string]any{"type": "string"}, + "tenant_id": map[string]any{"type": "string"}, + "report_type": map[string]any{"type": "string"}, + "product_id": map[string]any{"type": "string"}, + "release_id": map[string]any{"type": "string"}, + "html": map[string]any{"type": "string"}, + "hash": map[string]any{"type": "string", "pattern": "^sha256:"}, + "schema_version": map[string]any{"type": "string"}, + "created_at": map[string]any{"type": "string", "format": "date-time"}, + }, "id", "tenant_id", "report_type", "product_id", "html", "hash", "schema_version", "created_at")) + registry.RegisterSchema("HTMLReportPackageEnvelope", dataEnvelopeSchema("#/components/schemas/HTMLReportPackage")) + registry.RegisterSchema("CreateReportTemplateRequest", objectSchema(map[string]any{ + "name": map[string]any{"type": "string"}, + "version": map[string]any{"type": "string"}, + "report_type": map[string]any{"type": "string"}, + "allowed_fields": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "template": map[string]any{"type": "string"}, + }, "name", "version", "report_type", "allowed_fields", "template")) + registry.RegisterSchema("CustomReportTemplate", objectSchema(map[string]any{ + "id": map[string]any{"type": "string"}, + "tenant_id": map[string]any{"type": "string"}, + "name": map[string]any{"type": "string"}, + "version": map[string]any{"type": "string"}, + "report_type": map[string]any{"type": "string"}, + "allowed_fields": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "template": map[string]any{"type": "string"}, + "schema_version": map[string]any{"type": "string"}, + "created_at": map[string]any{"type": "string", "format": "date-time"}, + }, "id", "tenant_id", "name", "version", "report_type", "allowed_fields", "template", "schema_version", "created_at")) + registry.RegisterSchema("CustomReportTemplateEnvelope", dataEnvelopeSchema("#/components/schemas/CustomReportTemplate")) + registry.RegisterSchema("RenderReportTemplateRequest", objectSchema(map[string]any{ + "subject_type": map[string]any{"type": "string"}, + "subject_id": map[string]any{"type": "string"}, + }, "subject_type", "subject_id")) + registry.RegisterSchema("RenderedCustomReport", objectSchema(map[string]any{ + "id": map[string]any{"type": "string"}, + "tenant_id": map[string]any{"type": "string"}, + "template_id": map[string]any{"type": "string"}, + "subject_type": map[string]any{"type": "string"}, + "subject_id": map[string]any{"type": "string"}, + "output": map[string]any{"type": "object", "additionalProperties": true}, + "hash": map[string]any{"type": "string", "pattern": "^sha256:"}, + "schema_version": map[string]any{"type": "string"}, + "created_at": map[string]any{"type": "string", "format": "date-time"}, + }, "id", "tenant_id", "template_id", "subject_type", "subject_id", "output", "hash", "schema_version", "created_at")) + registry.RegisterSchema("RenderedCustomReportEnvelope", dataEnvelopeSchema("#/components/schemas/RenderedCustomReport")) + registry.RegisterSchema("ExportEvidenceBundleRequest", objectSchema(map[string]any{ + "release_id": map[string]any{"type": "string"}, + "evidence_ids": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + })) + registry.RegisterSchema("EvidenceBundle", objectSchema(map[string]any{ + "id": map[string]any{"type": "string"}, + "tenant_id": map[string]any{"type": "string"}, + "release_id": map[string]any{"type": "string"}, + "evidence_ids": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "manifest": map[string]any{"type": "object", "additionalProperties": true}, + "manifest_hash": map[string]any{"type": "string", "pattern": "^sha256:"}, + "signature_refs": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "verification_text": map[string]any{"type": "string"}, + "schema_version": map[string]any{"type": "string"}, + "created_at": map[string]any{"type": "string", "format": "date-time"}, + }, "id", "tenant_id", "evidence_ids", "manifest", "manifest_hash", "verification_text", "schema_version", "created_at")) + registry.RegisterSchema("EvidenceBundleEnvelope", dataEnvelopeSchema("#/components/schemas/EvidenceBundle")) + registry.RegisterSchema("EvidenceBundleImport", objectSchema(map[string]any{ + "id": map[string]any{"type": "string"}, + "tenant_id": map[string]any{"type": "string"}, + "bundle_hash": map[string]any{"type": "string", "pattern": "^sha256:"}, + "result": map[string]any{"type": "string"}, + "imported_count": map[string]any{"type": "integer"}, + "schema_version": map[string]any{"type": "string"}, + "created_at": map[string]any{"type": "string", "format": "date-time"}, + }, "id", "tenant_id", "bundle_hash", "result", "imported_count", "schema_version", "created_at")) + registry.RegisterSchema("EvidenceBundleImportEnvelope", dataEnvelopeSchema("#/components/schemas/EvidenceBundleImport")) + registry.RegisterSchema("CreateEvidenceSummaryRequest", objectSchema(map[string]any{ + "subject_type": map[string]any{"type": "string"}, + "subject_id": map[string]any{"type": "string"}, + "evidence_ids": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + }, "subject_type", "subject_id", "evidence_ids")) + registry.RegisterSchema("EvidenceCitation", objectSchema(map[string]any{ + "evidence_id": map[string]any{"type": "string"}, + "type": map[string]any{"type": "string"}, + "title": map[string]any{"type": "string"}, + "canonical_hash": map[string]any{"type": "string", "pattern": "^sha256:"}, + }, "evidence_id", "type", "title", "canonical_hash")) + registry.RegisterSchema("EvidenceSummary", objectSchema(map[string]any{ + "id": map[string]any{"type": "string"}, + "tenant_id": map[string]any{"type": "string"}, + "subject_type": map[string]any{"type": "string"}, + "subject_id": map[string]any{"type": "string"}, + "evidence_ids": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "summary": map[string]any{"type": "string"}, + "citations": map[string]any{"type": "array", "items": map[string]any{"$ref": "#/components/schemas/EvidenceCitation"}}, + "assumptions": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "limitations": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "schema_version": map[string]any{"type": "string"}, + "created_at": map[string]any{"type": "string", "format": "date-time"}, + }, "id", "tenant_id", "subject_type", "subject_id", "evidence_ids", "summary", "citations", "assumptions", "limitations", "schema_version", "created_at")) + registry.RegisterSchema("EvidenceSummaryEnvelope", dataEnvelopeSchema("#/components/schemas/EvidenceSummary")) + registry.RegisterSchema("QuestionnaireQuestion", objectSchema(map[string]any{ + "id": map[string]any{"type": "string"}, + "prompt": map[string]any{"type": "string"}, + "evidence_type": map[string]any{"type": "string"}, + "control_id": map[string]any{"type": "string"}, + "allowed_fields": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + }, "id", "prompt")) + registry.RegisterSchema("QuestionnaireResponse", objectSchema(map[string]any{ + "question_id": map[string]any{"type": "string"}, + "answer": map[string]any{"type": "string"}, + "evidence_ids": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "limitations": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + }, "question_id", "answer")) + registry.RegisterSchema("CreateQuestionnaireTemplateRequest", objectSchema(map[string]any{ + "name": map[string]any{"type": "string"}, + "version": map[string]any{"type": "string"}, + "questions": map[string]any{"type": "array", "items": map[string]any{"$ref": "#/components/schemas/QuestionnaireQuestion"}}, + }, "name", "version", "questions")) + registry.RegisterSchema("QuestionnaireTemplate", objectSchema(map[string]any{ + "id": map[string]any{"type": "string"}, + "tenant_id": map[string]any{"type": "string"}, + "name": map[string]any{"type": "string"}, + "version": map[string]any{"type": "string"}, + "questions": map[string]any{"type": "array", "items": map[string]any{"$ref": "#/components/schemas/QuestionnaireQuestion"}}, + "schema_version": map[string]any{"type": "string"}, + "created_at": map[string]any{"type": "string", "format": "date-time"}, + }, "id", "tenant_id", "name", "version", "questions", "schema_version", "created_at")) + registry.RegisterSchema("QuestionnaireTemplateEnvelope", dataEnvelopeSchema("#/components/schemas/QuestionnaireTemplate")) + registry.RegisterSchema("CreateQuestionnairePackageRequest", objectSchema(map[string]any{ + "template_id": map[string]any{"type": "string"}, + "package_id": map[string]any{"type": "string"}, + "product_id": map[string]any{"type": "string"}, + "release_id": map[string]any{"type": "string"}, + }, "template_id")) + registry.RegisterSchema("QuestionnairePackage", objectSchema(map[string]any{ + "id": map[string]any{"type": "string"}, + "tenant_id": map[string]any{"type": "string"}, + "template_id": map[string]any{"type": "string"}, + "package_id": map[string]any{"type": "string"}, + "product_id": map[string]any{"type": "string"}, + "release_id": map[string]any{"type": "string"}, + "responses": map[string]any{"type": "array", "items": map[string]any{"$ref": "#/components/schemas/QuestionnaireResponse"}}, + "manifest_hash": map[string]any{"type": "string", "pattern": "^sha256:"}, + "schema_version": map[string]any{"type": "string"}, + "created_at": map[string]any{"type": "string", "format": "date-time"}, + }, "id", "tenant_id", "template_id", "responses", "manifest_hash", "schema_version", "created_at")) + registry.RegisterSchema("QuestionnairePackageEnvelope", dataEnvelopeSchema("#/components/schemas/QuestionnairePackage")) + registry.RegisterSchema("CreateQuestionnaireDraftRequest", objectSchema(map[string]any{ + "template_id": map[string]any{"type": "string"}, + "product_id": map[string]any{"type": "string"}, + "release_id": map[string]any{"type": "string"}, + }, "template_id")) + registry.RegisterSchema("QuestionnaireDraft", objectSchema(map[string]any{ + "id": map[string]any{"type": "string"}, + "tenant_id": map[string]any{"type": "string"}, + "template_id": map[string]any{"type": "string"}, + "product_id": map[string]any{"type": "string"}, + "release_id": map[string]any{"type": "string"}, + "responses": map[string]any{"type": "array", "items": map[string]any{"$ref": "#/components/schemas/QuestionnaireResponse"}}, + "manifest_hash": map[string]any{"type": "string", "pattern": "^sha256:"}, + "limitations": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "schema_version": map[string]any{"type": "string"}, + "created_at": map[string]any{"type": "string", "format": "date-time"}, + }, "id", "tenant_id", "template_id", "responses", "manifest_hash", "limitations", "schema_version", "created_at")) + registry.RegisterSchema("QuestionnaireDraftEnvelope", dataEnvelopeSchema("#/components/schemas/QuestionnaireDraft")) + registry.RegisterSchema("CreateQuestionnaireAnswerLibraryEntryRequest", objectSchema(map[string]any{ + "question_id": map[string]any{"type": "string"}, + "evidence_type": map[string]any{"type": "string"}, + "control_id": map[string]any{"type": "string"}, + "product_id": map[string]any{"type": "string"}, + "release_id": map[string]any{"type": "string"}, + "answer": map[string]any{"type": "string"}, + "evidence_ids": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "limitations": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + }, "answer")) + registry.RegisterSchema("QuestionnaireAnswerLibraryEntry", objectSchema(map[string]any{ + "id": map[string]any{"type": "string"}, + "tenant_id": map[string]any{"type": "string"}, + "question_id": map[string]any{"type": "string"}, + "evidence_type": map[string]any{"type": "string"}, + "control_id": map[string]any{"type": "string"}, + "product_id": map[string]any{"type": "string"}, + "release_id": map[string]any{"type": "string"}, + "answer": map[string]any{"type": "string"}, + "evidence_ids": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "limitations": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "schema_version": map[string]any{"type": "string"}, + "created_at": map[string]any{"type": "string", "format": "date-time"}, + }, "id", "tenant_id", "answer", "schema_version", "created_at")) + registry.RegisterSchema("QuestionnaireAnswerLibraryEntryEnvelope", dataEnvelopeSchema("#/components/schemas/QuestionnaireAnswerLibraryEntry")) + registry.RegisterSchema("QuestionnaireAnswerLibraryEntryListEnvelope", dataArrayEnvelopeSchema("#/components/schemas/QuestionnaireAnswerLibraryEntry")) + registry.RegisterSchema("CreatePDFReportPackageRequest", objectSchema(map[string]any{ + "report_type": map[string]any{"type": "string"}, + "product_id": map[string]any{"type": "string"}, + "release_id": map[string]any{"type": "string"}, + "title": map[string]any{"type": "string"}, + }, "report_type", "title")) + registry.RegisterSchema("PDFReportPackage", objectSchema(map[string]any{ + "id": map[string]any{"type": "string"}, + "tenant_id": map[string]any{"type": "string"}, + "report_type": map[string]any{"type": "string"}, + "product_id": map[string]any{"type": "string"}, + "release_id": map[string]any{"type": "string"}, + "title": map[string]any{"type": "string"}, + "payload_ref": map[string]any{"type": "string"}, + "payload_hash": map[string]any{"type": "string", "pattern": "^sha256:"}, + "payload_size": map[string]any{"type": "integer"}, + "limitations": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "schema_version": map[string]any{"type": "string"}, + "created_at": map[string]any{"type": "string", "format": "date-time"}, + }, "id", "tenant_id", "report_type", "title", "payload_hash", "payload_size", "limitations", "schema_version", "created_at")) + registry.RegisterSchema("PDFReportPackageEnvelope", dataEnvelopeSchema("#/components/schemas/PDFReportPackage")) + registry.RegisterSchema("ReadinessReport", objectSchema(map[string]any{ + "report_type": map[string]any{"type": "string"}, + "template_version": map[string]any{"type": "string"}, + "product_id": map[string]any{"type": "string"}, + "release_id": map[string]any{"type": "string"}, + "result": map[string]any{"type": "string"}, + "policy_set": map[string]any{"type": "string"}, + "summary": map[string]any{"type": "object", "additionalProperties": false, "properties": map[string]any{ + "headline": map[string]any{"type": "string"}, + "result": map[string]any{"type": "string"}, + "human_summary": map[string]any{"type": "string"}, + "policy_set": map[string]any{"type": "string"}, + }}, + "checks": map[string]any{"type": "array", "items": map[string]any{"type": "object"}}, + "sections": map[string]any{"type": "array", "items": map[string]any{"type": "object", "additionalProperties": false, "properties": map[string]any{ + "id": map[string]any{"type": "string"}, + "title": map[string]any{"type": "string"}, + "status": map[string]any{"type": "string"}, + "summary": map[string]any{"type": "string"}, + "questions": map[string]any{"type": "array", "items": map[string]any{"type": "object", "additionalProperties": false, "properties": map[string]any{ + "id": map[string]any{"type": "string"}, + "question": map[string]any{"type": "string"}, + "answer": map[string]any{"type": "string"}, + "status": map[string]any{"type": "string"}, + "evidence": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "checks": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "missing_evidence": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "failed_policies": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "known_limitations": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + }}}, + }}}, + "gaps": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "missing_evidence": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "failed_policies": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "known_limitations": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "non_claims": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "assumptions": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "limitations": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "generated_at": map[string]any{"type": "string", "format": "date-time"}, + }, "report_type", "template_version", "result", "assumptions", "limitations", "generated_at")) + registry.RegisterSchema("ReadinessReportEnvelope", dataEnvelopeSchema("#/components/schemas/ReadinessReport")) + registry.RegisterSchema("MissingEvidenceReport", objectSchema(map[string]any{ + "report_type": map[string]any{"type": "string"}, + "template_version": map[string]any{"type": "string"}, + "release_id": map[string]any{"type": "string"}, + "result": map[string]any{"type": "string", "enum": []string{"passed", "failed"}}, + "missing": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "assumptions": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "limitations": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + }, "report_type", "template_version", "release_id", "result", "missing", "assumptions", "limitations")) + registry.RegisterSchema("MissingEvidenceReportEnvelope", dataEnvelopeSchema("#/components/schemas/MissingEvidenceReport")) + registry.RegisterSchema("SSOProvider", objectSchema(map[string]any{ + "id": map[string]any{"type": "string"}, + "tenant_id": map[string]any{"type": "string"}, + "name": map[string]any{"type": "string"}, + "type": map[string]any{"type": "string", "enum": []string{"oidc", "saml"}}, + "issuer": map[string]any{"type": "string"}, + "client_id": map[string]any{"type": "string"}, + "groups_claim": map[string]any{"type": "string"}, + "role_mapping": map[string]any{"type": "object", "additionalProperties": map[string]any{"type": "string"}}, + "jwks": map[string]any{"type": "object", "description": "Configured public JWKS material, when supplied."}, + "saml_signing_certificates": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "Configured SAML assertion signing certificates, when supplied."}, + "trust_material_updated_at": map[string]any{"type": "string", "format": "date-time"}, + "status": map[string]any{"type": "string"}, + "schema_version": map[string]any{"type": "string"}, + "created_at": map[string]any{"type": "string", "format": "date-time"}, + }, "id", "tenant_id", "name", "type", "issuer", "client_id", "status", "schema_version", "created_at")) + registry.RegisterSchema("SSOProviderEnvelope", dataEnvelopeSchema("#/components/schemas/SSOProvider")) + registry.RegisterSchema("ProviderVerification", objectSchema(map[string]any{ + "id": map[string]any{"type": "string"}, + "tenant_id": map[string]any{"type": "string"}, + "provider_type": map[string]any{"type": "string", "enum": []string{"oidc", "saml"}}, + "provider_id": map[string]any{"type": "string"}, + "subject": map[string]any{"type": "string"}, + "result": map[string]any{"type": "string", "enum": []string{"passed", "failed"}}, + "checks": map[string]any{"type": "array", "items": map[string]any{"$ref": "#/components/schemas/VerifyCheck"}}, + "limitations": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "schema_version": map[string]any{"type": "string"}, + "created_at": map[string]any{"type": "string", "format": "date-time"}, + }, "id", "tenant_id", "provider_type", "provider_id", "subject", "result", "checks", "limitations", "schema_version", "created_at")) + registry.RegisterSchema("ProviderVerificationEnvelope", dataEnvelopeSchema("#/components/schemas/ProviderVerification")) + registry.RegisterSchema("CreateOrganizationRequest", objectSchema(map[string]any{ + "name": map[string]any{"type": "string"}, + "slug": map[string]any{"type": "string"}, + }, "name", "slug")) + registry.RegisterSchema("Organization", objectSchema(map[string]any{ + "id": map[string]any{"type": "string"}, + "tenant_id": map[string]any{"type": "string"}, + "name": map[string]any{"type": "string"}, + "slug": map[string]any{"type": "string"}, + "status": map[string]any{"type": "string"}, + "schema_version": map[string]any{"type": "string"}, + "created_at": map[string]any{"type": "string", "format": "date-time"}, + }, "id", "tenant_id", "name", "slug", "status", "schema_version", "created_at")) + registry.RegisterSchema("OrganizationEnvelope", dataEnvelopeSchema("#/components/schemas/Organization")) + registry.RegisterSchema("CreateUserRequest", objectSchema(map[string]any{ + "organization_id": map[string]any{"type": "string"}, + "email": map[string]any{"type": "string", "format": "email"}, + "display_name": map[string]any{"type": "string"}, + }, "email", "display_name")) + registry.RegisterSchema("HumanUser", objectSchema(map[string]any{ + "id": map[string]any{"type": "string"}, + "tenant_id": map[string]any{"type": "string"}, + "organization_id": map[string]any{"type": "string"}, + "email": map[string]any{"type": "string", "format": "email"}, + "display_name": map[string]any{"type": "string"}, + "status": map[string]any{"type": "string"}, + "deactivated_at": map[string]any{"type": "string", "format": "date-time"}, + "schema_version": map[string]any{"type": "string"}, + "created_at": map[string]any{"type": "string", "format": "date-time"}, + }, "id", "tenant_id", "email", "display_name", "status", "schema_version", "created_at")) + registry.RegisterSchema("HumanUserEnvelope", dataEnvelopeSchema("#/components/schemas/HumanUser")) + registry.RegisterSchema("CreateRoleBindingRequest", objectSchema(map[string]any{ + "subject_type": map[string]any{"type": "string", "enum": []string{"user", "collector"}}, + "subject_id": map[string]any{"type": "string"}, + "role": map[string]any{"type": "string"}, + "resource_type": map[string]any{"type": "string"}, + "resource_id": map[string]any{"type": "string"}, + }, "subject_type", "subject_id", "role")) + registry.RegisterSchema("RoleBinding", objectSchema(map[string]any{ + "id": map[string]any{"type": "string"}, + "tenant_id": map[string]any{"type": "string"}, + "subject_type": map[string]any{"type": "string"}, + "subject_id": map[string]any{"type": "string"}, + "role": map[string]any{"type": "string"}, + "resource_type": map[string]any{"type": "string"}, + "resource_id": map[string]any{"type": "string"}, + "schema_version": map[string]any{"type": "string"}, + "created_at": map[string]any{"type": "string", "format": "date-time"}, + }, "id", "tenant_id", "subject_type", "subject_id", "role", "schema_version", "created_at")) + registry.RegisterSchema("RoleBindingEnvelope", dataEnvelopeSchema("#/components/schemas/RoleBinding")) + registry.RegisterSchema("RoleBindingListEnvelope", dataArrayEnvelopeSchema("#/components/schemas/RoleBinding")) + registry.RegisterSchema("LinkSSOIdentityRequest", objectSchema(map[string]any{ + "user_id": map[string]any{"type": "string"}, + "provider_id": map[string]any{"type": "string"}, + "subject": map[string]any{"type": "string"}, + "email": map[string]any{"type": "string", "format": "email"}, + "verified": map[string]any{"type": "boolean"}, + }, "user_id", "provider_id", "subject", "email", "verified")) + registry.RegisterSchema("UserIdentityLink", objectSchema(map[string]any{ + "id": map[string]any{"type": "string"}, + "tenant_id": map[string]any{"type": "string"}, + "user_id": map[string]any{"type": "string"}, + "provider_id": map[string]any{"type": "string"}, + "subject": map[string]any{"type": "string"}, + "email": map[string]any{"type": "string", "format": "email"}, + "verified": map[string]any{"type": "boolean"}, + "schema_version": map[string]any{"type": "string"}, + "created_at": map[string]any{"type": "string", "format": "date-time"}, + }, "id", "tenant_id", "user_id", "provider_id", "subject", "email", "verified", "schema_version", "created_at")) + registry.RegisterSchema("UserIdentityLinkEnvelope", dataEnvelopeSchema("#/components/schemas/UserIdentityLink")) + registry.RegisterSchema("SSOSession", objectSchema(map[string]any{ + "id": map[string]any{"type": "string"}, + "tenant_id": map[string]any{"type": "string"}, + "user_id": map[string]any{"type": "string"}, + "provider_id": map[string]any{"type": "string"}, + "prefix": map[string]any{"type": "string", "description": "Non-secret session token prefix for audit displays."}, + "groups": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "Provider group claim values captured for session-scoped role mapping."}, + "expires_at": map[string]any{"type": "string", "format": "date-time"}, + "revoked_at": map[string]any{"type": "string", "format": "date-time"}, + "schema_version": map[string]any{"type": "string"}, + "created_at": map[string]any{"type": "string", "format": "date-time"}, + }, "id", "tenant_id", "user_id", "provider_id", "prefix", "expires_at", "schema_version", "created_at")) + registry.RegisterSchema("SSOSessionEnvelope", dataEnvelopeSchema("#/components/schemas/SSOSession")) + registry.RegisterSchema("SSOSessionCreateResponse", objectSchema(map[string]any{ + "session": map[string]any{"$ref": "#/components/schemas/SSOSession"}, + "secret": map[string]any{"type": "string", "description": "One-time SSO session bearer secret; not returned by list/read operations."}, + }, "session", "secret")) + registry.RegisterSchema("SSOSessionCreateEnvelope", dataEnvelopeSchema("#/components/schemas/SSOSessionCreateResponse")) + registry.RegisterSchema("SSOCredentialExchangeResponse", objectSchema(map[string]any{ + "verification": map[string]any{"$ref": "#/components/schemas/ProviderVerification"}, + "session": map[string]any{"$ref": "#/components/schemas/SSOSession"}, + "secret": map[string]any{"type": "string", "description": "One-time SSO session bearer secret; also set as an HttpOnly cookie for browser clients."}, + }, "verification", "session", "secret")) + registry.RegisterSchema("SSOCredentialExchangeEnvelope", dataEnvelopeSchema("#/components/schemas/SSOCredentialExchangeResponse")) + registry.RegisterSchema("CreateAPIKeyRequest", objectSchema(map[string]any{ + "name": map[string]any{"type": "string"}, + "scopes": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "expires_at": map[string]any{"type": "string", "format": "date-time"}, + }, "name", "scopes")) + registry.RegisterSchema("APIKey", objectSchema(map[string]any{ + "id": map[string]any{"type": "string"}, + "tenant_id": map[string]any{"type": "string"}, + "name": map[string]any{"type": "string"}, + "prefix": map[string]any{"type": "string", "description": "Non-secret key prefix for lookup and audit displays."}, + "scopes": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "created_at": map[string]any{"type": "string", "format": "date-time"}, + "expires_at": map[string]any{"type": "string", "format": "date-time"}, + "revoked_at": map[string]any{"type": "string", "format": "date-time"}, + "last_used_at": map[string]any{"type": "string", "format": "date-time"}, + }, "id", "tenant_id", "name", "prefix", "scopes", "created_at")) + registry.RegisterSchema("APIKeyCreateResponse", objectSchema(map[string]any{ + "api_key": map[string]any{"$ref": "#/components/schemas/APIKey"}, + "secret": map[string]any{"type": "string", "description": "One-time API key secret; stored only as a peppered HMAC hash."}, + }, "api_key", "secret")) + registry.RegisterSchema("APIKeyCreateEnvelope", dataEnvelopeSchema("#/components/schemas/APIKeyCreateResponse")) + registry.RegisterSchema("APIKeyListEnvelope", dataArrayEnvelopeSchema("#/components/schemas/APIKey")) + registry.RegisterSchema("CreateCollectorRequest", objectSchema(map[string]any{ + "name": map[string]any{"type": "string"}, + "type": map[string]any{"type": "string", "enum": []string{"github_actions", "gitlab_ci", "generic_ci", "import_bundle"}}, + "version": map[string]any{"type": "string"}, + "scopes": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + }, "name", "type", "version")) + registry.RegisterSchema("Collector", objectSchema(map[string]any{ + "id": map[string]any{"type": "string"}, + "tenant_id": map[string]any{"type": "string"}, + "name": map[string]any{"type": "string"}, + "type": map[string]any{"type": "string"}, + "version": map[string]any{"type": "string"}, + "api_key_id": map[string]any{"type": "string"}, + "status": map[string]any{"type": "string"}, + "allowed_scopes": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "last_seen_at": map[string]any{"type": "string", "format": "date-time"}, + "schema_version": map[string]any{"type": "string"}, + "created_at": map[string]any{"type": "string", "format": "date-time"}, + }, "id", "tenant_id", "name", "type", "version", "api_key_id", "status", "allowed_scopes", "schema_version", "created_at")) + registry.RegisterSchema("CollectorCreateResponse", objectSchema(map[string]any{ + "collector": map[string]any{"$ref": "#/components/schemas/Collector"}, + "api_key": map[string]any{"$ref": "#/components/schemas/APIKey"}, + "secret": map[string]any{"type": "string", "description": "One-time collector API key secret; stored only as a peppered HMAC hash."}, + }, "collector", "api_key", "secret")) + registry.RegisterSchema("CollectorCreateEnvelope", dataEnvelopeSchema("#/components/schemas/CollectorCreateResponse")) + registry.RegisterSchema("CollectorListEnvelope", dataArrayEnvelopeSchema("#/components/schemas/Collector")) + registry.RegisterSchema("CreateControlFrameworkRequest", objectSchema(map[string]any{ + "name": map[string]any{"type": "string"}, + "slug": map[string]any{"type": "string"}, + "version": map[string]any{"type": "string"}, + "description": map[string]any{"type": "string"}, + }, "name", "version")) + registry.RegisterSchema("ControlFramework", objectSchema(map[string]any{ + "id": map[string]any{"type": "string"}, + "tenant_id": map[string]any{"type": "string"}, + "name": map[string]any{"type": "string"}, + "slug": map[string]any{"type": "string"}, + "version": map[string]any{"type": "string"}, + "description": map[string]any{"type": "string"}, + "status": map[string]any{"type": "string"}, + "schema_version": map[string]any{"type": "string"}, + "created_at": map[string]any{"type": "string", "format": "date-time"}, + }, "id", "tenant_id", "name", "slug", "version", "status", "schema_version", "created_at")) + registry.RegisterSchema("ControlFrameworkEnvelope", dataEnvelopeSchema("#/components/schemas/ControlFramework")) + registry.RegisterSchema("ControlFrameworkListEnvelope", dataArrayEnvelopeSchema("#/components/schemas/ControlFramework")) + registry.RegisterSchema("ControlEvidenceRequirement", objectSchema(map[string]any{ + "type": map[string]any{"type": "string"}, + "freshness_days": map[string]any{"type": "integer", "minimum": 0}, + "required": map[string]any{"type": "boolean"}, + }, "type", "required")) + registry.RegisterSchema("CreateSecurityControlRequest", objectSchema(map[string]any{ + "framework_id": map[string]any{"type": "string"}, + "code": map[string]any{"type": "string"}, + "title": map[string]any{"type": "string"}, + "objective": map[string]any{"type": "string"}, + "evidence_requirements": map[string]any{"type": "array", "items": map[string]any{"$ref": "#/components/schemas/ControlEvidenceRequirement"}}, + "applicability": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "limitations": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + }, "framework_id", "code", "title", "objective")) + registry.RegisterSchema("SecurityControl", objectSchema(map[string]any{ + "id": map[string]any{"type": "string"}, + "tenant_id": map[string]any{"type": "string"}, + "framework_id": map[string]any{"type": "string"}, + "code": map[string]any{"type": "string"}, + "title": map[string]any{"type": "string"}, + "objective": map[string]any{"type": "string"}, + "evidence_requirements": map[string]any{"type": "array", "items": map[string]any{"$ref": "#/components/schemas/ControlEvidenceRequirement"}}, + "applicability": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "limitations": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "schema_version": map[string]any{"type": "string"}, + "created_at": map[string]any{"type": "string", "format": "date-time"}, + }, "id", "tenant_id", "framework_id", "code", "title", "objective", "schema_version", "created_at")) + registry.RegisterSchema("SecurityControlEnvelope", dataEnvelopeSchema("#/components/schemas/SecurityControl")) + registry.RegisterSchema("LinkControlEvidenceRequest", objectSchema(map[string]any{ + "evidence_type": map[string]any{"type": "string"}, + "subject_type": map[string]any{"type": "string"}, + "subject_id": map[string]any{"type": "string"}, + "product_id": map[string]any{"type": "string"}, + "release_id": map[string]any{"type": "string"}, + "confidence": map[string]any{"type": "string", "enum": []string{"high", "medium", "low", "unsupported"}}, + "notes": map[string]any{"type": "string"}, + }, "evidence_type", "subject_type", "subject_id", "confidence")) + registry.RegisterSchema("ControlEvidence", objectSchema(map[string]any{ + "id": map[string]any{"type": "string"}, + "tenant_id": map[string]any{"type": "string"}, + "control_id": map[string]any{"type": "string"}, + "evidence_type": map[string]any{"type": "string"}, + "subject_type": map[string]any{"type": "string"}, + "subject_id": map[string]any{"type": "string"}, + "product_id": map[string]any{"type": "string"}, + "release_id": map[string]any{"type": "string"}, + "confidence": map[string]any{"type": "string"}, + "notes": map[string]any{"type": "string"}, + "schema_version": map[string]any{"type": "string"}, + "created_at": map[string]any{"type": "string", "format": "date-time"}, + }, "id", "tenant_id", "control_id", "evidence_type", "subject_type", "subject_id", "confidence", "schema_version", "created_at")) + registry.RegisterSchema("ControlEvidenceEnvelope", dataEnvelopeSchema("#/components/schemas/ControlEvidence")) + registry.RegisterSchema("ControlEvidenceListEnvelope", dataArrayEnvelopeSchema("#/components/schemas/ControlEvidence")) + registry.RegisterSchema("CreateProductRequest", objectSchema(map[string]any{ + "name": map[string]any{"type": "string"}, + "slug": map[string]any{"type": "string"}, + }, "name", "slug")) + registry.RegisterSchema("Product", objectSchema(map[string]any{ + "id": map[string]any{"type": "string"}, + "tenant_id": map[string]any{"type": "string"}, + "name": map[string]any{"type": "string"}, + "slug": map[string]any{"type": "string"}, + "schema_version": map[string]any{"type": "string"}, + "created_at": map[string]any{"type": "string", "format": "date-time"}, + }, "id", "tenant_id", "name", "slug", "schema_version", "created_at")) + registry.RegisterSchema("ProductEnvelope", dataEnvelopeSchema("#/components/schemas/Product")) + registry.RegisterSchema("ProductListEnvelope", dataArrayEnvelopeSchema("#/components/schemas/Product")) + registry.RegisterSchema("CreateProjectRequest", objectSchema(map[string]any{ + "product_id": map[string]any{"type": "string"}, + "name": map[string]any{"type": "string"}, + "slug": map[string]any{"type": "string"}, + }, "product_id", "name", "slug")) + registry.RegisterSchema("Project", objectSchema(map[string]any{ + "id": map[string]any{"type": "string"}, + "tenant_id": map[string]any{"type": "string"}, + "product_id": map[string]any{"type": "string"}, + "name": map[string]any{"type": "string"}, + "slug": map[string]any{"type": "string"}, + "schema_version": map[string]any{"type": "string"}, + "created_at": map[string]any{"type": "string", "format": "date-time"}, + }, "id", "tenant_id", "product_id", "name", "slug", "schema_version", "created_at")) + registry.RegisterSchema("ProjectEnvelope", dataEnvelopeSchema("#/components/schemas/Project")) + registry.RegisterSchema("CreateReleaseRequest", objectSchema(map[string]any{ + "product_id": map[string]any{"type": "string"}, + "project_id": map[string]any{"type": "string"}, + "version": map[string]any{"type": "string"}, + }, "product_id", "version")) + registry.RegisterSchema("Release", objectSchema(map[string]any{ + "id": map[string]any{"type": "string"}, + "tenant_id": map[string]any{"type": "string"}, + "product_id": map[string]any{"type": "string"}, + "project_id": map[string]any{"type": "string"}, + "version": map[string]any{"type": "string"}, + "status": map[string]any{"type": "string", "enum": []string{"draft", "frozen", "approved"}}, + "schema_version": map[string]any{"type": "string"}, + "created_at": map[string]any{"type": "string", "format": "date-time"}, + "frozen_at": map[string]any{"type": "string", "format": "date-time"}, + "approved_at": map[string]any{"type": "string", "format": "date-time"}, + }, "id", "tenant_id", "product_id", "version", "status", "schema_version", "created_at")) + registry.RegisterSchema("ReleaseEnvelope", dataEnvelopeSchema("#/components/schemas/Release")) + registry.RegisterSchema("ReleaseEvidenceFlowStep", objectSchema(map[string]any{ + "id": map[string]any{"type": "string"}, + "title": map[string]any{"type": "string"}, + "status": map[string]any{"type": "string", "enum": []string{"present", "missing", "optional"}}, + "required": map[string]any{"type": "boolean"}, + "method": map[string]any{"type": "string"}, + "path": map[string]any{"type": "string"}, + "required_scopes": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "idempotency_required": map[string]any{"type": "boolean"}, + "description": map[string]any{"type": "string"}, + "next_reference": map[string]any{"type": "string"}, + }, "id", "title", "status", "required", "method", "path", "required_scopes", "idempotency_required", "description")) + registry.RegisterSchema("ReleaseEvidenceFlow", objectSchema(map[string]any{ + "release_id": map[string]any{"type": "string"}, + "product_id": map[string]any{"type": "string"}, + "status": map[string]any{"type": "string", "enum": []string{"needs_evidence", "ready_for_review"}}, + "counts": map[string]any{"type": "object", "additionalProperties": map[string]any{"type": "integer"}}, + "steps": map[string]any{"type": "array", "items": map[string]any{"$ref": "#/components/schemas/ReleaseEvidenceFlowStep"}}, + "assumptions": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "limitations": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "schema_version": map[string]any{"type": "string"}, + "generated_at": map[string]any{"type": "string", "format": "date-time"}, + }, "release_id", "product_id", "status", "counts", "steps", "assumptions", "limitations", "schema_version", "generated_at")) + registry.RegisterSchema("ReleaseEvidenceFlowEnvelope", dataEnvelopeSchema("#/components/schemas/ReleaseEvidenceFlow")) + registry.RegisterSchema("ReleaseSecurityProductSummary", objectSchema(map[string]any{ + "id": map[string]any{"type": "string"}, + "name": map[string]any{"type": "string"}, + "slug": map[string]any{"type": "string"}, + }, "id", "name", "slug")) + registry.RegisterSchema("ReleaseSecurityReleaseSummary", objectSchema(map[string]any{ + "id": map[string]any{"type": "string"}, + "version": map[string]any{"type": "string"}, + "state": map[string]any{"type": "string"}, + }, "id", "version", "state")) + registry.RegisterSchema("ReleaseSecurityMissingDecision", objectSchema(map[string]any{ + "finding_id": map[string]any{"type": "string"}, + "scan_id": map[string]any{"type": "string"}, + "vulnerability": map[string]any{"type": "string"}, + "component": map[string]any{"type": "string"}, + "severity": map[string]any{"type": "string"}, + "state": map[string]any{"type": "string"}, + }, "finding_id", "scan_id", "vulnerability", "severity", "state")) + registry.RegisterSchema("ReleaseSecurityApprovalSummary", objectSchema(map[string]any{ + "total": map[string]any{"type": "integer"}, + "approved": map[string]any{"type": "integer"}, + }, "total", "approved")) + registry.RegisterSchema("ReleaseSecurityExceptionSummary", objectSchema(map[string]any{ + "total": map[string]any{"type": "integer"}, + "approved_unexpired": map[string]any{"type": "integer"}, + "unapproved": map[string]any{"type": "integer"}, + "expired": map[string]any{"type": "integer"}, + }, "total", "approved_unexpired", "unapproved", "expired")) + registry.RegisterSchema("ReleaseSecuritySummary", objectSchema(map[string]any{ + "product": map[string]any{"$ref": "#/components/schemas/ReleaseSecurityProductSummary"}, + "release": map[string]any{"$ref": "#/components/schemas/ReleaseSecurityReleaseSummary"}, + "artifact_count": map[string]any{"type": "integer"}, + "sbom_status": map[string]any{"type": "string", "enum": []string{"present", "missing"}}, + "vulnerability_scan_status": map[string]any{"type": "string", "enum": []string{"present", "missing"}}, + "open_findings_by_severity": map[string]any{"type": "object", "additionalProperties": map[string]any{"type": "integer"}}, + "decisions_by_status": map[string]any{"type": "object", "additionalProperties": map[string]any{"type": "integer"}}, + "missing_required_decisions": map[string]any{"type": "array", "items": map[string]any{"$ref": "#/components/schemas/ReleaseSecurityMissingDecision"}}, + "approval_summary": map[string]any{"$ref": "#/components/schemas/ReleaseSecurityApprovalSummary"}, + "exception_summary": map[string]any{"$ref": "#/components/schemas/ReleaseSecurityExceptionSummary"}, + "readiness_status": map[string]any{"type": "string", "enum": []string{"passed", "failed"}}, + "package_status": map[string]any{"type": "string", "enum": []string{"generated", "not_generated"}}, + "counts": map[string]any{"type": "object", "additionalProperties": map[string]any{"type": "integer"}}, + "assumptions": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "limitations": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "schema_version": map[string]any{"type": "string"}, + "generated_at": map[string]any{"type": "string", "format": "date-time"}, + }, "product", "release", "artifact_count", "sbom_status", "vulnerability_scan_status", "open_findings_by_severity", "decisions_by_status", "approval_summary", "exception_summary", "readiness_status", "package_status", "counts", "assumptions", "limitations", "schema_version", "generated_at")) + registry.RegisterSchema("ReleaseSecuritySummaryEnvelope", dataEnvelopeSchema("#/components/schemas/ReleaseSecuritySummary")) + registry.RegisterSchema("RegisterArtifactRequest", objectSchema(map[string]any{ + "release_id": map[string]any{"type": "string"}, + "name": map[string]any{"type": "string"}, + "media_type": map[string]any{"type": "string"}, + "digest": map[string]any{"type": "string", "pattern": "^sha256:"}, + "size": map[string]any{"type": "integer", "minimum": 0}, + "subject_ref": map[string]any{"type": "string"}, + }, "name", "digest")) + registry.RegisterSchema("Artifact", objectSchema(map[string]any{ + "id": map[string]any{"type": "string"}, + "tenant_id": map[string]any{"type": "string"}, + "release_id": map[string]any{"type": "string"}, + "name": map[string]any{"type": "string"}, + "media_type": map[string]any{"type": "string"}, + "digest": map[string]any{"type": "string"}, + "size": map[string]any{"type": "integer"}, + "schema_version": map[string]any{"type": "string"}, + "created_at": map[string]any{"type": "string", "format": "date-time"}, + }, "id", "tenant_id", "name", "digest", "schema_version", "created_at")) + registry.RegisterSchema("ArtifactEnvelope", dataEnvelopeSchema("#/components/schemas/Artifact")) + registry.RegisterSchema("CreateBuildRequest", objectSchema(map[string]any{ + "project_id": map[string]any{"type": "string"}, + "release_id": map[string]any{"type": "string"}, + "provider": map[string]any{"type": "string"}, + "commit_sha": map[string]any{"type": "string"}, + "status": map[string]any{"type": "string", "enum": []string{"queued", "running", "passed", "failed", "cancelled"}}, + "started_at": map[string]any{"type": "string", "format": "date-time"}, + "completed_at": map[string]any{"type": "string", "format": "date-time"}, + "github": map[string]any{"type": "object"}, + "outputs": map[string]any{"type": "array", "items": map[string]any{"type": "object"}}, + }, "project_id", "release_id", "provider", "commit_sha", "status", "started_at")) + registry.RegisterSchema("BuildRun", objectSchema(map[string]any{ + "id": map[string]any{"type": "string"}, + "tenant_id": map[string]any{"type": "string"}, + "collector_id": map[string]any{"type": "string"}, + "project_id": map[string]any{"type": "string"}, + "release_id": map[string]any{"type": "string"}, + "provider": map[string]any{"type": "string"}, + "commit_sha": map[string]any{"type": "string"}, + "status": map[string]any{"type": "string"}, + "outputs": map[string]any{"type": "array", "items": map[string]any{"type": "object"}}, + "schema_version": map[string]any{"type": "string"}, + "created_at": map[string]any{"type": "string", "format": "date-time"}, + }, "id", "tenant_id", "project_id", "release_id", "provider", "commit_sha", "status", "schema_version", "created_at")) + registry.RegisterSchema("BuildRunEnvelope", dataEnvelopeSchema("#/components/schemas/BuildRun")) + registry.RegisterSchema("SourceSnapshotRepositoryInput", objectSchema(map[string]any{ + "full_name": map[string]any{"type": "string"}, + "clone_url": map[string]any{"type": "string"}, + "default_branch": map[string]any{"type": "string"}, + }, "full_name")) + registry.RegisterSchema("SourceSnapshotCommitInput", objectSchema(map[string]any{ + "sha": map[string]any{"type": "string"}, + "author": map[string]any{"type": "string"}, + "message": map[string]any{"type": "string", "description": "Commit message supplied by the collector; Evydence stores a message hash."}, + "committed_at": map[string]any{"type": "string", "format": "date-time"}, + }, "sha", "committed_at")) + registry.RegisterSchema("SourceSnapshotBranchInput", objectSchema(map[string]any{ + "name": map[string]any{"type": "string"}, + "protected": map[string]any{"type": "boolean"}, + "protection_hash": map[string]any{"type": "string"}, + }, "name")) + registry.RegisterSchema("SourceSnapshotPullRequestInput", objectSchema(map[string]any{ + "provider_id": map[string]any{"type": "string"}, + "title": map[string]any{"type": "string"}, + "state": map[string]any{"type": "string"}, + "source_branch": map[string]any{"type": "string"}, + "target_branch": map[string]any{"type": "string"}, + "review_decision": map[string]any{"type": "string"}, + }, "provider_id", "state")) + registry.RegisterSchema("SourceSnapshotRequest", objectSchema(map[string]any{ + "project_id": map[string]any{"type": "string"}, + "repository": map[string]any{"$ref": "#/components/schemas/SourceSnapshotRepositoryInput"}, + "commit": map[string]any{"$ref": "#/components/schemas/SourceSnapshotCommitInput"}, + "branch": map[string]any{"$ref": "#/components/schemas/SourceSnapshotBranchInput"}, + "pull_request": map[string]any{"$ref": "#/components/schemas/SourceSnapshotPullRequestInput"}, + }, "repository")) + registry.RegisterSchema("SourceRepository", objectSchema(map[string]any{ + "id": map[string]any{"type": "string"}, + "tenant_id": map[string]any{"type": "string"}, + "project_id": map[string]any{"type": "string"}, + "provider": map[string]any{"type": "string"}, + "full_name": map[string]any{"type": "string"}, + "clone_url": map[string]any{"type": "string"}, + "default_branch": map[string]any{"type": "string"}, + "schema_version": map[string]any{"type": "string"}, + "created_at": map[string]any{"type": "string", "format": "date-time"}, + }, "id", "tenant_id", "provider", "full_name", "schema_version", "created_at")) + registry.RegisterSchema("SourceCommit", objectSchema(map[string]any{ + "id": map[string]any{"type": "string"}, + "tenant_id": map[string]any{"type": "string"}, + "repository_id": map[string]any{"type": "string"}, + "sha": map[string]any{"type": "string"}, + "author": map[string]any{"type": "string"}, + "message_hash": map[string]any{"type": "string"}, + "committed_at": map[string]any{"type": "string", "format": "date-time"}, + "schema_version": map[string]any{"type": "string"}, + "created_at": map[string]any{"type": "string", "format": "date-time"}, + }, "id", "tenant_id", "repository_id", "sha", "schema_version", "created_at")) + registry.RegisterSchema("SourceBranch", objectSchema(map[string]any{ + "id": map[string]any{"type": "string"}, + "tenant_id": map[string]any{"type": "string"}, + "repository_id": map[string]any{"type": "string"}, + "name": map[string]any{"type": "string"}, + "head_commit_id": map[string]any{"type": "string"}, + "protected": map[string]any{"type": "boolean"}, + "protection_hash": map[string]any{"type": "string"}, + "schema_version": map[string]any{"type": "string"}, + "created_at": map[string]any{"type": "string", "format": "date-time"}, + }, "id", "tenant_id", "repository_id", "name", "protected", "schema_version", "created_at")) + registry.RegisterSchema("PullRequest", objectSchema(map[string]any{ + "id": map[string]any{"type": "string"}, + "tenant_id": map[string]any{"type": "string"}, + "repository_id": map[string]any{"type": "string"}, + "provider": map[string]any{"type": "string"}, + "provider_id": map[string]any{"type": "string"}, + "title": map[string]any{"type": "string"}, + "state": map[string]any{"type": "string"}, + "source_branch": map[string]any{"type": "string"}, + "target_branch": map[string]any{"type": "string"}, + "head_commit_id": map[string]any{"type": "string"}, + "review_decision": map[string]any{"type": "string"}, + "schema_version": map[string]any{"type": "string"}, + "created_at": map[string]any{"type": "string", "format": "date-time"}, + }, "id", "tenant_id", "repository_id", "provider", "provider_id", "state", "schema_version", "created_at")) + registry.RegisterSchema("SourceSnapshotResult", objectSchema(map[string]any{ + "repository": map[string]any{"$ref": "#/components/schemas/SourceRepository"}, + "commit": map[string]any{"$ref": "#/components/schemas/SourceCommit"}, + "branch": map[string]any{"$ref": "#/components/schemas/SourceBranch"}, + "pull_request": map[string]any{"$ref": "#/components/schemas/PullRequest"}, + }, "repository")) + registry.RegisterSchema("SourceSnapshotEnvelope", dataEnvelopeSchema("#/components/schemas/SourceSnapshotResult")) + registry.RegisterSchema("CreateGraphSnapshotRequest", objectSchema(map[string]any{ + "product_id": map[string]any{"type": "string"}, + "release_id": map[string]any{"type": "string"}, + })) + registry.RegisterSchema("EvidenceGraphNode", objectSchema(map[string]any{ + "id": map[string]any{"type": "string"}, + "type": map[string]any{"type": "string"}, + "label": map[string]any{"type": "string"}, + }, "id", "type", "label")) + registry.RegisterSchema("EvidenceGraphEdge", objectSchema(map[string]any{ + "from": map[string]any{"type": "string"}, + "to": map[string]any{"type": "string"}, + "relationship": map[string]any{"type": "string"}, + }, "from", "to", "relationship")) + registry.RegisterSchema("EvidenceGraphSnapshot", objectSchema(map[string]any{ + "id": map[string]any{"type": "string"}, + "tenant_id": map[string]any{"type": "string"}, + "product_id": map[string]any{"type": "string"}, + "release_id": map[string]any{"type": "string"}, + "nodes": map[string]any{"type": "array", "items": map[string]any{"$ref": "#/components/schemas/EvidenceGraphNode"}}, + "edges": map[string]any{"type": "array", "items": map[string]any{"$ref": "#/components/schemas/EvidenceGraphEdge"}}, + "graph_hash": map[string]any{"type": "string", "pattern": "^sha256:"}, + "limitations": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "schema_version": map[string]any{"type": "string"}, + "created_at": map[string]any{"type": "string", "format": "date-time"}, + }, "id", "tenant_id", "nodes", "edges", "graph_hash", "limitations", "schema_version", "created_at")) + registry.RegisterSchema("EvidenceGraphSnapshotEnvelope", dataEnvelopeSchema("#/components/schemas/EvidenceGraphSnapshot")) + registry.RegisterSchema("EvidenceUploadRequest", objectSchema(map[string]any{ + "release_id": map[string]any{"type": "string"}, + "artifact_id": map[string]any{"type": "string"}, + "payload": map[string]any{"type": "object"}, + }, "release_id", "payload")) + registry.RegisterSchema("SBOM", objectSchema(map[string]any{ + "id": map[string]any{"type": "string"}, + "tenant_id": map[string]any{"type": "string"}, + "evidence_id": map[string]any{"type": "string"}, + "release_id": map[string]any{"type": "string"}, + "artifact_id": map[string]any{"type": "string"}, + "format": map[string]any{"type": "string"}, + "spec_version": map[string]any{"type": "string"}, + "component_count": map[string]any{"type": "integer"}, + "schema_version": map[string]any{"type": "string"}, + "created_at": map[string]any{"type": "string", "format": "date-time"}, + }, "id", "tenant_id", "evidence_id", "release_id", "format", "component_count", "created_at")) + registry.RegisterSchema("SBOMEnvelope", dataEnvelopeSchema("#/components/schemas/SBOM")) + registry.RegisterSchema("SBOMComponent", objectSchema(map[string]any{ + "name": map[string]any{"type": "string"}, + "version": map[string]any{"type": "string"}, + "purl": map[string]any{"type": "string"}, + "hashes": map[string]any{"type": "object", "additionalProperties": map[string]any{"type": "string"}}, + }, "name")) + registry.RegisterSchema("SBOMComponentRecord", objectSchema(map[string]any{ + "sbom_id": map[string]any{"type": "string"}, + "release_id": map[string]any{"type": "string"}, + "artifact_id": map[string]any{"type": "string"}, + "component": map[string]any{"$ref": "#/components/schemas/SBOMComponent"}, + }, "sbom_id", "component")) + registry.RegisterSchema("SBOMComponentRecordListEnvelope", dataArrayEnvelopeSchema("#/components/schemas/SBOMComponentRecord")) + registry.RegisterSchema("UploadSPDXSBOMRequest", objectSchema(map[string]any{ + "release_id": map[string]any{"type": "string"}, + "artifact_id": map[string]any{"type": "string"}, + "payload": map[string]any{"type": "object", "additionalProperties": true}, + }, "release_id", "payload")) + registry.RegisterSchema("CreateSBOMDiffRequest", objectSchema(map[string]any{ + "base_sbom_id": map[string]any{"type": "string"}, + "target_sbom_id": map[string]any{"type": "string"}, + "release_id": map[string]any{"type": "string"}, + }, "base_sbom_id", "target_sbom_id")) + registry.RegisterSchema("DependencyChange", objectSchema(map[string]any{ + "id": map[string]any{"type": "string"}, + "tenant_id": map[string]any{"type": "string"}, + "sbom_diff_id": map[string]any{"type": "string"}, + "change_type": map[string]any{"type": "string", "enum": []string{"added", "removed", "changed"}}, + "component": map[string]any{"$ref": "#/components/schemas/SBOMComponent"}, + "schema_version": map[string]any{"type": "string"}, + "created_at": map[string]any{"type": "string", "format": "date-time"}, + }, "id", "tenant_id", "sbom_diff_id", "change_type", "component", "schema_version", "created_at")) + registry.RegisterSchema("SBOMDiff", objectSchema(map[string]any{ + "id": map[string]any{"type": "string"}, + "tenant_id": map[string]any{"type": "string"}, + "base_sbom_id": map[string]any{"type": "string"}, + "target_sbom_id": map[string]any{"type": "string"}, + "release_id": map[string]any{"type": "string"}, + "added_components": map[string]any{"type": "array", "items": map[string]any{"$ref": "#/components/schemas/SBOMComponent"}}, + "removed_components": map[string]any{"type": "array", "items": map[string]any{"$ref": "#/components/schemas/SBOMComponent"}}, + "unchanged_count": map[string]any{"type": "integer"}, + "dependency_changes": map[string]any{"type": "array", "items": map[string]any{"$ref": "#/components/schemas/DependencyChange"}}, + "schema_version": map[string]any{"type": "string"}, + "created_at": map[string]any{"type": "string", "format": "date-time"}, + }, "id", "tenant_id", "base_sbom_id", "target_sbom_id", "unchanged_count", "schema_version", "created_at")) + registry.RegisterSchema("SBOMDiffEnvelope", dataEnvelopeSchema("#/components/schemas/SBOMDiff")) + registry.RegisterSchema("VEXDocument", objectSchema(map[string]any{ + "id": map[string]any{"type": "string"}, + "tenant_id": map[string]any{"type": "string"}, + "evidence_id": map[string]any{"type": "string"}, + "release_id": map[string]any{"type": "string"}, + "artifact_id": map[string]any{"type": "string"}, + "format": map[string]any{"type": "string"}, + "author": map[string]any{"type": "string"}, + "statement_count": map[string]any{"type": "integer"}, + "status_summary": map[string]any{"type": "object", "additionalProperties": map[string]any{"type": "integer"}}, + "schema_version": map[string]any{"type": "string"}, + "created_at": map[string]any{"type": "string", "format": "date-time"}, + }, "id", "tenant_id", "evidence_id", "release_id", "format", "statement_count", "schema_version", "created_at")) + registry.RegisterSchema("VEXDocumentEnvelope", dataEnvelopeSchema("#/components/schemas/VEXDocument")) + registry.RegisterSchema("VEXImportIssue", objectSchema(map[string]any{ + "statement_index": map[string]any{"type": "integer"}, + "code": map[string]any{"type": "string"}, + "detail": map[string]any{"type": "string"}, + }, "code", "detail")) + registry.RegisterSchema("VEXImportReport", objectSchema(map[string]any{ + "id": map[string]any{"type": "string"}, + "tenant_id": map[string]any{"type": "string"}, + "vex_document_id": map[string]any{"type": "string"}, + "evidence_id": map[string]any{"type": "string"}, + "release_id": map[string]any{"type": "string"}, + "artifact_id": map[string]any{"type": "string"}, + "parser_version": map[string]any{"type": "string"}, + "status": map[string]any{"type": "string", "enum": []string{"accepted", "parsed", "failed"}}, + "statement_count": map[string]any{"type": "integer"}, + "decisions_created": map[string]any{"type": "integer"}, + "decisions_superseded": map[string]any{"type": "integer"}, + "unsupported_fields": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "warnings": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "invalid_statements": map[string]any{"type": "array", "items": map[string]any{"$ref": "#/components/schemas/VEXImportIssue"}}, + "mapping_failures": map[string]any{"type": "array", "items": map[string]any{"$ref": "#/components/schemas/VEXImportIssue"}}, + "failure_code": map[string]any{"type": "string"}, + "failure_detail": map[string]any{"type": "string"}, + "schema_version": map[string]any{"type": "string"}, + "created_at": map[string]any{"type": "string", "format": "date-time"}, + "updated_at": map[string]any{"type": "string", "format": "date-time"}, + }, "id", "tenant_id", "vex_document_id", "evidence_id", "parser_version", "status", "statement_count", "decisions_created", "decisions_superseded", "schema_version", "created_at", "updated_at")) + registry.RegisterSchema("VEXImportReportEnvelope", dataEnvelopeSchema("#/components/schemas/VEXImportReport")) + registry.RegisterSchema("VEXImportPreview", objectSchema(map[string]any{ + "tenant_id": map[string]any{"type": "string"}, + "release_id": map[string]any{"type": "string"}, + "artifact_id": map[string]any{"type": "string"}, + "format": map[string]any{"type": "string", "enum": []string{"openvex", "cyclonedx"}}, + "parser_version": map[string]any{"type": "string"}, + "advisory": map[string]any{"type": "boolean"}, + "statement_count": map[string]any{"type": "integer"}, + "status_summary": map[string]any{"type": "object", "additionalProperties": map[string]any{"type": "integer"}}, + "decisions_would_create": map[string]any{"type": "integer"}, + "decisions_would_supersede": map[string]any{"type": "integer"}, + "warnings": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "invalid_statements": map[string]any{"type": "array", "items": map[string]any{"$ref": "#/components/schemas/VEXImportIssue"}}, + "mapping_failures": map[string]any{"type": "array", "items": map[string]any{"$ref": "#/components/schemas/VEXImportIssue"}}, + "assumptions": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "limitations": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "schema_version": map[string]any{"type": "string"}, + "generated_at": map[string]any{"type": "string", "format": "date-time"}, + }, "tenant_id", "release_id", "format", "parser_version", "advisory", "statement_count", "status_summary", "decisions_would_create", "decisions_would_supersede", "assumptions", "limitations", "schema_version", "generated_at")) + registry.RegisterSchema("VEXImportPreviewEnvelope", dataEnvelopeSchema("#/components/schemas/VEXImportPreview")) + registry.RegisterSchema("VulnerabilityScan", objectSchema(map[string]any{ + "id": map[string]any{"type": "string"}, + "tenant_id": map[string]any{"type": "string"}, + "release_id": map[string]any{"type": "string"}, + "scanner": map[string]any{"type": "string"}, + "target_ref": map[string]any{"type": "string"}, + "summary": map[string]any{"type": "object", "additionalProperties": map[string]any{"type": "integer"}}, + "findings": map[string]any{"type": "array", "items": map[string]any{"type": "object"}}, + "created_at": map[string]any{"type": "string", "format": "date-time"}, + }, "id", "tenant_id", "release_id", "scanner", "target_ref", "summary", "findings", "created_at")) + registry.RegisterSchema("VulnerabilityScanEnvelope", dataEnvelopeSchema("#/components/schemas/VulnerabilityScan")) + registry.RegisterSchema("UploadVulnerabilityScanRequest", objectSchema(map[string]any{ + "scanner": map[string]any{"type": "string"}, + "target_ref": map[string]any{"type": "string"}, + "release_id": map[string]any{"type": "string"}, + "findings": map[string]any{"type": "array", "items": objectSchema(map[string]any{ + "vulnerability": map[string]any{"type": "string"}, + "component": map[string]any{"type": "string"}, + "severity": map[string]any{"type": "string"}, + "state": map[string]any{"type": "string"}, + }, "vulnerability", "severity")}, + }, "scanner", "target_ref", "release_id", "findings")) + registry.RegisterSchema("CreateIncidentRequest", objectSchema(map[string]any{ + "product_id": map[string]any{"type": "string"}, + "release_id": map[string]any{"type": "string"}, + "title": map[string]any{"type": "string"}, + "severity": map[string]any{"type": "string", "enum": []string{"low", "medium", "high", "critical"}}, + "opened_at": map[string]any{"type": "string", "format": "date-time"}, + }, "product_id", "title", "severity")) + registry.RegisterSchema("Incident", objectSchema(map[string]any{ + "id": map[string]any{"type": "string"}, + "tenant_id": map[string]any{"type": "string"}, + "product_id": map[string]any{"type": "string"}, + "release_id": map[string]any{"type": "string"}, + "title": map[string]any{"type": "string"}, + "severity": map[string]any{"type": "string"}, + "status": map[string]any{"type": "string"}, + "opened_at": map[string]any{"type": "string", "format": "date-time"}, + "closed_at": map[string]any{"type": "string", "format": "date-time"}, + "schema_version": map[string]any{"type": "string"}, + "created_at": map[string]any{"type": "string", "format": "date-time"}, + }, "id", "tenant_id", "product_id", "title", "severity", "status", "opened_at", "schema_version", "created_at")) + registry.RegisterSchema("IncidentEnvelope", dataEnvelopeSchema("#/components/schemas/Incident")) + registry.RegisterSchema("RecordIncidentTimelineRequest", objectSchema(map[string]any{ + "event_type": map[string]any{"type": "string"}, + "summary": map[string]any{"type": "string"}, + "evidence_id": map[string]any{"type": "string"}, + "occurred_at": map[string]any{"type": "string", "format": "date-time"}, + }, "event_type", "summary")) + registry.RegisterSchema("IncidentTimelineEvent", objectSchema(map[string]any{ + "id": map[string]any{"type": "string"}, + "tenant_id": map[string]any{"type": "string"}, + "incident_id": map[string]any{"type": "string"}, + "event_type": map[string]any{"type": "string"}, + "summary": map[string]any{"type": "string"}, + "evidence_id": map[string]any{"type": "string"}, + "occurred_at": map[string]any{"type": "string", "format": "date-time"}, + "schema_version": map[string]any{"type": "string"}, + "created_at": map[string]any{"type": "string", "format": "date-time"}, + }, "id", "tenant_id", "incident_id", "event_type", "summary", "occurred_at", "schema_version", "created_at")) + registry.RegisterSchema("IncidentTimelineEventEnvelope", dataEnvelopeSchema("#/components/schemas/IncidentTimelineEvent")) + registry.RegisterSchema("CreateIncidentWebhookReceiverRequest", objectSchema(map[string]any{ + "name": map[string]any{"type": "string"}, + "provider": map[string]any{"type": "string"}, + "public_key": map[string]any{"type": "string", "description": "Ed25519 public key used to verify signed incident webhook events."}, + }, "name", "provider", "public_key")) + registry.RegisterSchema("IncidentWebhookReceiver", objectSchema(map[string]any{ + "id": map[string]any{"type": "string"}, + "tenant_id": map[string]any{"type": "string"}, + "incident_id": map[string]any{"type": "string"}, + "name": map[string]any{"type": "string"}, + "provider": map[string]any{"type": "string"}, + "public_key": map[string]any{"type": "string"}, + "status": map[string]any{"type": "string"}, + "schema_version": map[string]any{"type": "string"}, + "created_at": map[string]any{"type": "string", "format": "date-time"}, + }, "id", "tenant_id", "incident_id", "name", "provider", "public_key", "status", "schema_version", "created_at")) + registry.RegisterSchema("IncidentWebhookReceiverEnvelope", dataEnvelopeSchema("#/components/schemas/IncidentWebhookReceiver")) + registry.RegisterSchema("SignedIncidentWebhookPayload", objectSchema(map[string]any{ + "event_type": map[string]any{"type": "string"}, + "summary": map[string]any{"type": "string"}, + "evidence_id": map[string]any{"type": "string"}, + "occurred_at": map[string]any{"type": "string", "format": "date-time"}, + }, "event_type", "summary")) + registry.RegisterSchema("IncidentWebhookEvent", objectSchema(map[string]any{ + "id": map[string]any{"type": "string"}, + "tenant_id": map[string]any{"type": "string"}, + "receiver_id": map[string]any{"type": "string"}, + "incident_id": map[string]any{"type": "string"}, + "provider": map[string]any{"type": "string"}, + "event_id": map[string]any{"type": "string"}, + "payload_hash": map[string]any{"type": "string", "pattern": "^sha256:"}, + "signature_hash": map[string]any{"type": "string", "pattern": "^sha256:"}, + "timeline_event_id": map[string]any{"type": "string"}, + "result": map[string]any{"type": "string"}, + "schema_version": map[string]any{"type": "string"}, + "created_at": map[string]any{"type": "string", "format": "date-time"}, + }, "id", "tenant_id", "receiver_id", "incident_id", "provider", "event_id", "payload_hash", "signature_hash", "result", "schema_version", "created_at")) + registry.RegisterSchema("IncidentWebhookDelivery", objectSchema(map[string]any{ + "webhook_event": map[string]any{"$ref": "#/components/schemas/IncidentWebhookEvent"}, + "timeline_event": map[string]any{"$ref": "#/components/schemas/IncidentTimelineEvent"}, + }, "webhook_event", "timeline_event")) + registry.RegisterSchema("IncidentWebhookDeliveryEnvelope", dataEnvelopeSchema("#/components/schemas/IncidentWebhookDelivery")) + registry.RegisterSchema("CreateRemediationTaskRequest", objectSchema(map[string]any{ + "incident_id": map[string]any{"type": "string"}, + "release_id": map[string]any{"type": "string"}, + "title": map[string]any{"type": "string"}, + "owner": map[string]any{"type": "string"}, + "due_at": map[string]any{"type": "string", "format": "date-time"}, + "evidence_id": map[string]any{"type": "string"}, + }, "title", "owner")) + registry.RegisterSchema("RemediationTask", objectSchema(map[string]any{ + "id": map[string]any{"type": "string"}, + "tenant_id": map[string]any{"type": "string"}, + "incident_id": map[string]any{"type": "string"}, + "release_id": map[string]any{"type": "string"}, + "title": map[string]any{"type": "string"}, + "owner": map[string]any{"type": "string"}, + "status": map[string]any{"type": "string"}, + "due_at": map[string]any{"type": "string", "format": "date-time"}, + "evidence_id": map[string]any{"type": "string"}, + "schema_version": map[string]any{"type": "string"}, + "created_at": map[string]any{"type": "string", "format": "date-time"}, + }, "id", "tenant_id", "title", "owner", "status", "schema_version", "created_at")) + registry.RegisterSchema("RemediationTaskEnvelope", dataEnvelopeSchema("#/components/schemas/RemediationTask")) + registry.RegisterSchema("IncidentReport", objectSchema(map[string]any{ + "report_type": map[string]any{"type": "string"}, + "template_version": map[string]any{"type": "string"}, + "incident_id": map[string]any{"type": "string"}, + "result": map[string]any{"type": "string"}, + "timeline": map[string]any{"type": "array", "items": map[string]any{"$ref": "#/components/schemas/IncidentTimelineEvent"}}, + "tasks": map[string]any{"type": "array", "items": map[string]any{"$ref": "#/components/schemas/RemediationTask"}}, + "linked_evidence": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "assumptions": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "limitations": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "generated_at": map[string]any{"type": "string", "format": "date-time"}, + }, "report_type", "template_version", "incident_id", "result", "timeline", "tasks", "assumptions", "limitations", "generated_at")) + registry.RegisterSchema("IncidentReportEnvelope", dataEnvelopeSchema("#/components/schemas/IncidentReport")) + registry.RegisterSchema("UploadSecurityScanRequest", objectSchema(map[string]any{ + "product_id": map[string]any{"type": "string"}, + "release_id": map[string]any{"type": "string"}, + "artifact_id": map[string]any{"type": "string"}, + "category": map[string]any{"type": "string", "enum": []string{"sast", "dast", "secret", "license", "api_security"}}, + "format": map[string]any{"type": "string"}, + "scanner": map[string]any{"type": "string"}, + "target_ref": map[string]any{"type": "string"}, + "payload": map[string]any{"type": "object", "additionalProperties": true}, + }, "category", "format", "scanner", "target_ref", "payload")) + registry.RegisterSchema("SecurityScan", objectSchema(map[string]any{ + "id": map[string]any{"type": "string"}, + "tenant_id": map[string]any{"type": "string"}, + "product_id": map[string]any{"type": "string"}, + "release_id": map[string]any{"type": "string"}, + "artifact_id": map[string]any{"type": "string"}, + "category": map[string]any{"type": "string"}, + "format": map[string]any{"type": "string"}, + "scanner": map[string]any{"type": "string"}, + "target_ref": map[string]any{"type": "string"}, + "evidence_id": map[string]any{"type": "string"}, + "payload_ref": map[string]any{"type": "string"}, + "payload_hash": map[string]any{"type": "string", "pattern": "^sha256:"}, + "finding_count": map[string]any{"type": "integer"}, + "summary": map[string]any{"type": "object", "additionalProperties": map[string]any{"type": "integer"}}, + "redacted": map[string]any{"type": "boolean"}, + "quarantined": map[string]any{"type": "boolean"}, + "schema_version": map[string]any{"type": "string"}, + "created_at": map[string]any{"type": "string", "format": "date-time"}, + }, "id", "tenant_id", "category", "format", "scanner", "target_ref", "evidence_id", "payload_hash", "finding_count", "redacted", "quarantined", "schema_version", "created_at")) + registry.RegisterSchema("SecurityScanEnvelope", dataEnvelopeSchema("#/components/schemas/SecurityScan")) + registry.RegisterSchema("UploadManualSecurityDocumentRequest", objectSchema(map[string]any{ + "product_id": map[string]any{"type": "string"}, + "release_id": map[string]any{"type": "string"}, + "document_type": map[string]any{"type": "string", "enum": []string{"threat_model", "security_review", "pentest_report"}}, + "title": map[string]any{"type": "string"}, + "sensitivity": map[string]any{"type": "string", "enum": []string{"internal", "restricted", "confidential"}}, + "payload": map[string]any{"type": "string", "description": "Document payload or text supplied for object storage; responses expose only payload hash/ref metadata."}, + "media_type": map[string]any{"type": "string"}, + }, "document_type", "title", "sensitivity", "payload")) + registry.RegisterSchema("ManualSecurityDocument", objectSchema(map[string]any{ + "id": map[string]any{"type": "string"}, + "tenant_id": map[string]any{"type": "string"}, + "product_id": map[string]any{"type": "string"}, + "release_id": map[string]any{"type": "string"}, + "document_type": map[string]any{"type": "string"}, + "title": map[string]any{"type": "string"}, + "sensitivity": map[string]any{"type": "string"}, + "evidence_id": map[string]any{"type": "string"}, + "payload_ref": map[string]any{"type": "string"}, + "payload_hash": map[string]any{"type": "string", "pattern": "^sha256:"}, + "schema_version": map[string]any{"type": "string"}, + "created_at": map[string]any{"type": "string", "format": "date-time"}, + }, "id", "tenant_id", "document_type", "title", "sensitivity", "evidence_id", "payload_hash", "schema_version", "created_at")) + registry.RegisterSchema("ManualSecurityDocumentEnvelope", dataEnvelopeSchema("#/components/schemas/ManualSecurityDocument")) + registry.RegisterSchema("VulnerabilityPostureReport", objectSchema(map[string]any{ + "report_type": map[string]any{"type": "string"}, + "template_version": map[string]any{"type": "string"}, + "release_id": map[string]any{"type": "string"}, + "summary": map[string]any{"type": "object", "additionalProperties": map[string]any{"type": "integer"}}, + "open_critical": map[string]any{"type": "integer"}, + "assumptions": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "limitations": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "generated_at": map[string]any{"type": "string", "format": "date-time"}, + }, "report_type", "template_version", "summary", "open_critical", "assumptions", "limitations", "generated_at")) + registry.RegisterSchema("VulnerabilityPostureReportEnvelope", dataEnvelopeSchema("#/components/schemas/VulnerabilityPostureReport")) + registry.RegisterSchema("CRAVulnerabilityHandlingReport", objectSchema(map[string]any{ + "report_type": map[string]any{"type": "string"}, + "template_version": map[string]any{"type": "string"}, + "product_id": map[string]any{"type": "string"}, + "release_id": map[string]any{"type": "string"}, + "summary": map[string]any{"type": "object", "additionalProperties": map[string]any{"type": "integer"}}, + "decisions": map[string]any{"type": "array", "items": map[string]any{"$ref": "#/components/schemas/VulnerabilityDecisionCustomerSummary"}}, + "accepted_exceptions": map[string]any{"type": "array", "items": map[string]any{"$ref": "#/components/schemas/Exception"}}, + "evidence_ids": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "assumptions": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "limitations": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "generated_at": map[string]any{"type": "string", "format": "date-time"}, + }, "report_type", "template_version", "product_id", "release_id", "summary", "assumptions", "limitations", "generated_at")) + registry.RegisterSchema("CRAVulnerabilityHandlingReportEnvelope", dataEnvelopeSchema("#/components/schemas/CRAVulnerabilityHandlingReport")) + registry.RegisterSchema("SecurityUpdateEvidenceReport", objectSchema(map[string]any{ + "report_type": map[string]any{"type": "string"}, + "template_version": map[string]any{"type": "string"}, + "product_id": map[string]any{"type": "string"}, + "release_id": map[string]any{"type": "string"}, + "summary": map[string]any{"type": "object", "additionalProperties": map[string]any{"type": "integer"}}, + "fixed_decisions": map[string]any{"type": "array", "items": map[string]any{"$ref": "#/components/schemas/VulnerabilityDecisionCustomerSummary"}}, + "incidents": map[string]any{"type": "array", "items": map[string]any{"$ref": "#/components/schemas/Incident"}}, + "remediation_tasks": map[string]any{"type": "array", "items": map[string]any{"$ref": "#/components/schemas/RemediationTask"}}, + "evidence_ids": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "assumptions": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "limitations": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "generated_at": map[string]any{"type": "string", "format": "date-time"}, + }, "report_type", "template_version", "product_id", "release_id", "summary", "assumptions", "limitations", "generated_at")) + registry.RegisterSchema("SecurityUpdateEvidenceReportEnvelope", dataEnvelopeSchema("#/components/schemas/SecurityUpdateEvidenceReport")) + registry.RegisterSchema("CreateAnomalyReportRequest", objectSchema(map[string]any{ + "subject_type": map[string]any{"type": "string"}, + "subject_id": map[string]any{"type": "string"}, + }, "subject_type", "subject_id")) + registry.RegisterSchema("AnomalySignal", objectSchema(map[string]any{ + "name": map[string]any{"type": "string"}, + "severity": map[string]any{"type": "string"}, + "detail": map[string]any{"type": "string"}, + }, "name", "severity", "detail")) + registry.RegisterSchema("AnomalyReport", objectSchema(map[string]any{ + "id": map[string]any{"type": "string"}, + "tenant_id": map[string]any{"type": "string"}, + "subject_type": map[string]any{"type": "string"}, + "subject_id": map[string]any{"type": "string"}, + "result": map[string]any{"type": "string"}, + "signals": map[string]any{"type": "array", "items": map[string]any{"$ref": "#/components/schemas/AnomalySignal"}}, + "assumptions": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "limitations": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "schema_version": map[string]any{"type": "string"}, + "created_at": map[string]any{"type": "string", "format": "date-time"}, + }, "id", "tenant_id", "subject_type", "subject_id", "result", "assumptions", "limitations", "schema_version", "created_at")) + registry.RegisterSchema("AnomalyReportEnvelope", dataEnvelopeSchema("#/components/schemas/AnomalyReport")) + registry.RegisterSchema("CreatePublicTransparencyLogRequest", objectSchema(map[string]any{ + "name": map[string]any{"type": "string"}, + "endpoint": map[string]any{"type": "string"}, + "public_key": map[string]any{"type": "string"}, + }, "name", "endpoint", "public_key")) + registry.RegisterSchema("PublicTransparencyLog", objectSchema(map[string]any{ + "id": map[string]any{"type": "string"}, + "tenant_id": map[string]any{"type": "string"}, + "name": map[string]any{"type": "string"}, + "endpoint": map[string]any{"type": "string"}, + "public_key": map[string]any{"type": "string"}, + "state": map[string]any{"type": "string"}, + "schema_version": map[string]any{"type": "string"}, + "created_at": map[string]any{"type": "string", "format": "date-time"}, + }, "id", "tenant_id", "name", "endpoint", "public_key", "state", "schema_version", "created_at")) + registry.RegisterSchema("PublicTransparencyLogEnvelope", dataEnvelopeSchema("#/components/schemas/PublicTransparencyLog")) + registry.RegisterSchema("PublishPublicTransparencyLogEntryRequest", objectSchema(map[string]any{ + "log_id": map[string]any{"type": "string"}, + "checkpoint_id": map[string]any{"type": "string"}, + "external_id": map[string]any{"type": "string"}, + }, "log_id", "checkpoint_id", "external_id")) + registry.RegisterSchema("VerifyPublicTransparencyLogEntryRequest", objectSchema(map[string]any{ + "leaf_hash": map[string]any{"type": "string", "pattern": "^sha256:"}, + "root_hash": map[string]any{"type": "string", "pattern": "^sha256:"}, + "leaf_index": map[string]any{"type": "integer", "minimum": 0}, + "tree_size": map[string]any{"type": "integer", "minimum": 1}, + "inclusion_proof": map[string]any{"type": "array", "items": map[string]any{"type": "string", "pattern": "^sha256:"}}, + }, "root_hash", "leaf_index", "tree_size", "inclusion_proof")) + registry.RegisterSchema("PublicTransparencyLogEntry", objectSchema(map[string]any{ + "id": map[string]any{"type": "string"}, + "tenant_id": map[string]any{"type": "string"}, + "log_id": map[string]any{"type": "string"}, + "checkpoint_id": map[string]any{"type": "string"}, + "merkle_batch_id": map[string]any{"type": "string"}, + "external_id": map[string]any{"type": "string"}, + "entry_hash": map[string]any{"type": "string", "pattern": "^sha256:"}, + "inclusion_root_hash": map[string]any{"type": "string", "pattern": "^sha256:"}, + "inclusion_proof_hash": map[string]any{"type": "string", "pattern": "^sha256:"}, + "inclusion_verified_at": map[string]any{"type": "string", "format": "date-time"}, + "verification_checks": map[string]any{"type": "array", "items": map[string]any{"$ref": "#/components/schemas/VerifyCheck"}}, + "verification_limitations": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "state": map[string]any{"type": "string"}, + "schema_version": map[string]any{"type": "string"}, + "created_at": map[string]any{"type": "string", "format": "date-time"}, + }, "id", "tenant_id", "log_id", "checkpoint_id", "merkle_batch_id", "external_id", "entry_hash", "state", "schema_version", "created_at")) + registry.RegisterSchema("PublicTransparencyLogEntryEnvelope", dataEnvelopeSchema("#/components/schemas/PublicTransparencyLogEntry")) + registry.RegisterSchema("CreateSaaSEditionProfileRequest", objectSchema(map[string]any{ + "name": map[string]any{"type": "string"}, + "region": map[string]any{"type": "string"}, + "admin_tenant_id": map[string]any{"type": "string"}, + "isolation_model": map[string]any{"type": "string"}, + }, "name", "region", "admin_tenant_id", "isolation_model")) + registry.RegisterSchema("SaaSEditionProfile", objectSchema(map[string]any{ + "id": map[string]any{"type": "string"}, + "tenant_id": map[string]any{"type": "string"}, + "name": map[string]any{"type": "string"}, + "region": map[string]any{"type": "string"}, + "admin_tenant_id": map[string]any{"type": "string"}, + "isolation_model": map[string]any{"type": "string"}, + "status": map[string]any{"type": "string"}, + "config_hash": map[string]any{"type": "string", "pattern": "^sha256:"}, + "limitations": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "schema_version": map[string]any{"type": "string"}, + "created_at": map[string]any{"type": "string", "format": "date-time"}, + }, "id", "tenant_id", "name", "region", "admin_tenant_id", "isolation_model", "status", "config_hash", "limitations", "schema_version", "created_at")) + registry.RegisterSchema("SaaSEditionProfileEnvelope", dataEnvelopeSchema("#/components/schemas/SaaSEditionProfile")) + registry.RegisterSchema("VerifySubjectRequest", objectSchema(map[string]any{ + "subject_type": map[string]any{"type": "string"}, + "subject_id": map[string]any{"type": "string"}, + }, "subject_type")) + registry.RegisterSchema("SubjectRef", objectSchema(map[string]any{ + "type": map[string]any{"type": "string"}, + "id": map[string]any{"type": "string"}, + "digest": map[string]any{"type": "string"}, + }, "type")) + registry.RegisterSchema("EvidenceRef", objectSchema(map[string]any{ + "type": map[string]any{"type": "string"}, + "id": map[string]any{"type": "string"}, + "relationship": map[string]any{"type": "string"}, + }, "type", "id")) + registry.RegisterSchema("EvidenceNotice", objectSchema(map[string]any{ + "code": map[string]any{"type": "string"}, + "message": map[string]any{"type": "string"}, + }, "code", "message")) + registry.RegisterSchema("CreateEvidenceRequest", objectSchema(map[string]any{ + "product_id": map[string]any{"type": "string"}, + "project_id": map[string]any{"type": "string"}, + "release_id": map[string]any{"type": "string"}, + "build_id": map[string]any{"type": "string"}, + "deployment_id": map[string]any{"type": "string"}, + "type": map[string]any{"type": "string"}, + "subtype": map[string]any{"type": "string"}, + "title": map[string]any{"type": "string"}, + "source_system": map[string]any{"type": "string"}, + "source_identity": map[string]any{"type": "object", "additionalProperties": true}, + "collector_id": map[string]any{"type": "string"}, + "observed_at": map[string]any{"type": "string", "format": "date-time"}, + "payload_ref": map[string]any{"type": "string"}, + "payload_hash": map[string]any{"type": "string", "pattern": "^sha256:"}, + "payload_media_type": map[string]any{"type": "string"}, + "payload_size": map[string]any{"type": "integer", "minimum": 0}, + "subject_refs": map[string]any{"type": "array", "items": map[string]any{"$ref": "#/components/schemas/SubjectRef"}}, + "metadata": map[string]any{"type": "object", "additionalProperties": true}, + "tags": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "limitations": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + }, "type", "title", "payload_hash")) + registry.RegisterSchema("EvidenceItem", objectSchema(map[string]any{ + "id": map[string]any{"type": "string"}, + "tenant_id": map[string]any{"type": "string"}, + "product_id": map[string]any{"type": "string"}, + "project_id": map[string]any{"type": "string"}, + "release_id": map[string]any{"type": "string"}, + "build_id": map[string]any{"type": "string"}, + "deployment_id": map[string]any{"type": "string"}, + "type": map[string]any{"type": "string"}, + "subtype": map[string]any{"type": "string"}, + "title": map[string]any{"type": "string"}, + "source_system": map[string]any{"type": "string"}, + "source_identity": map[string]any{"type": "object", "additionalProperties": true}, + "collector_id": map[string]any{"type": "string"}, + "uploaded_by": map[string]any{"type": "string"}, + "observed_at": map[string]any{"type": "string", "format": "date-time"}, + "evidence_version": map[string]any{"type": "integer"}, + "schema_version": map[string]any{"type": "string"}, + "payload_ref": map[string]any{"type": "string"}, + "payload_hash": map[string]any{"type": "string", "pattern": "^sha256:"}, + "payload_media_type": map[string]any{"type": "string"}, + "payload_size": map[string]any{"type": "integer"}, + "canonical_hash": map[string]any{"type": "string", "pattern": "^sha256:"}, + "canonicalization": map[string]any{"type": "string"}, + "subject_refs": map[string]any{"type": "array", "items": map[string]any{"$ref": "#/components/schemas/SubjectRef"}}, + "related_evidence_refs": map[string]any{"type": "array", "items": map[string]any{"$ref": "#/components/schemas/EvidenceRef"}}, + "supersedes": map[string]any{"type": "string"}, + "superseded_by": map[string]any{"type": "string"}, + "trust_level": map[string]any{"type": "string"}, + "verification_status": map[string]any{"type": "string"}, + "signature_refs": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "chain_entry_id": map[string]any{"type": "string"}, + "tags": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "metadata": map[string]any{"type": "object", "additionalProperties": true}, + "warnings": map[string]any{"type": "array", "items": map[string]any{"$ref": "#/components/schemas/EvidenceNotice"}}, + "limitations": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "created_at": map[string]any{"type": "string", "format": "date-time"}, + }, "id", "tenant_id", "type", "title", "source_system", "observed_at", "evidence_version", "schema_version", "payload_hash", "canonical_hash", "canonicalization", "trust_level", "verification_status", "chain_entry_id", "created_at")) + registry.RegisterSchema("EvidenceItemEnvelope", dataEnvelopeSchema("#/components/schemas/EvidenceItem")) + registry.RegisterSchema("EvidenceItemListEnvelope", dataArrayEnvelopeSchema("#/components/schemas/EvidenceItem")) + registry.RegisterSchema("EvidenceSearchResponse", objectSchema(map[string]any{ + "items": map[string]any{"type": "array", "items": map[string]any{"$ref": "#/components/schemas/EvidenceItem"}}, + "next_cursor": map[string]any{"type": "string"}, + }, "items")) + registry.RegisterSchema("EvidenceSearchEnvelope", dataEnvelopeSchema("#/components/schemas/EvidenceSearchResponse")) + registry.RegisterSchema("CreateReleaseBundleRequest", objectSchema(map[string]any{ + "release_id": map[string]any{"type": "string"}, + }, "release_id")) + registry.RegisterSchema("ReleaseBundleManifest", objectSchema(map[string]any{ + "manifest_version": map[string]any{"type": "string"}, + "bundle_id": map[string]any{"type": "string"}, + "tenant_id": map[string]any{"type": "string"}, + "release": map[string]any{"type": "object", "additionalProperties": true}, + "evidence_ids": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "chain_checkpoint": map[string]any{"type": "object", "additionalProperties": true}, + "generated_at": map[string]any{"type": "string", "format": "date-time"}, + "generator": map[string]any{"type": "object", "additionalProperties": true}, + }, "manifest_version", "bundle_id", "tenant_id", "release", "evidence_ids", "chain_checkpoint", "generated_at", "generator")) + registry.RegisterSchema("ReleaseBundleManifestEnvelope", dataEnvelopeSchema("#/components/schemas/ReleaseBundleManifest")) + registry.RegisterSchema("ReleaseBundle", objectSchema(map[string]any{ + "id": map[string]any{"type": "string"}, + "tenant_id": map[string]any{"type": "string"}, + "release_id": map[string]any{"type": "string"}, + "state": map[string]any{"type": "string"}, + "manifest": map[string]any{"$ref": "#/components/schemas/ReleaseBundleManifest"}, + "manifest_hash": map[string]any{"type": "string", "pattern": "^sha256:"}, + "signature_refs": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "created_at": map[string]any{"type": "string", "format": "date-time"}, + "published_at": map[string]any{"type": "string", "format": "date-time"}, + "revoked_at": map[string]any{"type": "string", "format": "date-time"}, + }, "id", "tenant_id", "release_id", "state", "manifest", "manifest_hash", "signature_refs", "created_at")) + registry.RegisterSchema("ReleaseBundleEnvelope", dataEnvelopeSchema("#/components/schemas/ReleaseBundle")) + registry.RegisterSchema("ReleaseBundleVerification", objectSchema(map[string]any{ + "result": map[string]any{"type": "string", "enum": []string{"passed", "failed"}}, + "subject_type": map[string]any{"type": "string"}, + "subject_id": map[string]any{"type": "string"}, + "checked_at": map[string]any{"type": "string", "format": "date-time"}, + }, "result")) + registry.RegisterSchema("ReleaseBundleVerificationEnvelope", dataEnvelopeSchema("#/components/schemas/ReleaseBundleVerification")) + registry.RegisterSchema("CreateCustomerPortalAccessRequest", objectSchema(map[string]any{ + "package_id": map[string]any{"type": "string"}, + "customer_name": map[string]any{"type": "string"}, + "reviewer_name": map[string]any{"type": "string", "description": "Optional external reviewer display name for this package access record."}, + "reviewer_email": map[string]any{"type": "string", "description": "Optional external reviewer email label. It is not used as an authentication secret."}, + "require_nda": map[string]any{"type": "boolean"}, + "watermark": map[string]any{"type": "string", "description": "Optional customer-visible distribution watermark. It is not a token or secret."}, + "expires_at": map[string]any{"type": "string", "format": "date-time"}, + }, "package_id", "customer_name", "expires_at")) + registry.RegisterSchema("CustomerPortalAccess", objectSchema(map[string]any{ + "id": map[string]any{"type": "string"}, + "tenant_id": map[string]any{"type": "string"}, + "package_id": map[string]any{"type": "string"}, + "customer_name": map[string]any{"type": "string"}, + "reviewer_name": map[string]any{"type": "string"}, + "reviewer_email": map[string]any{"type": "string"}, + "require_nda": map[string]any{"type": "boolean"}, + "nda_accepted_at": map[string]any{"type": "string", "format": "date-time"}, + "nda_accepted_by": map[string]any{"type": "string"}, + "watermark": map[string]any{"type": "string"}, + "prefix": map[string]any{"type": "string", "description": "Non-secret portal token prefix for operational lookup."}, + "expires_at": map[string]any{"type": "string", "format": "date-time"}, + "revoked_at": map[string]any{"type": "string", "format": "date-time"}, + "access_count": map[string]any{"type": "integer"}, + "failed_access_count": map[string]any{"type": "integer"}, + "last_accessed_at": map[string]any{"type": "string", "format": "date-time"}, + "last_failed_at": map[string]any{"type": "string", "format": "date-time"}, + "schema_version": map[string]any{"type": "string"}, + "created_at": map[string]any{"type": "string", "format": "date-time"}, + }, "id", "tenant_id", "package_id", "customer_name", "prefix", "expires_at", "access_count", "failed_access_count", "schema_version", "created_at")) + registry.RegisterSchema("CustomerPortalAccessCreateResponse", objectSchema(map[string]any{ + "access": map[string]any{"$ref": "#/components/schemas/CustomerPortalAccess"}, + "secret": map[string]any{"type": "string", "description": "One-time portal token; stored only as a HMAC hash."}, + }, "access", "secret")) + registry.RegisterSchema("CustomerPortalAccessCreateEnvelope", dataEnvelopeSchema("#/components/schemas/CustomerPortalAccessCreateResponse")) + registry.RegisterSchema("CustomerPortalAccessEnvelope", dataEnvelopeSchema("#/components/schemas/CustomerPortalAccess")) + registry.RegisterSchema("CustomerPortalAccessListEnvelope", dataArrayEnvelopeSchema("#/components/schemas/CustomerPortalAccess")) + registry.RegisterSchema("CustomerPortalPackageRequest", objectSchema(map[string]any{ + "token": map[string]any{"type": "string", "description": "Customer portal access token issued by createCustomerPortalAccess."}, + "nda_accepted": map[string]any{"type": "boolean", "description": "Set true to record acceptance for NDA-gated portal access."}, + "nda_accepted_by": map[string]any{"type": "string", "description": "Reviewer label recorded when NDA acceptance is required. Do not include secrets."}, + }, "token")) +} + +func objectSchema(properties map[string]any, required ...string) map[string]any { + schema := map[string]any{ + "type": "object", + "properties": properties, + "additionalProperties": false, + } + if len(required) > 0 { + schema["required"] = required + } + return schema +} + +func dataEnvelopeSchema(dataRef string) map[string]any { + return objectSchema(map[string]any{ + "data": map[string]any{"$ref": dataRef}, + "meta": objectSchema(map[string]any{ + "api_version": map[string]any{"type": "string"}, + }, "api_version"), + }, "data", "meta") +} + +func dataArrayEnvelopeSchema(itemRef string) map[string]any { + return objectSchema(map[string]any{ + "data": map[string]any{"type": "array", "items": map[string]any{"$ref": itemRef}}, + "meta": objectSchema(map[string]any{ + "api_version": map[string]any{"type": "string"}, + }, "api_version"), + }, "data", "meta") +} diff --git a/internal/adapters/httpapi/openapi_operations.go b/internal/adapters/httpapi/openapi_operations.go new file mode 100644 index 0000000..07d12f8 --- /dev/null +++ b/internal/adapters/httpapi/openapi_operations.go @@ -0,0 +1,1149 @@ +package httpapi + +import ( + "net/http" + + "github.com/aatuh/api-toolkit/v3/specs" +) + +func withCriticalOperationDetails(operation specs.Operation) specs.Operation { + addProblemResponses(&operation) + switch operation.OperationID { + case "health": + operation.Description = "Returns low-detail liveness status without touching tenant evidence or secret material." + operation.Security = nil + operation.Scopes = nil + operation.Responses[http.StatusOK] = jsonResponse("Liveness status envelope.", "#/components/schemas/HealthStatusEnvelope") + case "ready": + operation.Description = "Returns low-detail process readiness without tenant evidence or secret material." + operation.Security = nil + operation.Scopes = nil + operation.Responses[http.StatusOK] = jsonResponse("Readiness status envelope.", "#/components/schemas/ReadinessStatusEnvelope") + case "version": + operation.Description = "Returns the API process version string." + operation.Security = nil + operation.Scopes = nil + operation.Responses[http.StatusOK] = jsonResponse("Version information envelope.", "#/components/schemas/VersionInfoEnvelope") + case "metrics": + operation.Description = "Returns safe tenant-scoped resource metrics for admin actors. A Prometheus text response is also available when requested with Accept: text/plain." + operation.Responses[http.StatusOK] = specs.Response{ + Description: "Tenant metrics envelope or Prometheus text metrics.", + ContentTypes: []string{"application/json", "text/plain"}, + Content: map[string]specs.MediaType{ + "application/json": {SchemaRef: "#/components/schemas/MetricsSnapshotEnvelope"}, + "text/plain": {Schema: map[string]any{"type": "string"}}, + }, + } + case "openapi": + operation.Description = "Returns the generated OpenAPI 3.1 document served by this process." + operation.Security = nil + operation.Scopes = nil + operation.Responses[http.StatusOK] = jsonResponse("OpenAPI document.", "#/components/schemas/OpenAPIDocument") + case "instanceAdminSnapshot": + operation.Description = "Returns instance-level diagnostic counts. Requires the explicit instance:admin scope; tenant admin and ordinary wildcard tenant keys are insufficient." + operation.Responses[http.StatusOK] = jsonResponse("Instance admin snapshot envelope.", "#/components/schemas/InstanceAdminSnapshotEnvelope") + case "createOrganization": + operation.Description = "Creates a tenant-scoped organization record for human identity grouping." + operation.RequestBody = jsonRequest("Organization creation request.", "#/components/schemas/CreateOrganizationRequest") + operation.Responses[http.StatusCreated] = jsonResponse("Created organization envelope.", "#/components/schemas/OrganizationEnvelope") + case "createUser": + operation.Description = "Creates a tenant-scoped human user metadata record. Authentication is still controlled by API keys or configured SSO/session flows." + operation.RequestBody = jsonRequest("Human user creation request.", "#/components/schemas/CreateUserRequest") + operation.Responses[http.StatusCreated] = jsonResponse("Created human user envelope.", "#/components/schemas/HumanUserEnvelope") + case "deactivateUser": + operation.Description = "Deactivates a tenant-scoped human user as an audited lifecycle transition." + operation.Parameters = append(operation.Parameters, pathParam("id", "Human user id.")) + operation.RequestBody = jsonRequest("Empty JSON object.", "#/components/schemas/EmptyObject") + operation.Responses[http.StatusOK] = jsonResponse("Deactivated human user envelope.", "#/components/schemas/HumanUserEnvelope") + case "createRoleBinding": + operation.Description = "Creates a tenant-scoped role binding for a user or collector subject." + operation.RequestBody = jsonRequest("Role binding creation request.", "#/components/schemas/CreateRoleBindingRequest") + operation.Responses[http.StatusCreated] = jsonResponse("Created role binding envelope.", "#/components/schemas/RoleBindingEnvelope") + case "listRoleBindings": + operation.Description = "Lists tenant-scoped role bindings visible to the identity administrator." + operation.Responses[http.StatusOK] = jsonResponse("Role binding list envelope.", "#/components/schemas/RoleBindingListEnvelope") + case "createSSOSession": + operation.Description = "Creates an admin-managed human SSO session record and returns a one-time bearer secret." + operation.RequestBody = jsonRequest("SSO session creation request.", "#/components/schemas/CreateSSOSessionRequest") + operation.Responses[http.StatusCreated] = jsonResponse("Created SSO session and one-time secret envelope.", "#/components/schemas/SSOSessionCreateEnvelope") + case "exchangeSSOCredential": + operation.Description = "Exchanges a locally verified OIDC ID token or SAML assertion for an SSO session and HttpOnly browser cookie using configured tenant trust material and verified identity links. No live provider API or group synchronization call is made." + operation.Security = nil + operation.Scopes = nil + operation.RequestBody = jsonRequest("SSO credential exchange request.", "#/components/schemas/ExchangeSSOCredentialRequest") + operation.Responses[http.StatusCreated] = jsonResponse("Created SSO session, verification receipt, and one-time secret envelope.", "#/components/schemas/SSOCredentialExchangeEnvelope") + case "revokeSSOSession": + operation.Description = "Revokes a tenant-scoped SSO session as an audited lifecycle transition." + operation.Parameters = append(operation.Parameters, pathParam("id", "SSO session id.")) + operation.RequestBody = jsonRequest("Empty JSON object.", "#/components/schemas/EmptyObject") + operation.Responses[http.StatusOK] = jsonResponse("Revoked SSO session envelope.", "#/components/schemas/SSOSessionEnvelope") + case "logoutSSOSession": + operation.Description = "Revokes the currently authenticated SSO session without requiring identity administrator privileges. API keys and collector keys cannot use this route." + operation.RequestBody = jsonRequest("Empty JSON object.", "#/components/schemas/EmptyObject") + operation.Responses[http.StatusOK] = jsonResponse("Revoked current SSO session envelope.", "#/components/schemas/SSOSessionEnvelope") + case "createSSOProvider": + operation.Description = "Records tenant SSO provider metadata. Optional static JWKS public keys and SAML signing certificates can be supplied for local token/assertion verification without live provider calls." + operation.RequestBody = jsonRequest("SSO provider creation request.", "#/components/schemas/CreateSSOProviderRequest") + operation.Responses[http.StatusCreated] = jsonResponse("Created SSO provider envelope.", "#/components/schemas/SSOProviderEnvelope") + case "updateSSOProviderTrustMaterial": + operation.Description = "Rotates tenant SSO provider public trust material for local OIDC ID-token or SAML assertion verification without storing provider secrets." + operation.Parameters = append(operation.Parameters, pathParam("id", "SSO provider id.")) + operation.RequestBody = jsonRequest("SSO provider trust material update request.", "#/components/schemas/UpdateSSOProviderTrustMaterialRequest") + operation.Responses[http.StatusOK] = jsonResponse("Updated SSO provider envelope.", "#/components/schemas/SSOProviderEnvelope") + case "refreshSSOProviderOIDCTrustMaterial": + operation.Description = "Fetches the OIDC discovery document and public JWKS for the tenant provider issuer, then stores refreshed public trust material. This does not authenticate users, store provider secrets, or synchronize groups." + operation.Parameters = append(operation.Parameters, pathParam("id", "SSO provider id.")) + operation.RequestBody = jsonRequest("Empty JSON object.", "#/components/schemas/EmptyObject") + operation.Responses[http.StatusOK] = jsonResponse("Refreshed SSO provider envelope.", "#/components/schemas/SSOProviderEnvelope") + case "verifyProviderIdentity": + operation.Description = "Verifies stored provider identity metadata and, when supplied, locally verifies OIDC ID-token or SAML assertion issuer, audience, subject, time bounds, and signature against configured tenant trust material." + operation.RequestBody = jsonRequest("Provider identity verification request.", "#/components/schemas/VerifyProviderIdentityRequest") + operation.Responses[http.StatusCreated] = jsonResponse("Provider verification envelope.", "#/components/schemas/ProviderVerificationEnvelope") + case "linkSSOIdentity": + operation.Description = "Links a verified provider subject to a tenant-scoped human user." + operation.RequestBody = jsonRequest("SSO identity link request.", "#/components/schemas/LinkSSOIdentityRequest") + operation.Responses[http.StatusCreated] = jsonResponse("Created SSO identity link envelope.", "#/components/schemas/UserIdentityLinkEnvelope") + case "createAPIKey": + operation.Description = "Creates a tenant-scoped API key and returns the secret exactly once. Stored records expose only non-secret key metadata." + operation.RequestBody = jsonRequest("API key creation request.", "#/components/schemas/CreateAPIKeyRequest") + operation.Responses[http.StatusCreated] = jsonResponse("Created API key and one-time secret envelope.", "#/components/schemas/APIKeyCreateEnvelope") + case "listAPIKeys": + operation.Description = "Lists tenant-scoped API key metadata without key hashes or one-time secrets." + operation.Responses[http.StatusOK] = jsonResponse("API key list envelope.", "#/components/schemas/APIKeyListEnvelope") + case "createCollector": + operation.Description = "Creates a tenant-scoped collector identity, binds a scoped API key, and returns the collector key secret exactly once." + operation.RequestBody = jsonRequest("Collector creation request.", "#/components/schemas/CreateCollectorRequest") + operation.Responses[http.StatusCreated] = jsonResponse("Created collector and one-time key secret envelope.", "#/components/schemas/CollectorCreateEnvelope") + case "listCollectors": + operation.Description = "Lists tenant-scoped collector metadata without API key hashes or one-time secrets." + operation.Responses[http.StatusOK] = jsonResponse("Collector list envelope.", "#/components/schemas/CollectorListEnvelope") + case "createControlFramework": + operation.Description = "Creates a tenant-scoped versioned control framework." + operation.RequestBody = jsonRequest("Control framework creation request.", "#/components/schemas/CreateControlFrameworkRequest") + operation.Responses[http.StatusCreated] = jsonResponse("Created control framework envelope.", "#/components/schemas/ControlFrameworkEnvelope") + case "listControlFrameworks": + operation.Description = "Lists tenant-scoped control frameworks." + operation.Responses[http.StatusOK] = jsonResponse("Control framework list envelope.", "#/components/schemas/ControlFrameworkListEnvelope") + case "createSecurityControl": + operation.Description = "Creates a framework-owned security control with deterministic evidence requirements." + operation.RequestBody = jsonRequest("Security control creation request.", "#/components/schemas/CreateSecurityControlRequest") + operation.Responses[http.StatusCreated] = jsonResponse("Created security control envelope.", "#/components/schemas/SecurityControlEnvelope") + case "getSecurityControl": + operation.Description = "Returns a tenant-scoped security control by id." + operation.Parameters = append(operation.Parameters, pathParam("id", "Security control id.")) + operation.Responses[http.StatusOK] = jsonResponse("Security control envelope.", "#/components/schemas/SecurityControlEnvelope") + case "linkControlEvidence": + operation.Description = "Creates an append-only link between a security control and tenant-scoped evidence or related release resource." + operation.Parameters = append(operation.Parameters, pathParam("id", "Security control id.")) + operation.RequestBody = jsonRequest("Control evidence link request.", "#/components/schemas/LinkControlEvidenceRequest") + operation.Responses[http.StatusCreated] = jsonResponse("Created control evidence link envelope.", "#/components/schemas/ControlEvidenceEnvelope") + case "listControlEvidence": + operation.Description = "Lists tenant-scoped control evidence links with optional control, product, and release filters." + operation.Parameters = append(operation.Parameters, + queryParam("control_id", "Filter by security control id.", "string"), + queryParam("product_id", "Filter by product id.", "string"), + queryParam("release_id", "Filter by release id.", "string"), + ) + operation.Responses[http.StatusOK] = jsonResponse("Control evidence list envelope.", "#/components/schemas/ControlEvidenceListEnvelope") + case "createProduct": + operation.Description = "Creates a tenant-scoped product. Product slugs must be unique per tenant." + operation.RequestBody = jsonRequest("Product creation request.", "#/components/schemas/CreateProductRequest") + operation.Responses[http.StatusCreated] = jsonResponse("Created product envelope.", "#/components/schemas/ProductEnvelope") + case "listProducts": + operation.Description = "Lists tenant-scoped products visible to the authenticated actor." + operation.Responses[http.StatusOK] = jsonResponse("Product list envelope.", "#/components/schemas/ProductListEnvelope") + case "getProduct": + operation.Description = "Returns a tenant-scoped product by id." + operation.Parameters = append(operation.Parameters, pathParam("id", "Product id.")) + operation.Responses[http.StatusOK] = jsonResponse("Product envelope.", "#/components/schemas/ProductEnvelope") + case "createProject": + operation.Description = "Creates a tenant-scoped project under a product." + operation.RequestBody = jsonRequest("Project creation request.", "#/components/schemas/CreateProjectRequest") + operation.Responses[http.StatusCreated] = jsonResponse("Created project envelope.", "#/components/schemas/ProjectEnvelope") + case "getProject": + operation.Description = "Returns a tenant-scoped project by id." + operation.Parameters = append(operation.Parameters, pathParam("id", "Project id.")) + operation.Responses[http.StatusOK] = jsonResponse("Project envelope.", "#/components/schemas/ProjectEnvelope") + case "createRelease": + operation.Description = "Creates an append-only release record under a product and optional project." + operation.RequestBody = jsonRequest("Release creation request.", "#/components/schemas/CreateReleaseRequest") + addJSONRequestExamples(operation.RequestBody, map[string]any{ + "release-candidate": specs.Example{ + Summary: "Create a release for evidence collection", + Value: map[string]any{ + "product_id": "prod_20260527120000", + "project_id": "proj_20260527120000", + "version": "1.0.0-rc.1", + }, + }, + }) + operation.Responses[http.StatusCreated] = jsonResponse("Created release envelope.", "#/components/schemas/ReleaseEnvelope") + addJSONResponseExamples(&operation, http.StatusCreated, map[string]any{ + "created-release": specs.Example{ + Summary: "Created release response", + Value: map[string]any{ + "data": map[string]any{ + "id": "rel_20260527120000", + "tenant_id": "ten_20260527120000", + "product_id": "prod_20260527120000", + "project_id": "proj_20260527120000", + "version": "1.0.0-rc.1", + "status": "draft", + "schema_version": "release.v1.0.0", + "created_at": "2026-05-27T12:00:00Z", + }, + "meta": map[string]any{"api_version": "v1"}, + }, + }, + }) + case "getRelease": + operation.Description = "Returns a tenant-scoped release by id." + operation.Parameters = append(operation.Parameters, pathParam("id", "Release id.")) + operation.Responses[http.StatusOK] = jsonResponse("Release envelope.", "#/components/schemas/ReleaseEnvelope") + case "startReleaseEvidenceFlow": + operation.Description = "Returns a high-level release evidence workflow plan, current evidence counts, required scopes, assumptions, and limitations. This read-only convenience operation does not create evidence." + operation.Parameters = append(operation.Parameters, pathParam("id", "Release id.")) + delete(operation.Responses, http.StatusCreated) + operation.Responses[http.StatusOK] = jsonResponse("Release evidence flow envelope.", "#/components/schemas/ReleaseEvidenceFlowEnvelope") + case "releaseSecuritySummary": + operation.Description = "Returns a tenant-scoped release security summary for review surfaces without raw evidence payload bytes." + operation.Parameters = append(operation.Parameters, pathParam("id", "Release id.")) + operation.Responses[http.StatusOK] = jsonResponse("Release security summary envelope.", "#/components/schemas/ReleaseSecuritySummaryEnvelope") + case "freezeRelease": + operation.Description = "Freezes a release as an append-only transition." + operation.Parameters = append(operation.Parameters, pathParam("id", "Release id.")) + operation.RequestBody = jsonRequest("Empty JSON object.", "#/components/schemas/EmptyObject") + operation.Responses[http.StatusOK] = jsonResponse("Frozen release envelope.", "#/components/schemas/ReleaseEnvelope") + case "approveRelease": + operation.Description = "Approves a release as an append-only transition." + operation.Parameters = append(operation.Parameters, pathParam("id", "Release id.")) + operation.RequestBody = jsonRequest("Empty JSON object.", "#/components/schemas/EmptyObject") + operation.Responses[http.StatusOK] = jsonResponse("Approved release envelope.", "#/components/schemas/ReleaseEnvelope") + case "registerArtifact": + operation.Description = "Registers an artifact digest for release evidence and later build/attestation matching." + operation.RequestBody = jsonRequest("Artifact registration request.", "#/components/schemas/RegisterArtifactRequest") + operation.Responses[http.StatusCreated] = jsonResponse("Registered artifact envelope.", "#/components/schemas/ArtifactEnvelope") + case "getArtifact": + operation.Description = "Returns a tenant-scoped artifact by id." + operation.Parameters = append(operation.Parameters, pathParam("id", "Artifact id.")) + operation.Responses[http.StatusOK] = jsonResponse("Artifact envelope.", "#/components/schemas/ArtifactEnvelope") + case "createBuild": + operation.Description = "Records an immutable CI build run. Collector identity is derived from the authenticated key when present." + operation.RequestBody = jsonRequest("Build run creation request.", "#/components/schemas/CreateBuildRequest") + operation.Responses[http.StatusCreated] = jsonResponse("Created build run envelope.", "#/components/schemas/BuildRunEnvelope") + case "getBuild": + operation.Description = "Returns a tenant-scoped build run by id." + operation.Parameters = append(operation.Parameters, pathParam("id", "Build run id.")) + operation.Responses[http.StatusOK] = jsonResponse("Build run envelope.", "#/components/schemas/BuildRunEnvelope") + case "uploadGitHubSourceSnapshot": + operation.Description = "Uploads a strict GitHub source snapshot, hashes commit messages, and stores repository, commit, branch, and pull-request evidence records." + operation.RequestBody = jsonRequest("GitHub source snapshot upload request.", "#/components/schemas/SourceSnapshotRequest") + operation.Responses[http.StatusCreated] = jsonResponse("Created source snapshot resources envelope.", "#/components/schemas/SourceSnapshotEnvelope") + case "uploadGitLabSourceSnapshot": + operation.Description = "Uploads a strict GitLab source snapshot, hashes commit messages, and stores repository, commit, branch, and pull-request evidence records." + operation.RequestBody = jsonRequest("GitLab source snapshot upload request.", "#/components/schemas/SourceSnapshotRequest") + operation.Responses[http.StatusCreated] = jsonResponse("Created source snapshot resources envelope.", "#/components/schemas/SourceSnapshotEnvelope") + case "uploadSBOM": + operation.Description = "Uploads a CycloneDX SBOM payload, stores raw bytes in object storage, and records normalized SBOM metadata." + operation.RequestBody = jsonRequest("CycloneDX SBOM upload request.", "#/components/schemas/EvidenceUploadRequest") + addJSONRequestExamples(operation.RequestBody, map[string]any{ + "cyclonedx-release-sbom": specs.Example{ + Summary: "Upload a CycloneDX SBOM linked to the release artifact", + Value: cyclonedxSBOMUploadExample(), + }, + }) + operation.Responses[http.StatusCreated] = jsonResponse("Created SBOM envelope.", "#/components/schemas/SBOMEnvelope") + case "getSBOM": + operation.Description = "Returns a tenant-scoped SBOM metadata record by id." + operation.Parameters = append(operation.Parameters, pathParam("id", "SBOM id.")) + operation.Responses[http.StatusOK] = jsonResponse("SBOM envelope.", "#/components/schemas/SBOMEnvelope") + case "uploadVEX": + operation.Description = "Uploads VEX payload bytes, stores raw evidence in object storage, and records normalized VEX metadata and decisions where applicable." + operation.RequestBody = jsonRequest("OpenVEX upload request.", "#/components/schemas/EvidenceUploadRequest") + operation.RequestBody.Content["application/json"] = specs.MediaType{ + SchemaRef: "#/components/schemas/EvidenceUploadRequest", + Examples: map[string]any{ + "openvex-fixed-decision": specs.Example{ + Summary: "Imported OpenVEX fixed decision", + Value: openVEXUploadExample(), + }, + }, + } + operation.Responses[http.StatusCreated] = jsonResponse("Created VEX document envelope.", "#/components/schemas/VEXDocumentEnvelope") + case "previewVEXImport": + operation.Description = "Validates an OpenVEX payload and returns advisory mapping counts without storing raw payloads, creating evidence, creating decisions, or enqueueing parser jobs." + operation.RequestBody = jsonRequest("OpenVEX import preview request.", "#/components/schemas/EvidenceUploadRequest") + operation.Responses[http.StatusOK] = jsonResponse("Advisory VEX import preview envelope.", "#/components/schemas/VEXImportPreviewEnvelope") + case "uploadCycloneDXVEX": + operation.Description = "Uploads VEX payload bytes, stores raw evidence in object storage, and records normalized VEX metadata and decisions where applicable." + operation.RequestBody = jsonRequest("CycloneDX VEX upload request.", "#/components/schemas/EvidenceUploadRequest") + operation.Responses[http.StatusCreated] = jsonResponse("Created VEX document envelope.", "#/components/schemas/VEXDocumentEnvelope") + case "previewCycloneDXVEXImport": + operation.Description = "Validates a CycloneDX VEX payload and returns advisory mapping counts without storing raw payloads, creating evidence, creating decisions, or enqueueing parser jobs." + operation.RequestBody = jsonRequest("CycloneDX VEX import preview request.", "#/components/schemas/EvidenceUploadRequest") + operation.Responses[http.StatusOK] = jsonResponse("Advisory VEX import preview envelope.", "#/components/schemas/VEXImportPreviewEnvelope") + case "getVEX": + operation.Description = "Returns a tenant-scoped VEX document metadata record by id." + operation.Parameters = append(operation.Parameters, pathParam("id", "VEX document id.")) + operation.Responses[http.StatusOK] = jsonResponse("VEX document envelope.", "#/components/schemas/VEXDocumentEnvelope") + case "getVEXImportReport": + operation.Description = "Returns the persisted parser report for a tenant-scoped VEX import, including counts, warnings, and mapping failures without raw payload bytes." + operation.Parameters = append(operation.Parameters, pathParam("id", "VEX document id.")) + operation.Responses[http.StatusOK] = jsonResponse("VEX import report envelope.", "#/components/schemas/VEXImportReportEnvelope") + case "uploadVulnerabilityScan": + operation.Description = "Uploads a generic vulnerability scan JSON payload and records normalized findings." + operation.RequestBody = jsonRequest("Vulnerability scan upload payload.", "#/components/schemas/UploadVulnerabilityScanRequest") + addJSONRequestExamples(operation.RequestBody, map[string]any{ + "generic-critical-finding": specs.Example{ + Summary: "Upload a generic scanner finding for release triage", + Value: vulnerabilityScanUploadExample(), + }, + }) + operation.Responses[http.StatusCreated] = jsonResponse("Created vulnerability scan envelope.", "#/components/schemas/VulnerabilityScanEnvelope") + case "getVulnerabilityScan": + operation.Description = "Returns a tenant-scoped vulnerability scan by id." + operation.Parameters = append(operation.Parameters, pathParam("id", "Vulnerability scan id.")) + operation.Responses[http.StatusOK] = jsonResponse("Vulnerability scan envelope.", "#/components/schemas/VulnerabilityScanEnvelope") + case "createEvidence": + operation.Description = "Creates immutable evidence metadata and optional raw payload evidence. Evidence core fields are append-only after creation." + operation.RequestBody = jsonRequest("Evidence creation request.", "#/components/schemas/CreateEvidenceRequest") + operation.Responses[http.StatusCreated] = jsonResponse("Created evidence item envelope.", "#/components/schemas/EvidenceItemEnvelope") + case "listEvidence": + operation.Description = "Lists tenant-scoped evidence by optional release and evidence type filters." + operation.Parameters = append(operation.Parameters, + queryParam("release_id", "Filter by release id.", "string"), + queryParam("type", "Filter by evidence type.", "string"), + ) + operation.Responses[http.StatusOK] = jsonResponse("Evidence item list envelope.", "#/components/schemas/EvidenceItemListEnvelope") + case "searchEvidence": + operation.Description = "Searches tenant-scoped evidence with deterministic filters and cursor-style pagination." + operation.Parameters = append(operation.Parameters, + queryParam("product_id", "Filter by product id.", "string"), + queryParam("project_id", "Filter by project id.", "string"), + queryParam("release_id", "Filter by release id.", "string"), + queryParam("type", "Filter by evidence type.", "string"), + queryParam("source", "Filter by evidence source.", "string"), + queryParam("tag", "Filter by a single evidence tag.", "string"), + queryParam("cursor", "Opaque pagination cursor.", "string"), + queryParam("limit", "Maximum returned records.", "integer"), + ) + operation.Responses[http.StatusOK] = jsonResponse("Evidence search result envelope.", "#/components/schemas/EvidenceSearchEnvelope") + case "getEvidence": + operation.Description = "Returns a tenant-scoped immutable evidence item by id." + operation.Parameters = append(operation.Parameters, pathParam("id", "Evidence item id.")) + operation.Responses[http.StatusOK] = jsonResponse("Evidence item envelope.", "#/components/schemas/EvidenceItemEnvelope") + case "createGraphSnapshot": + operation.Description = "Creates a deterministic product/release evidence adjacency snapshot from stored tenant-scoped evidence records." + operation.RequestBody = jsonRequest("Evidence graph snapshot creation request.", "#/components/schemas/CreateGraphSnapshotRequest") + operation.Responses[http.StatusCreated] = jsonResponse("Created evidence graph snapshot envelope.", "#/components/schemas/EvidenceGraphSnapshotEnvelope") + case "listSBOMComponents": + operation.Description = "Lists tenant-scoped SBOM components by SBOM, release, artifact, name/version/PURL query, or exact PURL." + operation.Parameters = append(operation.Parameters, + queryParam("sbom_id", "Filter by SBOM id.", "string"), + queryParam("release_id", "Filter by release id.", "string"), + queryParam("artifact_id", "Filter by artifact id.", "string"), + queryParam("query", "Case-insensitive component name, version, or PURL search.", "string"), + queryParam("purl", "Exact package URL filter.", "string"), + queryParam("limit", "Maximum returned component records.", "integer"), + ) + operation.Responses[http.StatusOK] = jsonResponse("SBOM component result envelope.", "#/components/schemas/SBOMComponentRecordListEnvelope") + case "createIncident": + operation.Description = "Creates an append-only incident record linked to tenant-scoped product and optional release evidence." + operation.RequestBody = jsonRequest("Incident creation request.", "#/components/schemas/CreateIncidentRequest") + operation.Responses[http.StatusCreated] = jsonResponse("Created incident envelope.", "#/components/schemas/IncidentEnvelope") + case "recordIncidentTimeline": + operation.Description = "Appends an incident timeline event and optional evidence reference." + operation.Parameters = append(operation.Parameters, pathParam("id", "Incident id.")) + operation.RequestBody = jsonRequest("Incident timeline event request.", "#/components/schemas/RecordIncidentTimelineRequest") + operation.Responses[http.StatusCreated] = jsonResponse("Created incident timeline event envelope.", "#/components/schemas/IncidentTimelineEventEnvelope") + case "createIncidentWebhookReceiver": + operation.Description = "Creates an incident-scoped webhook receiver with an Ed25519 public key. The matching private key stays with the external incident tool." + operation.Parameters = append(operation.Parameters, pathParam("id", "Incident id.")) + operation.RequestBody = jsonRequest("Incident webhook receiver creation request.", "#/components/schemas/CreateIncidentWebhookReceiverRequest") + operation.Responses[http.StatusCreated] = jsonResponse("Created incident webhook receiver envelope.", "#/components/schemas/IncidentWebhookReceiverEnvelope") + case "receiveIncidentWebhook": + operation.Description = "Public signed webhook endpoint for incident timeline events. It verifies Ed25519 signature, event id replay, and timestamp before parsing payload fields." + operation.Parameters = append(operation.Parameters, + pathParam("receiver_id", "Incident webhook receiver id."), + headerParam("X-Evydence-Webhook-Event-ID", "Provider event id used for replay detection."), + headerParam("X-Evydence-Webhook-Timestamp", "RFC3339 timestamp included in the signed payload."), + headerParam("X-Evydence-Webhook-Signature", "ed25519= over timestamp, event id, and raw body."), + ) + operation.RequestBody = jsonRequest("Signed incident timeline event payload.", "#/components/schemas/SignedIncidentWebhookPayload") + operation.Security = nil + operation.Scopes = nil + operation.Extensions = nil + operation.Responses[http.StatusCreated] = jsonResponse("Accepted webhook event and timeline event envelope.", "#/components/schemas/IncidentWebhookDeliveryEnvelope") + case "createRemediationTask": + operation.Description = "Creates an incident or release remediation task linked to optional evidence." + operation.RequestBody = jsonRequest("Remediation task creation request.", "#/components/schemas/CreateRemediationTaskRequest") + operation.Responses[http.StatusCreated] = jsonResponse("Created remediation task envelope.", "#/components/schemas/RemediationTaskEnvelope") + case "incidentReport": + operation.Description = "Returns a deterministic incident package report with timeline, remediation tasks, linked evidence, assumptions, and limitations." + operation.Parameters = append(operation.Parameters, queryParam("incident_id", "Incident id.", "string")) + operation.Responses[http.StatusOK] = jsonResponse("Incident package report envelope.", "#/components/schemas/IncidentReportEnvelope") + case "createReleaseBundle": + operation.Description = "Creates an immutable signed release bundle for a release." + operation.RequestBody = jsonRequest("Release bundle creation request.", "#/components/schemas/CreateReleaseBundleRequest") + operation.Responses[http.StatusCreated] = jsonResponse("Created release bundle envelope.", "#/components/schemas/ReleaseBundleEnvelope") + case "getReleaseBundle": + operation.Description = "Returns a tenant-scoped immutable release bundle by id." + operation.Parameters = append(operation.Parameters, pathParam("id", "Release bundle id.")) + operation.Responses[http.StatusOK] = jsonResponse("Release bundle envelope.", "#/components/schemas/ReleaseBundleEnvelope") + case "getReleaseBundleManifest": + operation.Description = "Returns the deterministic release bundle manifest by bundle id." + operation.Parameters = append(operation.Parameters, pathParam("id", "Release bundle id.")) + operation.Responses[http.StatusOK] = jsonResponse("Release bundle manifest envelope.", "#/components/schemas/ReleaseBundleManifestEnvelope") + case "verifyReleaseBundle": + operation.Description = "Verifies a tenant-scoped release bundle and returns a deterministic verification result." + operation.Parameters = append(operation.Parameters, pathParam("id", "Release bundle id.")) + operation.Responses[http.StatusOK] = jsonResponse("Release bundle verification envelope.", "#/components/schemas/VerificationResultEnvelope") + case "verifyAuditChain": + operation.Description = "Verifies the tenant audit chain continuity and returns deterministic verification checks." + operation.Responses[http.StatusOK] = jsonResponse("Audit chain verification envelope.", "#/components/schemas/VerificationResultEnvelope") + case "verify": + operation.Description = "Verifies a supported tenant-scoped subject such as evidence, audit chain, release bundle, artifact signature, or related verification target." + operation.RequestBody = jsonRequest("Subject verification request.", "#/components/schemas/VerifySubjectRequest") + operation.Responses[http.StatusOK] = jsonResponse("Subject verification envelope.", "#/components/schemas/VerificationResultEnvelope") + case "listAuditLog": + operation.Description = "Lists tenant-scoped append-only audit-chain entries in reverse chronological order." + operation.Parameters = append(operation.Parameters, + queryParam("subject_type", "Filter by audited subject type.", "string"), + queryParam("subject_id", "Filter by audited subject id.", "string"), + queryParam("since", "Only include entries at or after this RFC3339 timestamp.", "string"), + queryParam("limit", "Maximum returned entries; defaults to 100 and caps at 500.", "integer"), + ) + operation.Responses[http.StatusOK] = jsonResponse("Audit-chain entry list envelope.", "#/components/schemas/AuditChainEntryListEnvelope") + case "generateBackupManifest": + operation.Description = "Generates a tenant-scoped backup manifest after an operator backup completes. The manifest excludes raw payload bytes and private key material." + operation.RequestBody = jsonRequest("Empty JSON object.", "#/components/schemas/EmptyObject") + operation.Responses[http.StatusCreated] = jsonResponse("Backup manifest envelope.", "#/components/schemas/BackupManifestEnvelope") + case "verifyBackupManifest": + operation.Description = "Verifies a tenant-scoped backup manifest and returns deterministic manifest verification checks." + operation.Parameters = append(operation.Parameters, pathParam("id", "Backup manifest id.")) + operation.Responses[http.StatusOK] = jsonResponse("Backup manifest verification envelope.", "#/components/schemas/VerificationResultEnvelope") + case "releaseReadinessReport": + operation.Description = "Returns a deterministic release-readiness report with gaps, assumptions, and limitations." + operation.Parameters = append(operation.Parameters, queryParam("release_id", "Release id.", "string")) + operation.Responses[http.StatusOK] = jsonResponse("Release readiness report envelope.", "#/components/schemas/ReadinessReportEnvelope") + addJSONResponseExamples(&operation, http.StatusOK, map[string]any{ + "failed-readiness-with-gap": specs.Example{ + Summary: "Readiness report with a vulnerability decision gap", + Value: releaseReadinessReportExample(), + }, + }) + case "missingEvidenceReport": + operation.Description = "Returns a deterministic missing-evidence report for a release with assumptions and limitations." + operation.Parameters = append(operation.Parameters, queryParam("release_id", "Release id.", "string")) + operation.Responses[http.StatusOK] = jsonResponse("Missing evidence report envelope.", "#/components/schemas/MissingEvidenceReportEnvelope") + case "evaluatePolicy": + operation.Description = "Evaluates built-in deterministic release policy checks for a release." + operation.RequestBody = jsonRequest("Policy evaluation request.", "#/components/schemas/EvaluatePolicyRequest") + operation.Responses[http.StatusCreated] = jsonResponse("Policy evaluation envelope.", "#/components/schemas/PolicyEvaluationEnvelope") + case "createVulnerabilityDecision": + operation.Description = "Creates an append-only vulnerability decision for a tenant-scoped scan finding." + operation.Parameters = append(operation.Parameters, pathParam("id", "Vulnerability finding id.")) + operation.RequestBody = jsonRequest("Vulnerability decision creation request.", "#/components/schemas/CreateVulnerabilityDecisionRequest") + addJSONRequestExamples(operation.RequestBody, map[string]any{ + "not-affected-decision": specs.Example{ + Summary: "Record a customer-visible not-affected decision", + Value: vulnerabilityDecisionExample(), + }, + }) + operation.Responses[http.StatusCreated] = jsonResponse("Created vulnerability decision envelope.", "#/components/schemas/VulnerabilityDecisionEnvelope") + case "listVulnerabilityDecisions": + operation.Description = "Lists append-only vulnerability decisions over time with tenant-scoped product, release, vulnerability, component, status, and active filters." + operation.Parameters = append(operation.Parameters, + queryParam("product_id", "Filter by product id.", "string"), + queryParam("release_id", "Filter by release id.", "string"), + queryParam("vulnerability", "Filter by vulnerability identifier.", "string"), + queryParam("component", "Filter by affected component.", "string"), + queryParam("status", "Filter by decision status.", "string"), + queryParam("active", "When true returns active decisions; when false returns superseded decisions.", "boolean"), + ) + operation.Responses[http.StatusOK] = jsonResponse("Vulnerability decision list envelope.", "#/components/schemas/VulnerabilityDecisionListEnvelope") + case "recordVulnerabilityWorkflow": + operation.Description = "Records an append-only vulnerability workflow event for a tenant-scoped finding." + operation.Parameters = append(operation.Parameters, pathParam("id", "Vulnerability finding id.")) + operation.RequestBody = jsonRequest("Vulnerability workflow event request.", "#/components/schemas/RecordVulnerabilityWorkflowRequest") + operation.Responses[http.StatusCreated] = jsonResponse("Created vulnerability workflow record envelope.", "#/components/schemas/VulnerabilityWorkflowRecordEnvelope") + case "createException": + operation.Description = "Creates a scoped, expiring release/finding/control exception that is inactive until approved." + operation.RequestBody = jsonRequest("Exception creation request.", "#/components/schemas/CreateExceptionRequest") + operation.Responses[http.StatusCreated] = jsonResponse("Created exception envelope.", "#/components/schemas/ExceptionEnvelope") + case "listExceptions": + operation.Description = "Lists tenant-scoped exceptions, optionally filtered by release." + operation.Parameters = append(operation.Parameters, queryParam("release_id", "Release id.", "string")) + operation.Responses[http.StatusOK] = jsonResponse("Exception list envelope.", "#/components/schemas/ExceptionListEnvelope") + case "approveException": + operation.Description = "Approves an unexpired exception as an audited append-only transition." + operation.Parameters = append(operation.Parameters, pathParam("id", "Exception id.")) + operation.RequestBody = jsonRequest("Empty JSON object.", "#/components/schemas/EmptyObject") + operation.Responses[http.StatusOK] = jsonResponse("Approved exception envelope.", "#/components/schemas/ExceptionEnvelope") + case "createCustomPolicy": + operation.Description = "Creates a deterministic custom policy definition for tenant-managed release checks." + operation.RequestBody = jsonRequest("Custom policy creation request.", "#/components/schemas/CreateCustomPolicyRequest") + operation.Responses[http.StatusCreated] = jsonResponse("Created custom policy envelope.", "#/components/schemas/CustomPolicyEnvelope") + case "evaluateCustomPolicy": + operation.Description = "Evaluates a tenant custom policy against a release and records the input hash." + operation.Parameters = append(operation.Parameters, pathParam("id", "Custom policy id.")) + operation.RequestBody = jsonRequest("Custom policy evaluation request.", "#/components/schemas/EvaluatePolicyRequest") + operation.Responses[http.StatusCreated] = jsonResponse("Custom policy evaluation envelope.", "#/components/schemas/CustomPolicyEvaluationEnvelope") + case "createWaiver": + operation.Description = "Creates a first-class scoped waiver for controls or policies. Approval is a separate audited transition." + operation.RequestBody = jsonRequest("Waiver creation request.", "#/components/schemas/CreateWaiverRequest") + operation.Responses[http.StatusCreated] = jsonResponse("Created waiver envelope.", "#/components/schemas/WaiverEnvelope") + case "approveWaiver": + operation.Description = "Approves an unexpired waiver as an audited transition." + operation.Parameters = append(operation.Parameters, pathParam("id", "Waiver id.")) + operation.RequestBody = jsonRequest("Empty JSON object.", "#/components/schemas/EmptyObject") + operation.Responses[http.StatusOK] = jsonResponse("Approved waiver envelope.", "#/components/schemas/WaiverEnvelope") + case "createApproval": + operation.Description = "Creates an immutable approval record for a release, waiver, package, or review subject." + operation.RequestBody = jsonRequest("Approval creation request.", "#/components/schemas/CreateApprovalRequest") + operation.Responses[http.StatusCreated] = jsonResponse("Created approval envelope.", "#/components/schemas/ApprovalRecordEnvelope") + case "uploadOpenAPIContract": + operation.Description = "Uploads an OpenAPI 3.1 contract, stores raw bytes as evidence, and records normalized operation metadata." + operation.RequestBody = jsonRequest("OpenAPI contract upload request.", "#/components/schemas/UploadOpenAPIContractRequest") + operation.Responses[http.StatusCreated] = jsonResponse("Created OpenAPI contract envelope.", "#/components/schemas/OpenAPIContractEnvelope") + case "getOpenAPIContract": + operation.Description = "Returns a tenant-scoped OpenAPI contract metadata record by id." + operation.Parameters = append(operation.Parameters, pathParam("id", "OpenAPI contract id.")) + operation.Responses[http.StatusOK] = jsonResponse("OpenAPI contract envelope.", "#/components/schemas/OpenAPIContractEnvelope") + case "createOpenAPIDiff": + operation.Description = "Creates a deterministic OpenAPI contract diff for release contract checks." + operation.RequestBody = jsonRequest("OpenAPI contract diff request.", "#/components/schemas/CreateOpenAPIDiffRequest") + operation.Responses[http.StatusCreated] = jsonResponse("Created OpenAPI contract diff envelope.", "#/components/schemas/ContractDiffEnvelope") + case "listSigningKeys": + operation.Description = "Lists tenant signing public-key metadata without private key material." + operation.Responses[http.StatusOK] = jsonResponse("Signing key list envelope.", "#/components/schemas/SigningKeyListEnvelope") + case "rotateSigningKey": + operation.Description = "Rotates the active tenant signing key and returns public-key metadata only." + operation.RequestBody = jsonRequest("Signing key rotation request.", "#/components/schemas/SigningKeyTransitionRequest") + operation.Responses[http.StatusCreated] = jsonResponse("Rotated signing key envelope.", "#/components/schemas/SigningKeyEnvelope") + case "revokeSigningKey": + operation.Description = "Revokes a tenant signing key as an audited lifecycle transition." + operation.Parameters = append(operation.Parameters, pathParam("id", "Signing key id.")) + operation.RequestBody = jsonRequest("Signing key revocation request.", "#/components/schemas/SigningKeyTransitionRequest") + operation.Responses[http.StatusOK] = jsonResponse("Revoked signing key envelope.", "#/components/schemas/SigningKeyEnvelope") + case "createSigningProvider": + operation.Description = "Creates signing provider metadata for external signing operations. Production private key material must not be supplied." + operation.RequestBody = jsonRequest("Signing provider creation request.", "#/components/schemas/CreateSigningProviderRequest") + operation.Responses[http.StatusCreated] = jsonResponse("Created signing provider envelope.", "#/components/schemas/SigningProviderEnvelope") + case "createSigningOperation": + operation.Description = "Records an external signing operation receipt and checks payload/signature metadata without logging secrets. When the API is configured with a signing executor, external_signature may be omitted and the executor signs the payload hash." + operation.RequestBody = jsonRequest("Signing operation creation request.", "#/components/schemas/CreateSigningOperationRequest") + operation.Responses[http.StatusCreated] = jsonResponse("Created signing operation envelope.", "#/components/schemas/SigningOperationEnvelope") + case "createArtifactSignature": + operation.Description = "Records detached artifact signature evidence and optional raw signature payload metadata." + operation.RequestBody = jsonRequest("Artifact signature creation request.", "#/components/schemas/CreateArtifactSignatureRequest") + operation.Responses[http.StatusCreated] = jsonResponse("Created artifact signature envelope.", "#/components/schemas/ArtifactSignatureEnvelope") + case "getArtifactSignature": + operation.Description = "Returns tenant-scoped artifact signature metadata by id." + operation.Parameters = append(operation.Parameters, pathParam("id", "Artifact signature id.")) + operation.Responses[http.StatusOK] = jsonResponse("Artifact signature envelope.", "#/components/schemas/ArtifactSignatureEnvelope") + case "verifyCosignSignature": + operation.Description = "Records deterministic cosign-style verification metadata for an artifact signature without implying online transparency trust." + operation.Parameters = append(operation.Parameters, pathParam("id", "Artifact signature id.")) + operation.RequestBody = jsonRequest("Cosign verification metadata request.", "#/components/schemas/VerifyCosignSignatureRequest") + operation.Responses[http.StatusOK] = jsonResponse("Cosign verification envelope.", "#/components/schemas/CosignVerificationEnvelope") + case "uploadBuildAttestation": + operation.Description = "Uploads a DSSE/in-toto build attestation for a tenant-scoped build and stores raw bytes in object storage." + operation.Parameters = append(operation.Parameters, pathParam("id", "Build run id.")) + operation.RequestBody = jsonRequest("DSSE envelope.", "#/components/schemas/DSSEEnvelope") + operation.Responses[http.StatusCreated] = jsonResponse("Created build attestation envelope.", "#/components/schemas/BuildAttestationEnvelope") + case "verifyBuildAttestationSignature": + operation.Description = "Verifies a build attestation signature against configured tenant DSSE trust roots." + operation.Parameters = append(operation.Parameters, pathParam("id", "Build attestation id.")) + operation.RequestBody = jsonRequest("Empty JSON object.", "#/components/schemas/EmptyObject") + operation.Responses[http.StatusOK] = jsonResponse("Build attestation verification envelope.", "#/components/schemas/VerificationResultEnvelope") + case "createDSSETrustRoot": + operation.Description = "Creates a tenant-scoped DSSE trust root using public verification key material only." + operation.RequestBody = jsonRequest("DSSE trust-root creation request.", "#/components/schemas/CreateDSSETrustRootRequest") + operation.Responses[http.StatusCreated] = jsonResponse("Created DSSE trust root envelope.", "#/components/schemas/DSSETrustRootEnvelope") + case "createReleaseCandidate": + operation.Description = "Creates an immutable release-candidate snapshot of selected release evidence references." + operation.RequestBody = jsonRequest("Release candidate creation request.", "#/components/schemas/CreateReleaseCandidateRequest") + operation.Responses[http.StatusCreated] = jsonResponse("Created release candidate envelope.", "#/components/schemas/ReleaseCandidateEnvelope") + case "listReleaseCandidates": + operation.Description = "Lists tenant-scoped release candidates, optionally filtered by release." + operation.Parameters = append(operation.Parameters, queryParam("release_id", "Release id.", "string")) + operation.Responses[http.StatusOK] = jsonResponse("Release candidate list envelope.", "#/components/schemas/ReleaseCandidateListEnvelope") + case "getReleaseCandidate": + operation.Description = "Returns a tenant-scoped release candidate by id." + operation.Parameters = append(operation.Parameters, pathParam("id", "Release candidate id.")) + operation.Responses[http.StatusOK] = jsonResponse("Release candidate envelope.", "#/components/schemas/ReleaseCandidateEnvelope") + case "promoteReleaseCandidate", "rejectReleaseCandidate": + operation.Description = "Records a release-candidate lifecycle transition without mutating the original snapshot." + operation.Parameters = append(operation.Parameters, pathParam("id", "Release candidate id.")) + operation.RequestBody = jsonRequest("Release candidate transition request.", "#/components/schemas/ReleaseCandidateTransitionRequest") + operation.Responses[http.StatusOK] = jsonResponse("Transitioned release candidate envelope.", "#/components/schemas/ReleaseCandidateEnvelope") + case "supersedeEvidence": + operation.Description = "Supersedes immutable evidence by linking it to replacement evidence and appending lifecycle metadata." + operation.Parameters = append(operation.Parameters, pathParam("id", "Evidence item id.")) + operation.RequestBody = jsonRequest("Evidence supersession request.", "#/components/schemas/SupersedeEvidenceRequest") + operation.Responses[http.StatusCreated] = jsonResponse("Superseded evidence item envelope.", "#/components/schemas/EvidenceItemEnvelope") + case "linkEvidence": + operation.Description = "Creates an append-only relationship from evidence to another tenant-scoped subject." + operation.Parameters = append(operation.Parameters, pathParam("id", "Evidence item id.")) + operation.RequestBody = jsonRequest("Evidence link request.", "#/components/schemas/LinkEvidenceRequest") + operation.Responses[http.StatusCreated] = jsonResponse("Linked evidence item envelope.", "#/components/schemas/EvidenceItemEnvelope") + case "recordEvidenceLifecycleEvent": + operation.Description = "Appends an evidence lifecycle event such as amendment, redaction marker, tombstone, or retention marker." + operation.Parameters = append(operation.Parameters, pathParam("id", "Evidence item id.")) + operation.RequestBody = jsonRequest("Evidence lifecycle event request.", "#/components/schemas/RecordEvidenceLifecycleEventRequest") + operation.Responses[http.StatusCreated] = jsonResponse("Created evidence lifecycle event envelope.", "#/components/schemas/EvidenceLifecycleEventEnvelope") + case "listEvidenceLifecycleEvents": + operation.Description = "Lists append-only lifecycle events for a tenant-scoped evidence item." + operation.Parameters = append(operation.Parameters, pathParam("id", "Evidence item id.")) + operation.Responses[http.StatusOK] = jsonResponse("Evidence lifecycle event list envelope.", "#/components/schemas/EvidenceLifecycleEventListEnvelope") + case "createSourceRepository": + operation.Description = "Creates a tenant-scoped source repository record." + operation.RequestBody = jsonRequest("Source repository creation request.", "#/components/schemas/CreateSourceRepositoryRequest") + operation.Responses[http.StatusCreated] = jsonResponse("Created source repository envelope.", "#/components/schemas/SourceRepositoryEnvelope") + case "listSourceRepositories": + operation.Description = "Lists tenant-scoped source repositories, optionally filtered by project." + operation.Parameters = append(operation.Parameters, queryParam("project_id", "Project id.", "string")) + operation.Responses[http.StatusOK] = jsonResponse("Source repository list envelope.", "#/components/schemas/SourceRepositoryListEnvelope") + case "recordSourceCommit": + operation.Description = "Records immutable source commit metadata and stores only a hash of the commit message." + operation.RequestBody = jsonRequest("Source commit creation request.", "#/components/schemas/RecordSourceCommitRequest") + operation.Responses[http.StatusCreated] = jsonResponse("Created source commit envelope.", "#/components/schemas/SourceCommitEnvelope") + case "upsertSourceBranch": + operation.Description = "Records or updates source branch metadata and protected-branch snapshot hash." + operation.RequestBody = jsonRequest("Source branch upsert request.", "#/components/schemas/UpsertSourceBranchRequest") + operation.Responses[http.StatusCreated] = jsonResponse("Source branch envelope.", "#/components/schemas/SourceBranchEnvelope") + case "recordPullRequest": + operation.Description = "Records pull-request review metadata linked to source repository evidence." + operation.RequestBody = jsonRequest("Pull request record request.", "#/components/schemas/RecordPullRequestRequest") + operation.Responses[http.StatusCreated] = jsonResponse("Created pull request envelope.", "#/components/schemas/PullRequestEnvelope") + case "createDeploymentEnvironment": + operation.Description = "Creates a tenant-scoped deployment environment for release deployment evidence." + operation.RequestBody = jsonRequest("Deployment environment creation request.", "#/components/schemas/CreateDeploymentEnvironmentRequest") + operation.Responses[http.StatusCreated] = jsonResponse("Created deployment environment envelope.", "#/components/schemas/DeploymentEnvironmentEnvelope") + case "listDeploymentEnvironments": + operation.Description = "Lists tenant-scoped deployment environments, optionally filtered by product." + operation.Parameters = append(operation.Parameters, queryParam("product_id", "Product id.", "string")) + operation.Responses[http.StatusOK] = jsonResponse("Deployment environment list envelope.", "#/components/schemas/DeploymentEnvironmentListEnvelope") + case "recordDeployment": + operation.Description = "Records append-only deployment evidence for a release/environment/artifact set." + operation.RequestBody = jsonRequest("Deployment event creation request.", "#/components/schemas/RecordDeploymentRequest") + operation.Responses[http.StatusCreated] = jsonResponse("Created deployment event envelope.", "#/components/schemas/DeploymentEventEnvelope") + case "listDeployments": + operation.Description = "Lists tenant-scoped deployment events by optional release and environment filters." + operation.Parameters = append(operation.Parameters, + queryParam("release_id", "Release id.", "string"), + queryParam("environment_id", "Deployment environment id.", "string"), + ) + operation.Responses[http.StatusOK] = jsonResponse("Deployment event list envelope.", "#/components/schemas/DeploymentEventListEnvelope") + case "getDeployment": + operation.Description = "Returns a tenant-scoped deployment event by id." + operation.Parameters = append(operation.Parameters, pathParam("id", "Deployment event id.")) + operation.Responses[http.StatusOK] = jsonResponse("Deployment event envelope.", "#/components/schemas/DeploymentEventEnvelope") + case "recordCollectorRelease": + operation.Description = "Records collector release supply-chain evidence for a tenant-scoped collector." + operation.Parameters = append(operation.Parameters, pathParam("id", "Collector id.")) + operation.RequestBody = jsonRequest("Collector release record request.", "#/components/schemas/RecordCollectorReleaseRequest") + operation.Responses[http.StatusCreated] = jsonResponse("Created collector release envelope.", "#/components/schemas/CollectorReleaseEnvelope") + case "collectorHealthReport": + operation.Description = "Returns collector supply-chain health from recorded tenant evidence, assumptions, and limitations." + operation.Parameters = append(operation.Parameters, pathParam("id", "Collector id.")) + operation.Responses[http.StatusOK] = jsonResponse("Collector health report envelope.", "#/components/schemas/CollectorHealthReportEnvelope") + case "createCommercialCollector": + operation.Description = "Creates tenant-scoped commercial collector metadata without installing external code." + operation.RequestBody = jsonRequest("Commercial collector definition request.", "#/components/schemas/CreateCommercialCollectorRequest") + operation.Responses[http.StatusCreated] = jsonResponse("Created commercial collector definition envelope.", "#/components/schemas/CommercialCollectorDefinitionEnvelope") + case "listCommercialCollectors": + operation.Description = "Lists tenant-scoped commercial collector definitions." + operation.Responses[http.StatusOK] = jsonResponse("Commercial collector definition list envelope.", "#/components/schemas/CommercialCollectorDefinitionListEnvelope") + case "createMarketplaceCollector": + operation.Description = "Creates tenant-scoped marketplace collector package metadata and evidence references." + operation.RequestBody = jsonRequest("Marketplace collector creation request.", "#/components/schemas/CreateMarketplaceCollectorRequest") + operation.Responses[http.StatusCreated] = jsonResponse("Created marketplace collector envelope.", "#/components/schemas/MarketplaceCollectorEnvelope") + case "listMarketplaceCollectors": + operation.Description = "Lists tenant-scoped marketplace collector package metadata." + operation.Responses[http.StatusOK] = jsonResponse("Marketplace collector list envelope.", "#/components/schemas/MarketplaceCollectorListEnvelope") + case "marketplaceCollectorHealth": + operation.Description = "Returns marketplace collector package health from recorded signature, SBOM, and scan evidence." + operation.Parameters = append(operation.Parameters, pathParam("id", "Marketplace collector id.")) + operation.Responses[http.StatusOK] = jsonResponse("Marketplace collector health report envelope.", "#/components/schemas/MarketplaceCollectorHealthReportEnvelope") + case "listControlFrameworkTemplatePacks": + operation.Description = "Lists built-in control framework template packs available for explicit tenant installation." + operation.Responses[http.StatusOK] = jsonResponse("Control framework template pack list envelope.", "#/components/schemas/ControlFrameworkTemplatePackListEnvelope") + case "installControlFrameworkTemplatePack": + operation.Description = "Installs a named control framework template pack into the tenant as ordinary framework/control records." + operation.Parameters = append(operation.Parameters, pathParam("slug", "Control framework template pack slug.")) + operation.RequestBody = jsonRequest("Empty JSON object.", "#/components/schemas/EmptyObject") + operation.Responses[http.StatusCreated] = jsonResponse("Installed control framework envelope.", "#/components/schemas/ControlFrameworkEnvelope") + case "registerContainerImage": + operation.Description = "Registers OCI/container image metadata and digest evidence linked to an optional artifact." + operation.RequestBody = jsonRequest("Container image registration request.", "#/components/schemas/RegisterContainerImageRequest") + operation.Responses[http.StatusCreated] = jsonResponse("Registered container image envelope.", "#/components/schemas/ContainerImageEnvelope") + case "uploadSecurityScan", "uploadAPISecurityScan": + operation.Description = "Uploads SAST, DAST, secret, license, or API security scan metadata and raw JSON payload evidence without exposing raw payload bytes in responses." + operation.RequestBody = jsonRequest("Security scan upload request.", "#/components/schemas/UploadSecurityScanRequest") + operation.Responses[http.StatusCreated] = jsonResponse("Created security scan envelope.", "#/components/schemas/SecurityScanEnvelope") + case "uploadManualSecurityDocument": + operation.Description = "Uploads sensitive manual security evidence such as threat model, security review, or penetration-test report metadata and raw payload reference." + operation.RequestBody = jsonRequest("Manual security document upload request.", "#/components/schemas/UploadManualSecurityDocumentRequest") + operation.Responses[http.StatusCreated] = jsonResponse("Created manual security document envelope.", "#/components/schemas/ManualSecurityDocumentEnvelope") + case "uploadSPDXSBOM": + operation.Description = "Uploads an SPDX JSON SBOM payload, stores raw bytes as evidence, and records normalized SBOM metadata." + operation.RequestBody = jsonRequest("SPDX SBOM upload request.", "#/components/schemas/UploadSPDXSBOMRequest") + operation.Responses[http.StatusCreated] = jsonResponse("Created SBOM envelope.", "#/components/schemas/SBOMEnvelope") + case "createSBOMDiff": + operation.Description = "Creates a deterministic SBOM diff between two tenant-scoped SBOM records." + operation.RequestBody = jsonRequest("SBOM diff creation request.", "#/components/schemas/CreateSBOMDiffRequest") + operation.Responses[http.StatusCreated] = jsonResponse("Created SBOM diff envelope.", "#/components/schemas/SBOMDiffEnvelope") + case "vulnerabilityPostureReport": + operation.Description = "Returns a vulnerability posture report derived from stored scan, decision, VEX, exception, and workflow records." + operation.Parameters = append(operation.Parameters, queryParam("release_id", "Release id.", "string")) + operation.Responses[http.StatusOK] = jsonResponse("Vulnerability posture report envelope.", "#/components/schemas/VulnerabilityPostureReportEnvelope") + case "vulnerabilityDecisionSummaryReport": + operation.Description = "Returns customer-safe active vulnerability decision summaries for a release with assumptions and limitations. Raw payloads and internal notes are excluded." + operation.Parameters = append(operation.Parameters, queryParam("release_id", "Release id.", "string")) + operation.Responses[http.StatusOK] = jsonResponse("Vulnerability decision summary report envelope.", "#/components/schemas/VulnerabilityDecisionSummaryReportEnvelope") + case "generateAnomalyReport": + operation.Description = "Creates a deterministic anomaly report over existing tenant evidence and metrics with assumptions and limitations." + operation.RequestBody = jsonRequest("Anomaly report creation request.", "#/components/schemas/CreateAnomalyReportRequest") + operation.Responses[http.StatusCreated] = jsonResponse("Created anomaly report envelope.", "#/components/schemas/AnomalyReportEnvelope") + case "createMerkleBatch": + operation.Description = "Creates a Merkle batch over tenant audit-chain entries for checkpoint export or transparency anchoring." + operation.RequestBody = jsonRequest("Merkle batch creation request.", "#/components/schemas/CreateMerkleBatchRequest") + operation.Responses[http.StatusCreated] = jsonResponse("Created Merkle batch envelope.", "#/components/schemas/MerkleBatchEnvelope") + case "verifyMerkleBatch": + operation.Description = "Verifies a tenant-scoped Merkle batch root and leaf set against stored audit-chain entries." + operation.Parameters = append(operation.Parameters, pathParam("id", "Merkle batch id.")) + operation.Responses[http.StatusOK] = jsonResponse("Merkle batch verification envelope.", "#/components/schemas/VerificationResultEnvelope") + case "createTransparencyCheckpoint": + operation.Description = "Records an external transparency or timestamp checkpoint reference for a Merkle batch." + operation.RequestBody = jsonRequest("Transparency checkpoint creation request.", "#/components/schemas/CreateTransparencyCheckpointRequest") + operation.Responses[http.StatusCreated] = jsonResponse("Created transparency checkpoint envelope.", "#/components/schemas/TransparencyCheckpointEnvelope") + case "createPublicTransparencyLog": + operation.Description = "Creates tenant metadata for an optional public transparency log trust root." + operation.RequestBody = jsonRequest("Public transparency log creation request.", "#/components/schemas/CreatePublicTransparencyLogRequest") + operation.Responses[http.StatusCreated] = jsonResponse("Created public transparency log envelope.", "#/components/schemas/PublicTransparencyLogEnvelope") + case "publishPublicTransparencyLogEntry": + operation.Description = "Records publication metadata for a checkpoint submitted to a configured public transparency log." + operation.RequestBody = jsonRequest("Public transparency log entry publication request.", "#/components/schemas/PublishPublicTransparencyLogEntryRequest") + operation.Responses[http.StatusCreated] = jsonResponse("Created public transparency log entry envelope.", "#/components/schemas/PublicTransparencyLogEntryEnvelope") + case "verifyPublicTransparencyLogEntry": + operation.Description = "Verifies operator-supplied RFC6962-style public transparency inclusion proof material for a published entry." + operation.Parameters = append(operation.Parameters, pathParam("id", "Public transparency log entry id.")) + operation.RequestBody = jsonRequest("Public transparency log inclusion proof verification request.", "#/components/schemas/VerifyPublicTransparencyLogEntryRequest") + operation.Responses[http.StatusOK] = jsonResponse("Verified public transparency log entry envelope.", "#/components/schemas/PublicTransparencyLogEntryEnvelope") + case "fetchPublicTransparencyLogEntryProof": + operation.Description = "Fetches public transparency inclusion proof material from the configured log endpoint or transparency proof gateway and verifies it locally. Endpoint trust and provider semantics remain deployment responsibilities." + operation.Parameters = append(operation.Parameters, pathParam("id", "Public transparency log entry id.")) + operation.RequestBody = jsonRequest("Empty JSON object.", "#/components/schemas/EmptyObject") + operation.Responses[http.StatusOK] = jsonResponse("Fetched and verified public transparency log entry envelope.", "#/components/schemas/PublicTransparencyLogEntryEnvelope") + case "createObjectRetentionPolicy": + operation.Description = "Creates an object retention policy record for storage immutability verification." + operation.RequestBody = jsonRequest("Object retention policy creation request.", "#/components/schemas/CreateObjectRetentionPolicyRequest") + operation.Responses[http.StatusCreated] = jsonResponse("Created object retention policy envelope.", "#/components/schemas/ObjectRetentionPolicyEnvelope") + case "verifyObjectRetentionPolicy": + operation.Description = "Records verification metadata for a tenant object retention policy." + operation.Parameters = append(operation.Parameters, pathParam("id", "Object retention policy id.")) + operation.RequestBody = jsonRequest("Empty JSON object.", "#/components/schemas/EmptyObject") + operation.Responses[http.StatusOK] = jsonResponse("Verified object retention policy envelope.", "#/components/schemas/ObjectRetentionPolicyEnvelope") + case "signingCustodyReviewReport": + operation.Description = "Returns tenant signing-provider and object-lock verification metadata for deployment custody review. It is evidence metadata only, not legal compliance proof, certification, HSM custody proof, or a secure-deployment guarantee." + operation.Responses[http.StatusOK] = jsonResponse("Signing custody review report envelope.", "#/components/schemas/SigningCustodyReviewReportEnvelope") + case "createLegalHold": + operation.Description = "Creates an append-only legal-hold marker for a tenant-scoped retention subject." + operation.RequestBody = jsonRequest("Legal hold creation request.", "#/components/schemas/CreateLegalHoldRequest") + operation.Responses[http.StatusCreated] = jsonResponse("Created legal hold envelope.", "#/components/schemas/LegalHoldEnvelope") + case "createRetentionOverride": + operation.Description = "Creates an append-only retention override for a tenant-scoped retention subject." + operation.RequestBody = jsonRequest("Retention override creation request.", "#/components/schemas/CreateRetentionOverrideRequest") + operation.Responses[http.StatusCreated] = jsonResponse("Created retention override envelope.", "#/components/schemas/RetentionOverrideEnvelope") + case "retentionReport": + operation.Description = "Returns a retention report for tenant-scoped holds and overrides with storage verification limitations." + operation.Parameters = append(operation.Parameters, + queryParam("scope_type", "Optional retention scope type.", "string"), + queryParam("scope_id", "Optional retention scope id.", "string"), + ) + operation.Responses[http.StatusOK] = jsonResponse("Retention report envelope.", "#/components/schemas/RetentionReportEnvelope") + case "createSaaSEditionProfile": + operation.Description = "Creates a SaaS edition profile record for future hosted deployment planning; it is not a production readiness claim." + operation.RequestBody = jsonRequest("SaaS edition profile creation request.", "#/components/schemas/CreateSaaSEditionProfileRequest") + operation.Responses[http.StatusCreated] = jsonResponse("Created SaaS edition profile envelope.", "#/components/schemas/SaaSEditionProfileEnvelope") + case "craReadinessReport": + operation.Description = "Returns a CRA-oriented readiness report without legal compliance or certification conclusions." + operation.Parameters = append(operation.Parameters, + queryParam("product_id", "Product id.", "string"), + queryParam("release_id", "Release id.", "string"), + ) + operation.Responses[http.StatusOK] = jsonResponse("CRA readiness report envelope.", "#/components/schemas/ReadinessReportEnvelope") + case "craVulnerabilityHandlingReport": + operation.Description = "Returns a CRA-oriented vulnerability handling evidence report without legal compliance, certification, scanner-authority, or release-security conclusions." + operation.Parameters = append(operation.Parameters, + queryParam("product_id", "Product id.", "string"), + queryParam("release_id", "Release id.", "string"), + ) + operation.Responses[http.StatusOK] = jsonResponse("CRA vulnerability handling report envelope.", "#/components/schemas/CRAVulnerabilityHandlingReportEnvelope") + case "securityUpdateEvidenceReport": + operation.Description = "Returns a security update evidence report for release-scoped fixed decisions, incidents, remediation tasks, and linked evidence without legal or security conclusions." + operation.Parameters = append(operation.Parameters, + queryParam("product_id", "Product id.", "string"), + queryParam("release_id", "Release id.", "string"), + ) + operation.Responses[http.StatusOK] = jsonResponse("Security update evidence report envelope.", "#/components/schemas/SecurityUpdateEvidenceReportEnvelope") + case "controlCoverageReport": + operation.Description = "Returns deterministic control coverage with linked evidence, missing evidence, assumptions, and limitations." + operation.Parameters = append(operation.Parameters, + queryParam("framework_id", "Control framework id.", "string"), + queryParam("product_id", "Product id.", "string"), + queryParam("release_id", "Release id.", "string"), + ) + operation.Responses[http.StatusOK] = jsonResponse("Control coverage report envelope.", "#/components/schemas/ReadinessReportEnvelope") + case "createRedactionProfile": + operation.Description = "Creates an explicit redaction profile for customer and report package generation." + operation.RequestBody = jsonRequest("Redaction profile creation request.", "#/components/schemas/CreateRedactionProfileRequest") + operation.Responses[http.StatusCreated] = jsonResponse("Created redaction profile envelope.", "#/components/schemas/RedactionProfileEnvelope") + case "createCustomerPackage": + operation.Description = "Creates a scoped customer security package manifest using an explicit redaction profile." + operation.RequestBody = jsonRequest("Customer package creation request.", "#/components/schemas/CreateCustomerPackageRequest") + addJSONRequestExamples(operation.RequestBody, map[string]any{ + "release-evidence-package": specs.Example{ + Summary: "Create a scoped release evidence package", + Value: customerPackageRequestExample(), + }, + }) + operation.Responses[http.StatusCreated] = jsonResponse("Created customer security package envelope.", "#/components/schemas/CustomerSecurityPackageEnvelope") + case "getCustomerPackage": + operation.Description = "Returns a tenant-scoped customer security package manifest by id." + operation.Parameters = append(operation.Parameters, pathParam("id", "Customer package id.")) + operation.Responses[http.StatusOK] = jsonResponse("Customer security package envelope.", "#/components/schemas/CustomerSecurityPackageEnvelope") + case "createCustomerPortalAccess": + operation.Description = "Creates a named, expiring external reviewer access record for a customer package and returns the portal token once." + operation.RequestBody = jsonRequest("Customer portal access creation request.", "#/components/schemas/CreateCustomerPortalAccessRequest") + operation.Responses[http.StatusCreated] = jsonResponse("Created portal access and one-time token envelope.", "#/components/schemas/CustomerPortalAccessCreateEnvelope") + case "listCustomerPortalAccess": + operation.Description = "Lists tenant-scoped external reviewer access records without token hashes or token secrets." + operation.Parameters = append(operation.Parameters, queryParam("package_id", "Optional customer package id filter.", "string")) + operation.Responses[http.StatusOK] = jsonResponse("Customer portal access list envelope.", "#/components/schemas/CustomerPortalAccessListEnvelope") + case "revokeCustomerPortalAccess": + operation.Description = "Revokes a tenant-scoped external reviewer access record; revocation is append-only and the original token cannot be used afterwards." + operation.Parameters = append(operation.Parameters, pathParam("id", "Customer portal access id.")) + operation.RequestBody = jsonRequest("Empty JSON object.", "#/components/schemas/EmptyObject") + operation.Responses[http.StatusOK] = jsonResponse("Revoked customer portal access envelope.", "#/components/schemas/CustomerPortalAccessEnvelope") + case "downloadCustomerPackage": + operation.Description = "Downloads a scoped customer security package ZIP. The archive contains redacted manifest metadata and verification guidance, not raw tenant evidence payload bytes." + operation.Parameters = append(operation.Parameters, pathParam("id", "Customer package id.")) + operation.Responses[http.StatusOK] = binaryResponse("Customer security package ZIP archive.") + case "accessCustomerPortalPackage": + operation.Description = "Public token exchange endpoint for a scoped customer package. It intentionally uses no bearer authentication and accepts only the issued portal token in the JSON body." + operation.RequestBody = jsonRequest("Customer portal token request.", "#/components/schemas/CustomerPortalPackageRequest") + operation.Security = nil + operation.Scopes = nil + operation.Responses[http.StatusOK] = jsonResponse("Scoped customer package envelope.", "#/components/schemas/CustomerSecurityPackageEnvelope") + case "downloadCustomerPortalPackage": + operation.Description = "Public token exchange endpoint for downloading a scoped customer package ZIP. It intentionally uses no bearer authentication and accepts only the issued portal token in the JSON body." + operation.RequestBody = jsonRequest("Customer portal token request.", "#/components/schemas/CustomerPortalPackageRequest") + operation.Security = nil + operation.Scopes = nil + operation.Responses[http.StatusOK] = binaryResponse("Customer security package ZIP archive.") + case "customerPortalPackageViewForm": + operation.Description = "Public HTML form for reviewing or downloading a scoped customer package with a portal token. It does not accept tokens in URLs and does not use bearer authentication." + operation.Security = nil + operation.Scopes = nil + operation.Responses[http.StatusOK] = htmlResponse("Customer portal package review form.") + case "customerPortalPackageView": + operation.Description = "Public HTML package review endpoint backed by the customer portal token exchange. Tokens are accepted only as form body fields." + operation.RequestBody = formRequest("Customer portal token form request.", "#/components/schemas/CustomerPortalPackageRequest") + operation.Security = nil + operation.Scopes = nil + operation.Responses[http.StatusOK] = htmlResponse("Scoped customer package review HTML.") + case "downloadCustomerPortalPackageView": + operation.Description = "Public HTML-form package ZIP download endpoint backed by the customer portal token exchange. Tokens are accepted only as form body fields." + operation.RequestBody = formRequest("Customer portal token form request.", "#/components/schemas/CustomerPortalPackageRequest") + operation.Security = nil + operation.Scopes = nil + operation.Responses[http.StatusOK] = binaryResponse("Customer security package ZIP archive.") + case "securityReviewPackageReport": + operation.Description = "Returns a redaction-aware security-review package report with assumptions and limitations." + operation.Parameters = append(operation.Parameters, queryParam("package_id", "Customer package id.", "string")) + operation.Responses[http.StatusOK] = jsonResponse("Security review package report envelope.", "#/components/schemas/SecurityReviewPackageReportEnvelope") + case "craReadinessHTMLPackage": + operation.Description = "Creates a deterministic CRA-readiness HTML package without legal compliance or certification conclusions." + operation.Parameters = append(operation.Parameters, + queryParam("product_id", "Product id.", "string"), + queryParam("release_id", "Release id.", "string"), + ) + operation.Responses[http.StatusOK] = jsonResponse("CRA readiness HTML package envelope.", "#/components/schemas/HTMLReportPackageEnvelope") + case "createReportTemplate": + operation.Description = "Creates a tenant-defined deterministic report template with an explicit allowed-field list." + operation.RequestBody = jsonRequest("Report template creation request.", "#/components/schemas/CreateReportTemplateRequest") + operation.Responses[http.StatusCreated] = jsonResponse("Created report template envelope.", "#/components/schemas/CustomReportTemplateEnvelope") + case "renderReportTemplate": + operation.Description = "Renders a tenant report template for a scoped subject using allowed fields only." + operation.Parameters = append(operation.Parameters, pathParam("id", "Report template id.")) + operation.RequestBody = jsonRequest("Report template render request.", "#/components/schemas/RenderReportTemplateRequest") + operation.Responses[http.StatusCreated] = jsonResponse("Rendered report envelope.", "#/components/schemas/RenderedCustomReportEnvelope") + case "exportEvidenceBundle": + operation.Description = "Exports a portable evidence bundle manifest with hashes, signatures, and verification text." + operation.RequestBody = jsonRequest("Evidence bundle export request.", "#/components/schemas/ExportEvidenceBundleRequest") + operation.Responses[http.StatusCreated] = jsonResponse("Created evidence bundle envelope.", "#/components/schemas/EvidenceBundleEnvelope") + case "importEvidenceBundle": + operation.Description = "Imports a portable evidence bundle manifest and records deterministic import metadata." + operation.RequestBody = jsonRequest("Evidence bundle import request.", "#/components/schemas/EvidenceBundle") + operation.Responses[http.StatusCreated] = jsonResponse("Evidence bundle import result envelope.", "#/components/schemas/EvidenceBundleImportEnvelope") + case "createEvidenceSummary": + operation.Description = "Creates an evidence-backed summary with citations, assumptions, and limitations." + operation.RequestBody = jsonRequest("Evidence summary creation request.", "#/components/schemas/CreateEvidenceSummaryRequest") + operation.Responses[http.StatusCreated] = jsonResponse("Created evidence summary envelope.", "#/components/schemas/EvidenceSummaryEnvelope") + case "createQuestionnaireTemplate": + operation.Description = "Creates a tenant questionnaire template with explicit evidence/control mapping fields." + operation.RequestBody = jsonRequest("Questionnaire template creation request.", "#/components/schemas/CreateQuestionnaireTemplateRequest") + operation.Responses[http.StatusCreated] = jsonResponse("Created questionnaire template envelope.", "#/components/schemas/QuestionnaireTemplateEnvelope") + case "createQuestionnairePackage": + operation.Description = "Creates a questionnaire response package from a template and scoped evidence package." + operation.RequestBody = jsonRequest("Questionnaire package creation request.", "#/components/schemas/CreateQuestionnairePackageRequest") + operation.Responses[http.StatusCreated] = jsonResponse("Created questionnaire package envelope.", "#/components/schemas/QuestionnairePackageEnvelope") + case "createQuestionnaireDraft": + operation.Description = "Creates an evidence-backed questionnaire draft with limitations." + operation.RequestBody = jsonRequest("Questionnaire draft creation request.", "#/components/schemas/CreateQuestionnaireDraftRequest") + operation.Responses[http.StatusCreated] = jsonResponse("Created questionnaire draft envelope.", "#/components/schemas/QuestionnaireDraftEnvelope") + case "createQuestionnaireAnswerLibraryEntry": + operation.Description = "Creates a tenant-scoped reusable questionnaire answer draft linked to optional evidence, product, release, or control scope." + operation.RequestBody = jsonRequest("Questionnaire answer library entry creation request.", "#/components/schemas/CreateQuestionnaireAnswerLibraryEntryRequest") + operation.Responses[http.StatusCreated] = jsonResponse("Created questionnaire answer library entry envelope.", "#/components/schemas/QuestionnaireAnswerLibraryEntryEnvelope") + case "listQuestionnaireAnswerLibrary": + operation.Description = "Lists tenant-scoped questionnaire answer library entries with optional question, product, and release filters." + operation.Parameters = append(operation.Parameters, + queryParam("question_id", "Filter by questionnaire question id.", "string"), + queryParam("product_id", "Filter by product id.", "string"), + queryParam("release_id", "Filter by release id.", "string"), + ) + operation.Responses[http.StatusOK] = jsonResponse("Questionnaire answer library entry list envelope.", "#/components/schemas/QuestionnaireAnswerLibraryEntryListEnvelope") + case "createPDFReportPackage": + operation.Description = "Creates a deterministic PDF report package record and payload metadata." + operation.RequestBody = jsonRequest("PDF report package creation request.", "#/components/schemas/CreatePDFReportPackageRequest") + operation.Responses[http.StatusCreated] = jsonResponse("Created PDF report package envelope.", "#/components/schemas/PDFReportPackageEnvelope") + } + return operation +} + +func addProblemResponses(operation *specs.Operation) { + for _, status := range []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusConflict, http.StatusUnprocessableEntity} { + operation.Responses[status] = problemResponse(http.StatusText(status)) + } +} + +func jsonRequest(description, schemaRef string) *specs.RequestBody { + return &specs.RequestBody{ + Description: description, + Required: true, + ContentTypes: []string{"application/json"}, + Content: map[string]specs.MediaType{ + "application/json": {SchemaRef: schemaRef}, + }, + } +} + +func formRequest(description, schemaRef string) *specs.RequestBody { + return &specs.RequestBody{ + Description: description, + Required: true, + ContentTypes: []string{"application/x-www-form-urlencoded"}, + Content: map[string]specs.MediaType{ + "application/x-www-form-urlencoded": {SchemaRef: schemaRef}, + }, + } +} + +func addJSONRequestExamples(body *specs.RequestBody, examples map[string]any) { + if body == nil || len(examples) == 0 { + return + } + media := body.Content["application/json"] + media.Examples = examples + body.Content["application/json"] = media +} + +func addJSONResponseExamples(operation *specs.Operation, status int, examples map[string]any) { + if operation == nil || len(examples) == 0 { + return + } + response := operation.Responses[status] + media := response.Content["application/json"] + media.Examples = examples + response.Content["application/json"] = media + operation.Responses[status] = response +} + +func cyclonedxSBOMUploadExample() map[string]any { + return map[string]any{ + "release_id": "rel_20260527120000", + "artifact_id": "art_20260527120000", + "payload": map[string]any{ + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "components": []map[string]any{ + {"name": "payments-api", "version": "1.0.0-rc.1", "purl": "pkg:oci/payments-api@sha256-ca978112"}, + {"name": "openssl", "version": "3.1.0-r0", "purl": "pkg:apk/openssl@3.1.0-r0"}, + }, + }, + } +} + +func openVEXUploadExample() map[string]any { + return map[string]any{ + "release_id": "rel_20260527120000", + "artifact_id": "art_20260527120000", + "payload": map[string]any{ + "@context": "https://openvex.dev/ns/v0.2.0", + "@id": "https://example.test/vex/payments-api/1.0.0", + "author": "security@example.test", + "timestamp": "2026-05-27T12:00:00Z", + "version": 1, + "statements": []map[string]any{ + { + "vulnerability": map[string]any{"name": "CVE-2026-0002"}, + "products": []map[string]any{{"@id": "pkg:apk/openssl@3.1.0"}}, + "status": "fixed", + "justification": "fixed_in_release_candidate", + "impact_statement": "Patched before this release candidate.", + "action_statement": "Ship the fixed artifact after operator review.", + }, + }, + }, + } +} + +func vulnerabilityScanUploadExample() map[string]any { + return map[string]any{ + "scanner": "generic-json", + "target_ref": "pkg:oci/payments-api@sha256-ca978112", + "release_id": "rel_20260527120000", + "findings": []map[string]any{ + { + "vulnerability": "CVE-2026-0099", + "component": "pkg:apk/openssl@3.1.0-r0", + "severity": "critical", + "state": "open", + }, + }, + } +} + +func vulnerabilityDecisionExample() map[string]any { + return map[string]any{ + "status": "not_affected", + "justification": "The vulnerable code path is not reachable in this release.", + "impact_statement": "The shipped artifact does not include the affected runtime path.", + "customer_visible": true, + "evidence_ids": []string{"ev_20260527120000"}, + "supporting_refs": []map[string]string{{"type": "exception", "id": "exc_20260527120000"}}, + } +} + +func releaseReadinessReportExample() map[string]any { + return map[string]any{ + "data": map[string]any{ + "report_type": "release_readiness", + "template_version": "release-readiness.v1.0.0", + "product_id": "prod_20260527120000", + "release_id": "rel_20260527120000", + "result": "failed", + "policy_set": "policy-set.v1.0.0", + "summary": map[string]any{ + "headline": "Release evidence has one blocking vulnerability decision gap.", + "result": "failed", + "human_summary": "Required artifact, SBOM, scan, build, and bundle evidence is present, but one critical finding still needs a valid decision or approved exception.", + "policy_set": "policy-set.v1.0.0", + }, + "blocking_findings": []map[string]any{ + { + "finding_id": "scan_20260527120000:finding:1", + "scan_id": "scan_20260527120000", + "release_id": "rel_20260527120000", + "vulnerability": "CVE-2026-0099", + "component": "pkg:apk/openssl@3.1.0-r0", + "severity": "critical", + "state": "open", + "decision_state": "missing", + }, + }, + "missing_evidence": []string{"vulnerability_decision"}, + "accepted_exceptions": []map[string]any{}, + "assumptions": []string{ + "Readiness is based on tenant evidence recorded in Evydence.", + }, + "limitations": []string{ + "Readiness supports technical review and does not prove legal compliance, certification, complete vulnerability coverage, or release security.", + }, + "schema_version": "release-readiness.v1.0.0", + "generated_at": "2026-05-27T12:00:00Z", + }, + "meta": map[string]any{"api_version": "v1"}, + } +} + +func customerPackageRequestExample() map[string]any { + return map[string]any{ + "product_id": "prod_20260527120000", + "release_id": "rel_20260527120000", + "redaction_profile_id": "rp_20260527120000", + "title": "Payments API 1.0.0 release evidence package", + "expires_at": "2026-06-30T00:00:00Z", + } +} + +func jsonResponse(description, schemaRef string) specs.Response { + return specs.Response{ + Description: description, + ContentTypes: []string{"application/json"}, + Content: map[string]specs.MediaType{ + "application/json": {SchemaRef: schemaRef}, + }, + } +} + +func problemResponse(description string) specs.Response { + return specs.Response{ + Description: description, + ContentTypes: []string{"application/problem+json"}, + Content: map[string]specs.MediaType{ + "application/problem+json": {SchemaRef: "#/components/schemas/Problem"}, + }, + } +} + +func binaryResponse(description string) specs.Response { + return specs.Response{ + Description: description, + ContentTypes: []string{"application/zip"}, + Content: map[string]specs.MediaType{ + "application/zip": {Schema: map[string]any{"type": "string", "format": "binary"}}, + }, + } +} + +func htmlResponse(description string) specs.Response { + return specs.Response{ + Description: description, + ContentTypes: []string{"text/html"}, + Content: map[string]specs.MediaType{ + "text/html": {Schema: map[string]any{"type": "string"}}, + }, + } +} + +func queryParam(name, description, typ string) specs.Parameter { + schema := map[string]any{"type": typ} + if name == "limit" { + schema["minimum"] = 1 + schema["maximum"] = 100 + } + return specs.Parameter{Name: name, In: "query", Description: description, Schema: schema} +} + +func pathParam(name, description string) specs.Parameter { + return specs.Parameter{Name: name, In: "path", Description: description, Required: true, Schema: map[string]any{"type": "string"}} +} + +func headerParam(name, description string) specs.Parameter { + return specs.Parameter{Name: name, In: "header", Description: description, Required: true, Schema: map[string]any{"type": "string"}} +} diff --git a/internal/adapters/httpapi/ops_handlers.go b/internal/adapters/httpapi/ops_handlers.go new file mode 100644 index 0000000..bd10ed6 --- /dev/null +++ b/internal/adapters/httpapi/ops_handlers.go @@ -0,0 +1,55 @@ +package httpapi + +import ( + "net/http" + "time" + + "github.com/aatuh/evydence/internal/app" + "github.com/aatuh/evydence/internal/domain" +) + +func (s *Server) createLegalHold(w http.ResponseWriter, r *http.Request) { + var req struct { + ScopeType string `json:"scope_type"` + ScopeID string `json:"scope_id"` + Reason string `json:"reason"` + Owner string `json:"owner"` + } + s.create(w, r, func(ctx requestContext, actor domain.Actor, body []byte) (int, any, error) { + if err := decodeJSON(body, &req); err != nil { + return 0, nil, err + } + hold, err := s.ledger.CreateLegalHold(ctx, actor, app.CreateLegalHoldInput{ScopeType: req.ScopeType, ScopeID: req.ScopeID, Reason: req.Reason, Owner: req.Owner}) + return http.StatusCreated, hold, err + }) +} + +func (s *Server) createRetentionOverride(w http.ResponseWriter, r *http.Request) { + var req struct { + ScopeType string `json:"scope_type"` + ScopeID string `json:"scope_id"` + RetentionUntil time.Time `json:"retention_until"` + Reason string `json:"reason"` + Owner string `json:"owner"` + } + s.create(w, r, func(ctx requestContext, actor domain.Actor, body []byte) (int, any, error) { + if err := decodeJSON(body, &req); err != nil { + return 0, nil, err + } + override, err := s.ledger.CreateRetentionOverride(ctx, actor, app.CreateRetentionOverrideInput{ScopeType: req.ScopeType, ScopeID: req.ScopeID, RetentionUntil: req.RetentionUntil, Reason: req.Reason, Owner: req.Owner}) + return http.StatusCreated, override, err + }) +} + +func (s *Server) retentionReport(w http.ResponseWriter, r *http.Request) { + actor, ok := s.authenticate(w, r) + if !ok { + return + } + report, err := s.ledger.RetentionReport(r.Context(), actor, r.URL.Query().Get("scope_type"), r.URL.Query().Get("scope_id")) + if err != nil { + writeProblem(w, r, err) + return + } + writeData(w, http.StatusOK, report) +} diff --git a/internal/adapters/httpapi/portal_handlers.go b/internal/adapters/httpapi/portal_handlers.go new file mode 100644 index 0000000..fcc68c5 --- /dev/null +++ b/internal/adapters/httpapi/portal_handlers.go @@ -0,0 +1,489 @@ +package httpapi + +import ( + "fmt" + "html" + "io" + "net/http" + "net/url" + "sort" + "strings" + "time" + + "github.com/aatuh/evydence/internal/app" + "github.com/aatuh/evydence/internal/domain" +) + +const maxPortalFormBody = 64 << 10 + +func (s *Server) createCustomerPortalAccess(w http.ResponseWriter, r *http.Request) { + var req struct { + PackageID string `json:"package_id"` + CustomerName string `json:"customer_name"` + ReviewerName string `json:"reviewer_name"` + ReviewerEmail string `json:"reviewer_email"` + RequireNDA bool `json:"require_nda"` + Watermark string `json:"watermark"` + ExpiresAt time.Time `json:"expires_at"` + } + s.create(w, r, func(ctx requestContext, actor domain.Actor, body []byte) (int, any, error) { + if err := decodeJSON(body, &req); err != nil { + return 0, nil, err + } + access, secret, err := s.ledger.CreateCustomerPortalAccess(ctx, actor, app.CreateCustomerPortalAccessInput{PackageID: req.PackageID, CustomerName: req.CustomerName, ReviewerName: req.ReviewerName, ReviewerEmail: req.ReviewerEmail, RequireNDA: req.RequireNDA, Watermark: req.Watermark, ExpiresAt: req.ExpiresAt}) + return http.StatusCreated, map[string]any{"access": access, "secret": secret}, err + }) +} + +func (s *Server) listCustomerPortalAccess(w http.ResponseWriter, r *http.Request) { + actor, ok := s.authenticate(w, r) + if !ok { + return + } + access, err := s.ledger.ListCustomerPortalAccess(r.Context(), actor, r.URL.Query().Get("package_id")) + if err != nil { + writeProblem(w, r, err) + return + } + writeData(w, http.StatusOK, access) +} + +func (s *Server) revokeCustomerPortalAccess(w http.ResponseWriter, r *http.Request) { + s.create(w, r, func(ctx requestContext, actor domain.Actor, _ []byte) (int, any, error) { + access, err := s.ledger.RevokeCustomerPortalAccess(ctx, actor, r.PathValue("id")) + return http.StatusOK, access, err + }) +} + +func (s *Server) accessCustomerPortalPackage(w http.ResponseWriter, r *http.Request) { + var req struct { + Token string `json:"token"` + NDAAccepted bool `json:"nda_accepted"` + NDAAcceptedBy string `json:"nda_accepted_by"` + } + body, err := readBody(r) + if err != nil { + writeProblem(w, r, err) + return + } + if err := decodeJSON(body, &req); err != nil { + writeProblem(w, r, err) + return + } + pkg, err := s.ledger.AccessCustomerPortalPackageWithAcceptance(r.Context(), req.Token, app.CustomerPortalAcceptanceInput{NDAAccepted: req.NDAAccepted, NDAAcceptedBy: req.NDAAcceptedBy}) + if err != nil { + writeProblem(w, r, err) + return + } + setPortalNoStoreHeaders(w) + writeData(w, http.StatusOK, pkg) +} + +func (s *Server) downloadCustomerPortalPackage(w http.ResponseWriter, r *http.Request) { + var req struct { + Token string `json:"token"` + NDAAccepted bool `json:"nda_accepted"` + NDAAcceptedBy string `json:"nda_accepted_by"` + } + body, err := readBody(r) + if err != nil { + writeProblem(w, r, err) + return + } + if err := decodeJSON(body, &req); err != nil { + writeProblem(w, r, err) + return + } + archive, err := s.ledger.ExportCustomerPortalPackageArchiveWithAcceptance(r.Context(), req.Token, app.CustomerPortalAcceptanceInput{NDAAccepted: req.NDAAccepted, NDAAcceptedBy: req.NDAAcceptedBy}) + if err != nil { + writeProblem(w, r, err) + return + } + setPortalNoStoreHeaders(w) + writeArchive(w, archive) +} + +func (s *Server) customerPortalPackageViewForm(w http.ResponseWriter, r *http.Request) { + writePortalHTML(w, http.StatusOK, customerPortalPackageFormHTML("")) +} + +func (s *Server) customerPortalPackageView(w http.ResponseWriter, r *http.Request) { + req, err := readCustomerPortalForm(r) + if err != nil { + writeProblem(w, r, err) + return + } + pkg, err := s.ledger.AccessCustomerPortalPackageWithAcceptance(r.Context(), req.Token, app.CustomerPortalAcceptanceInput{NDAAccepted: req.NDAAccepted, NDAAcceptedBy: req.NDAAcceptedBy}) + if err != nil { + writeProblem(w, r, err) + return + } + writePortalHTML(w, http.StatusOK, customerPortalPackageHTML(pkg)) +} + +func (s *Server) downloadCustomerPortalPackageView(w http.ResponseWriter, r *http.Request) { + req, err := readCustomerPortalForm(r) + if err != nil { + writeProblem(w, r, err) + return + } + archive, err := s.ledger.ExportCustomerPortalPackageArchiveWithAcceptance(r.Context(), req.Token, app.CustomerPortalAcceptanceInput{NDAAccepted: req.NDAAccepted, NDAAcceptedBy: req.NDAAcceptedBy}) + if err != nil { + writeProblem(w, r, err) + return + } + setPortalNoStoreHeaders(w) + writeArchive(w, archive) +} + +type customerPortalFormRequest struct { + Token string + NDAAccepted bool + NDAAcceptedBy string +} + +func readCustomerPortalForm(r *http.Request) (customerPortalFormRequest, error) { + if strings.TrimSpace(r.URL.RawQuery) != "" { + return customerPortalFormRequest{}, app.ErrValidation + } + contentType := strings.ToLower(strings.TrimSpace(r.Header.Get("Content-Type"))) + if !strings.HasPrefix(contentType, "application/x-www-form-urlencoded") { + return customerPortalFormRequest{}, app.ErrValidation + } + body, err := io.ReadAll(io.LimitReader(r.Body, maxPortalFormBody+1)) + if err != nil || len(body) > maxPortalFormBody { + return customerPortalFormRequest{}, app.ErrValidation + } + values, err := url.ParseQuery(string(body)) + if err != nil { + return customerPortalFormRequest{}, app.ErrValidation + } + for key := range values { + switch key { + case "token", "nda_accepted", "nda_accepted_by": + if len(values[key]) > 1 { + return customerPortalFormRequest{}, app.ErrValidation + } + default: + return customerPortalFormRequest{}, app.ErrValidation + } + } + token := strings.TrimSpace(values.Get("token")) + acceptedBy := strings.TrimSpace(values.Get("nda_accepted_by")) + if token == "" || len(token) > 4096 || len(acceptedBy) > 256 { + return customerPortalFormRequest{}, app.ErrValidation + } + accepted := false + switch strings.ToLower(strings.TrimSpace(values.Get("nda_accepted"))) { + case "", "false", "0", "off": + case "true", "1", "on": + accepted = true + default: + return customerPortalFormRequest{}, app.ErrValidation + } + return customerPortalFormRequest{Token: token, NDAAccepted: accepted, NDAAcceptedBy: acceptedBy}, nil +} + +func writePortalHTML(w http.ResponseWriter, status int, body string) { + setPortalNoStoreHeaders(w) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'") + w.WriteHeader(status) + _, _ = w.Write([]byte(body)) +} + +func setPortalNoStoreHeaders(w http.ResponseWriter) { + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Pragma", "no-cache") + w.Header().Set("Referrer-Policy", "no-referrer") + w.Header().Set("X-Content-Type-Options", "nosniff") +} + +func customerPortalPackageFormHTML(message string) string { + var b strings.Builder + writePortalHTMLHead(&b, "Evydence Package Review") + b.WriteString("

Evydence package review

") + if strings.TrimSpace(message) != "" { + b.WriteString("

") + b.WriteString(escapeHTML(message)) + b.WriteString("

") + } + b.WriteString("

Review package

") + writePortalTokenFields(&b) + b.WriteString("
") + b.WriteString("

Download ZIP

") + writePortalTokenFields(&b) + b.WriteString("
") + b.WriteString("

Package review supports technical evidence review and compliance readiness only. It is not legal compliance proof, certification, complete SBOM proof, an authoritative vulnerability result, regulator acceptance, or a secure-release guarantee.

") + b.WriteString("
") + return b.String() +} + +func writePortalTokenFields(b *strings.Builder) { + b.WriteString("") + b.WriteString("") + b.WriteString("") +} + +func customerPortalPackageHTML(pkg domain.CustomerSecurityPackage) string { + var b strings.Builder + writePortalHTMLHead(&b, "Evydence Package "+pkg.ID) + b.WriteString("

") + b.WriteString(escapeHTML(pkg.Title)) + b.WriteString("

This page is generated from a scoped, redacted customer package. It omits raw tenant evidence payloads and internal notes.

") + b.WriteString("

Scope

") + portalDefinition(&b, "package_id", pkg.ID) + portalDefinition(&b, "product_id", pkg.ProductID) + portalDefinition(&b, "release_id", pkg.ReleaseID) + portalDefinition(&b, "state", pkg.State) + portalDefinition(&b, "manifest_hash", pkg.ManifestHash) + portalDefinition(&b, "expires_at", pkg.ExpiresAt.UTC().Format(time.RFC3339)) + if pkg.DistributionWatermark != "" { + portalDefinition(&b, "distribution_watermark", pkg.DistributionWatermark) + } + b.WriteString("
") + for _, section := range []struct { + Key string + Title string + }{ + {"readiness_summary", "Readiness Summary"}, + {"vulnerability_decisions", "Vulnerability Decisions"}, + {"customer_decision_export", "Customer Decision Export"}, + {"customer_safe_gaps", "Customer-Safe Gaps"}, + {"artifact_digests", "Artifact Digests"}, + {"evidence_ids", "Included Evidence"}, + {"verification_material", "Verification Material"}, + {"reviewer_checklist", "Reviewer Checklist"}, + {"limitations", "Limitations"}, + {"non_claims", "Non-Claims"}, + } { + portalManifestSection(&b, pkg.Manifest, section.Key, section.Title) + } + b.WriteString("

Download ZIP

") + writePortalTokenFields(&b) + b.WriteString("
") + b.WriteString("

Review output supports technical evidence review and compliance readiness only. It is not legal compliance proof, certification, complete SBOM proof, an authoritative vulnerability result, regulator acceptance, or a secure-release guarantee.

") + b.WriteString("
") + return b.String() +} + +func writePortalHTMLHead(b *strings.Builder, title string) { + b.WriteString("") + b.WriteString(escapeHTML(title)) + b.WriteString("") +} + +func portalDefinition(b *strings.Builder, key, value string) { + if strings.TrimSpace(value) == "" { + return + } + b.WriteString("
") + b.WriteString(escapeHTML(key)) + b.WriteString("
") + b.WriteString(escapeHTML(value)) + b.WriteString("
") +} + +func portalManifestSection(b *strings.Builder, manifest map[string]any, key, title string) { + value, ok := manifest[key] + if !ok || portalValueEmpty(value) { + return + } + b.WriteString("

") + b.WriteString(escapeHTML(title)) + b.WriteString("

") + portalHTMLValue(b, value, 0) + b.WriteString("
") +} + +func portalHTMLValue(b *strings.Builder, value any, depth int) { + if depth > 5 { + b.WriteString("nested content omitted") + return + } + switch typed := value.(type) { + case nil: + b.WriteString("not recorded") + case string: + b.WriteString(escapeHTML(typed)) + case bool: + if typed { + b.WriteString("true") + } else { + b.WriteString("false") + } + case float64, float32, int, int64, int32, uint, uint64, uint32: + b.WriteString(escapeHTML(toString(typed))) + case []any: + if len(typed) == 0 { + b.WriteString("none") + return + } + b.WriteString("
    ") + for i, item := range typed { + if i >= 100 { + b.WriteString("
  • additional entries omitted
  • ") + break + } + b.WriteString("
  • ") + portalHTMLValue(b, item, depth+1) + b.WriteString("
  • ") + } + b.WriteString("
") + case []string: + if len(typed) == 0 { + b.WriteString("none") + return + } + b.WriteString("
    ") + for i, item := range typed { + if i >= 100 { + b.WriteString("
  • additional entries omitted
  • ") + break + } + b.WriteString("
  • ") + b.WriteString(escapeHTML(item)) + b.WriteString("
  • ") + } + b.WriteString("
") + case map[string]any: + keys := make([]string, 0, len(typed)) + for key := range typed { + if portalSensitiveManifestKey(key) { + continue + } + keys = append(keys, key) + } + sort.Strings(keys) + if len(keys) == 0 { + b.WriteString("none") + return + } + b.WriteString("
") + for _, key := range keys { + b.WriteString("
") + b.WriteString(escapeHTML(key)) + b.WriteString("
") + portalHTMLValue(b, typed[key], depth+1) + b.WriteString("
") + } + b.WriteString("
") + case map[string]string: + keys := make([]string, 0, len(typed)) + for key := range typed { + if portalSensitiveManifestKey(key) { + continue + } + keys = append(keys, key) + } + sort.Strings(keys) + b.WriteString("
") + for _, key := range keys { + portalDefinition(b, key, typed[key]) + } + b.WriteString("
") + default: + b.WriteString(escapeHTML(toString(typed))) + } +} + +func portalValueEmpty(value any) bool { + switch typed := value.(type) { + case nil: + return true + case string: + return strings.TrimSpace(typed) == "" + case []any: + return len(typed) == 0 + case []string: + return len(typed) == 0 + case map[string]any: + return len(typed) == 0 + case map[string]string: + return len(typed) == 0 + default: + return false + } +} + +func portalSensitiveManifestKey(key string) bool { + normalized := strings.ToLower(strings.TrimSpace(key)) + switch normalized { + case "payload_ref", "payload_refs", "object_key", "object_keys", "object_url", "object_urls", "raw_payload", "raw_payload_bytes", "private_key", "private_keys", "token", "tokens", "token_hash", "token_hashes", "api_key", "api_keys", "secret", "secrets", "internal_notes": + return true + default: + return false + } +} + +func toString(value any) string { + return fmt.Sprint(value) +} + +func escapeHTML(value string) string { + return html.EscapeString(value) +} + +func (s *Server) createQuestionnaireTemplate(w http.ResponseWriter, r *http.Request) { + var req struct { + Name string `json:"name"` + Version string `json:"version"` + Questions []domain.QuestionnaireQuestion `json:"questions"` + } + s.create(w, r, func(ctx requestContext, actor domain.Actor, body []byte) (int, any, error) { + if err := decodeJSON(body, &req); err != nil { + return 0, nil, err + } + tpl, err := s.ledger.CreateQuestionnaireTemplate(ctx, actor, app.CreateQuestionnaireTemplateInput{Name: req.Name, Version: req.Version, Questions: req.Questions}) + return http.StatusCreated, tpl, err + }) +} + +func (s *Server) createQuestionnairePackage(w http.ResponseWriter, r *http.Request) { + var req struct { + TemplateID string `json:"template_id"` + PackageID string `json:"package_id"` + ProductID string `json:"product_id"` + ReleaseID string `json:"release_id"` + } + s.create(w, r, func(ctx requestContext, actor domain.Actor, body []byte) (int, any, error) { + if err := decodeJSON(body, &req); err != nil { + return 0, nil, err + } + pkg, err := s.ledger.CreateQuestionnairePackage(ctx, actor, app.CreateQuestionnairePackageInput{TemplateID: req.TemplateID, PackageID: req.PackageID, ProductID: req.ProductID, ReleaseID: req.ReleaseID}) + return http.StatusCreated, pkg, err + }) +} + +func (s *Server) createQuestionnaireAnswerLibraryEntry(w http.ResponseWriter, r *http.Request) { + var req struct { + QuestionID string `json:"question_id"` + EvidenceType string `json:"evidence_type"` + ControlID string `json:"control_id"` + ProductID string `json:"product_id"` + ReleaseID string `json:"release_id"` + Answer string `json:"answer"` + EvidenceIDs []string `json:"evidence_ids"` + Limitations []string `json:"limitations"` + } + s.create(w, r, func(ctx requestContext, actor domain.Actor, body []byte) (int, any, error) { + if err := decodeJSON(body, &req); err != nil { + return 0, nil, err + } + entry, err := s.ledger.CreateQuestionnaireAnswerLibraryEntry(ctx, actor, app.CreateQuestionnaireAnswerLibraryEntryInput{QuestionID: req.QuestionID, EvidenceType: req.EvidenceType, ControlID: req.ControlID, ProductID: req.ProductID, ReleaseID: req.ReleaseID, Answer: req.Answer, EvidenceIDs: req.EvidenceIDs, Limitations: req.Limitations}) + return http.StatusCreated, entry, err + }) +} + +func (s *Server) listQuestionnaireAnswerLibrary(w http.ResponseWriter, r *http.Request) { + actor, ok := s.authenticate(w, r) + if !ok { + return + } + entries, err := s.ledger.ListQuestionnaireAnswerLibrary(r.Context(), actor, app.ListQuestionnaireAnswerLibraryInput{QuestionID: r.URL.Query().Get("question_id"), ProductID: r.URL.Query().Get("product_id"), ReleaseID: r.URL.Query().Get("release_id")}) + if err != nil { + writeProblem(w, r, err) + return + } + writeData(w, http.StatusOK, entries) +} diff --git a/internal/adapters/httpapi/route_registration.go b/internal/adapters/httpapi/route_registration.go new file mode 100644 index 0000000..cb137d8 --- /dev/null +++ b/internal/adapters/httpapi/route_registration.go @@ -0,0 +1,288 @@ +package httpapi + +import ( + "net/http" + + "github.com/aatuh/api-toolkit/v3/routecontracts" + "github.com/aatuh/api-toolkit/v3/specs" + + "github.com/aatuh/evydence/internal/app" +) + +type routeDef struct { + method string + path string + op specs.Operation + handler http.Handler +} + +func (s *Server) registerRoutes() error { + for _, route := range s.routeDefinitions() { + if err := s.routes.Register(routecontracts.Route{Method: route.method, Pattern: route.path, Handler: route.handler, Operation: route.op}); err != nil { + return err + } + } + return nil +} + +func (s *Server) routeDefinitions() []routeDef { + groups := [][]routeDef{ + s.systemRoutes(), + s.identityRoutes(), + s.collectorRoutes(), + s.controlRoutes(), + s.productReleaseRoutes(), + s.artifactBuildSourceRoutes(), + s.deploymentIncidentSecurityRoutes(), + s.packagePortalRoutes(), + s.evidenceRiskPolicyRoutes(), + s.integrityOpsRoutes(), + s.keyAndAdminRoutes(), + } + var routes []routeDef + for _, group := range groups { + routes = append(routes, group...) + } + return routes +} + +func (s *Server) systemRoutes() []routeDef { + return []routeDef{ + {http.MethodGet, "/v1/health", op("health", http.MethodGet, "/v1/health", "Health", nil), http.HandlerFunc(s.health)}, + {http.MethodGet, "/v1/ready", op("ready", http.MethodGet, "/v1/ready", "Readiness", nil), http.HandlerFunc(s.ready)}, + {http.MethodGet, "/v1/version", op("version", http.MethodGet, "/v1/version", "Version", nil), http.HandlerFunc(s.version)}, + {http.MethodGet, "/v1/metrics", op("metrics", http.MethodGet, "/v1/metrics", "Safe tenant metrics", []string{app.ScopeAdmin}), http.HandlerFunc(s.metrics)}, + {http.MethodGet, "/v1/openapi.json", op("openapi", http.MethodGet, "/v1/openapi.json", "OpenAPI", nil), http.HandlerFunc(s.openapi)}, + {http.MethodGet, "/v1/admin/instance", op("instanceAdminSnapshot", http.MethodGet, "/v1/admin/instance", "Instance admin snapshot", []string{app.ScopeInstanceAdmin}), http.HandlerFunc(s.instanceAdminSnapshot)}, + } +} + +func (s *Server) identityRoutes() []routeDef { + return []routeDef{ + {http.MethodPost, "/v1/organizations", op("createOrganization", http.MethodPost, "/v1/organizations", "Create organization", []string{app.ScopeIdentityAdmin}), http.HandlerFunc(s.createOrganization)}, + {http.MethodPost, "/v1/users", op("createUser", http.MethodPost, "/v1/users", "Create user", []string{app.ScopeIdentityAdmin}), http.HandlerFunc(s.createUser)}, + {http.MethodPost, "/v1/users/{id}/deactivate", op("deactivateUser", http.MethodPost, "/v1/users/{id}/deactivate", "Deactivate user", []string{app.ScopeIdentityAdmin}), http.HandlerFunc(s.deactivateUser)}, + {http.MethodPost, "/v1/role-bindings", op("createRoleBinding", http.MethodPost, "/v1/role-bindings", "Create role binding", []string{app.ScopeIdentityAdmin}), http.HandlerFunc(s.createRoleBinding)}, + {http.MethodGet, "/v1/role-bindings", op("listRoleBindings", http.MethodGet, "/v1/role-bindings", "List role bindings", []string{app.ScopeIdentityAdmin}), http.HandlerFunc(s.listRoleBindings)}, + {http.MethodPost, "/v1/sso/providers", op("createSSOProvider", http.MethodPost, "/v1/sso/providers", "Create SSO provider", []string{app.ScopeIdentityAdmin}), http.HandlerFunc(s.createSSOProvider)}, + {http.MethodPost, "/v1/sso/providers/{id}/trust-material", op("updateSSOProviderTrustMaterial", http.MethodPost, "/v1/sso/providers/{id}/trust-material", "Update SSO provider trust material", []string{app.ScopeIdentityAdmin}), http.HandlerFunc(s.updateSSOProviderTrustMaterial)}, + {http.MethodPost, "/v1/sso/providers/{id}/discover-oidc", op("refreshSSOProviderOIDCTrustMaterial", http.MethodPost, "/v1/sso/providers/{id}/discover-oidc", "Refresh SSO provider OIDC trust material", []string{app.ScopeIdentityAdmin}), http.HandlerFunc(s.refreshSSOProviderOIDCTrustMaterial)}, + {http.MethodPost, "/v1/sso/identity-links", op("linkSSOIdentity", http.MethodPost, "/v1/sso/identity-links", "Link SSO identity", []string{app.ScopeIdentityAdmin}), http.HandlerFunc(s.linkSSOIdentity)}, + {http.MethodPost, "/v1/sso/sessions", op("createSSOSession", http.MethodPost, "/v1/sso/sessions", "Create SSO session", []string{app.ScopeIdentityAdmin}), http.HandlerFunc(s.createSSOSession)}, + {http.MethodPost, "/v1/sso/session-exchanges", publicPostOp("exchangeSSOCredential", http.MethodPost, "/v1/sso/session-exchanges", "Exchange SSO credential"), http.HandlerFunc(s.exchangeSSOCredential)}, + {http.MethodPost, "/v1/sso/sessions/{id}/revoke", op("revokeSSOSession", http.MethodPost, "/v1/sso/sessions/{id}/revoke", "Revoke SSO session", []string{app.ScopeIdentityAdmin}), http.HandlerFunc(s.revokeSSOSession)}, + {http.MethodPost, "/v1/sso/logout", authenticatedOp("logoutSSOSession", http.MethodPost, "/v1/sso/logout", "Logout SSO session"), http.HandlerFunc(s.logoutSSOSession)}, + } +} + +func (s *Server) collectorRoutes() []routeDef { + return []routeDef{ + {http.MethodPost, "/v1/collectors", op("createCollector", http.MethodPost, "/v1/collectors", "Create collector", []string{app.ScopeCollectorAdmin}), http.HandlerFunc(s.createCollector)}, + {http.MethodGet, "/v1/collectors", op("listCollectors", http.MethodGet, "/v1/collectors", "List collectors", []string{app.ScopeCollectorRead}), http.HandlerFunc(s.listCollectors)}, + {http.MethodPost, "/v1/collectors/{id}/releases", op("recordCollectorRelease", http.MethodPost, "/v1/collectors/{id}/releases", "Record collector release evidence", []string{app.ScopeCollectorAdmin}), http.HandlerFunc(s.recordCollectorRelease)}, + {http.MethodGet, "/v1/collectors/{id}/health", op("collectorHealthReport", http.MethodGet, "/v1/collectors/{id}/health", "Collector health report", []string{app.ScopeCollectorRead}), http.HandlerFunc(s.collectorHealthReport)}, + {http.MethodPost, "/v1/commercial-collectors", op("createCommercialCollector", http.MethodPost, "/v1/commercial-collectors", "Create commercial collector definition", []string{app.ScopeCollectorAdmin}), http.HandlerFunc(s.createCommercialCollector)}, + {http.MethodGet, "/v1/commercial-collectors", op("listCommercialCollectors", http.MethodGet, "/v1/commercial-collectors", "List commercial collector definitions", []string{app.ScopeCollectorRead}), http.HandlerFunc(s.listCommercialCollectors)}, + {http.MethodPost, "/v1/marketplace-collectors", op("createMarketplaceCollector", http.MethodPost, "/v1/marketplace-collectors", "Create marketplace collector record", []string{app.ScopeCollectorAdmin}), http.HandlerFunc(s.createMarketplaceCollector)}, + {http.MethodGet, "/v1/marketplace-collectors", op("listMarketplaceCollectors", http.MethodGet, "/v1/marketplace-collectors", "List marketplace collector records", []string{app.ScopeCollectorRead}), http.HandlerFunc(s.listMarketplaceCollectors)}, + {http.MethodGet, "/v1/marketplace-collectors/{id}/health", op("marketplaceCollectorHealth", http.MethodGet, "/v1/marketplace-collectors/{id}/health", "Marketplace collector health report", []string{app.ScopeCollectorRead}), http.HandlerFunc(s.marketplaceCollectorHealth)}, + } +} + +func (s *Server) controlRoutes() []routeDef { + return []routeDef{ + {http.MethodPost, "/v1/control-frameworks", op("createControlFramework", http.MethodPost, "/v1/control-frameworks", "Create control framework", []string{app.ScopeControlsAdmin}), http.HandlerFunc(s.createControlFramework)}, + {http.MethodGet, "/v1/control-frameworks", op("listControlFrameworks", http.MethodGet, "/v1/control-frameworks", "List control frameworks", []string{app.ScopeControlsRead}), http.HandlerFunc(s.listControlFrameworks)}, + {http.MethodGet, "/v1/control-framework-template-packs", op("listControlFrameworkTemplatePacks", http.MethodGet, "/v1/control-framework-template-packs", "List control framework template packs", []string{app.ScopeControlsRead}), http.HandlerFunc(s.listControlFrameworkTemplatePacks)}, + {http.MethodPost, "/v1/control-framework-template-packs/{slug}/install", op("installControlFrameworkTemplatePack", http.MethodPost, "/v1/control-framework-template-packs/{slug}/install", "Install control framework template pack", []string{app.ScopeControlsAdmin}), http.HandlerFunc(s.installControlFrameworkTemplatePack)}, + {http.MethodPost, "/v1/controls", op("createSecurityControl", http.MethodPost, "/v1/controls", "Create security control", []string{app.ScopeControlsAdmin}), http.HandlerFunc(s.createSecurityControl)}, + {http.MethodGet, "/v1/controls/{id}", op("getSecurityControl", http.MethodGet, "/v1/controls/{id}", "Get security control", []string{app.ScopeControlsRead}), http.HandlerFunc(s.getSecurityControl)}, + {http.MethodPost, "/v1/controls/{id}/evidence", op("linkControlEvidence", http.MethodPost, "/v1/controls/{id}/evidence", "Link control evidence", []string{app.ScopeControlsWrite}), http.HandlerFunc(s.linkControlEvidence)}, + {http.MethodGet, "/v1/control-evidence", op("listControlEvidence", http.MethodGet, "/v1/control-evidence", "List control evidence", []string{app.ScopeControlsRead}), http.HandlerFunc(s.listControlEvidence)}, + {http.MethodGet, "/v1/reports/control-coverage", op("controlCoverageReport", http.MethodGet, "/v1/reports/control-coverage", "Control coverage report", []string{app.ScopeReportRead}), http.HandlerFunc(s.controlCoverageReport)}, + {http.MethodGet, "/v1/reports/cra-readiness", op("craReadinessReport", http.MethodGet, "/v1/reports/cra-readiness", "CRA readiness report", []string{app.ScopeReportRead}), http.HandlerFunc(s.craReadinessReport)}, + {http.MethodGet, "/v1/reports/cra-vulnerability-handling", op("craVulnerabilityHandlingReport", http.MethodGet, "/v1/reports/cra-vulnerability-handling", "CRA vulnerability handling report", []string{app.ScopeReportRead}), http.HandlerFunc(s.craVulnerabilityHandlingReport)}, + {http.MethodGet, "/v1/reports/security-update-evidence", op("securityUpdateEvidenceReport", http.MethodGet, "/v1/reports/security-update-evidence", "Security update evidence report", []string{app.ScopeReportRead}), http.HandlerFunc(s.securityUpdateEvidenceReport)}, + } +} + +func (s *Server) productReleaseRoutes() []routeDef { + return []routeDef{ + {http.MethodPost, "/v1/products", op("createProduct", http.MethodPost, "/v1/products", "Create product", []string{app.ScopeProductWrite}), http.HandlerFunc(s.createProduct)}, + {http.MethodGet, "/v1/products", op("listProducts", http.MethodGet, "/v1/products", "List products", []string{app.ScopeProductRead}), http.HandlerFunc(s.listProducts)}, + {http.MethodGet, "/v1/products/{id}", op("getProduct", http.MethodGet, "/v1/products/{id}", "Get product", []string{app.ScopeProductRead}), http.HandlerFunc(s.getProduct)}, + {http.MethodPost, "/v1/projects", op("createProject", http.MethodPost, "/v1/projects", "Create project", []string{app.ScopeProjectWrite}), http.HandlerFunc(s.createProject)}, + {http.MethodGet, "/v1/projects/{id}", op("getProject", http.MethodGet, "/v1/projects/{id}", "Get project", []string{app.ScopeProjectRead}), http.HandlerFunc(s.getProject)}, + {http.MethodPost, "/v1/releases", op("createRelease", http.MethodPost, "/v1/releases", "Create release", []string{app.ScopeReleaseWrite}), http.HandlerFunc(s.createRelease)}, + {http.MethodGet, "/v1/releases/{id}", op("getRelease", http.MethodGet, "/v1/releases/{id}", "Get release", []string{app.ScopeReleaseRead}), http.HandlerFunc(s.getRelease)}, + {http.MethodPost, "/v1/releases/{id}/evidence-flow/start", op("startReleaseEvidenceFlow", http.MethodPost, "/v1/releases/{id}/evidence-flow/start", "Start release evidence flow", []string{app.ScopeReleaseRead}), http.HandlerFunc(s.startReleaseEvidenceFlow)}, + {http.MethodGet, "/v1/releases/{id}/security-summary", op("releaseSecuritySummary", http.MethodGet, "/v1/releases/{id}/security-summary", "Release security summary", []string{app.ScopeReportRead}), http.HandlerFunc(s.releaseSecuritySummary)}, + {http.MethodPost, "/v1/releases/{id}/freeze", op("freezeRelease", http.MethodPost, "/v1/releases/{id}/freeze", "Freeze release", []string{app.ScopeReleaseWrite}), http.HandlerFunc(s.freezeRelease)}, + {http.MethodPost, "/v1/releases/{id}/approve", op("approveRelease", http.MethodPost, "/v1/releases/{id}/approve", "Approve release", []string{app.ScopeReleaseWrite}), http.HandlerFunc(s.approveRelease)}, + {http.MethodPost, "/v1/release-candidates", op("createReleaseCandidate", http.MethodPost, "/v1/release-candidates", "Create release candidate", []string{app.ScopeReleaseWrite}), http.HandlerFunc(s.createReleaseCandidate)}, + {http.MethodGet, "/v1/release-candidates", op("listReleaseCandidates", http.MethodGet, "/v1/release-candidates", "List release candidates", []string{app.ScopeReleaseRead}), http.HandlerFunc(s.listReleaseCandidates)}, + {http.MethodGet, "/v1/release-candidates/{id}", op("getReleaseCandidate", http.MethodGet, "/v1/release-candidates/{id}", "Get release candidate", []string{app.ScopeReleaseRead}), http.HandlerFunc(s.getReleaseCandidate)}, + {http.MethodPost, "/v1/release-candidates/{id}/promote", op("promoteReleaseCandidate", http.MethodPost, "/v1/release-candidates/{id}/promote", "Promote release candidate", []string{app.ScopeReleaseWrite}), http.HandlerFunc(s.promoteReleaseCandidate)}, + {http.MethodPost, "/v1/release-candidates/{id}/reject", op("rejectReleaseCandidate", http.MethodPost, "/v1/release-candidates/{id}/reject", "Reject release candidate", []string{app.ScopeReleaseWrite}), http.HandlerFunc(s.rejectReleaseCandidate)}, + } +} + +func (s *Server) artifactBuildSourceRoutes() []routeDef { + return []routeDef{ + {http.MethodPost, "/v1/artifacts", op("registerArtifact", http.MethodPost, "/v1/artifacts", "Register artifact", []string{app.ScopeEvidenceWrite}), http.HandlerFunc(s.registerArtifact)}, + {http.MethodGet, "/v1/artifacts/{id}", op("getArtifact", http.MethodGet, "/v1/artifacts/{id}", "Get artifact", []string{app.ScopeEvidenceRead}), http.HandlerFunc(s.getArtifact)}, + {http.MethodPost, "/v1/container-images", op("registerContainerImage", http.MethodPost, "/v1/container-images", "Register container image", []string{app.ScopeEvidenceWrite}), http.HandlerFunc(s.registerContainerImage)}, + {http.MethodPost, "/v1/artifact-signatures", op("createArtifactSignature", http.MethodPost, "/v1/artifact-signatures", "Create artifact signature", []string{app.ScopeEvidenceWrite}), http.HandlerFunc(s.createArtifactSignature)}, + {http.MethodGet, "/v1/artifact-signatures/{id}", op("getArtifactSignature", http.MethodGet, "/v1/artifact-signatures/{id}", "Get artifact signature", []string{app.ScopeEvidenceRead}), http.HandlerFunc(s.getArtifactSignature)}, + {http.MethodPost, "/v1/artifact-signatures/{id}/verify-cosign", op("verifyCosignSignature", http.MethodPost, "/v1/artifact-signatures/{id}/verify-cosign", "Verify cosign-style artifact signature", []string{app.ScopeVerifyRead}), http.HandlerFunc(s.verifyCosignSignature)}, + {http.MethodPost, "/v1/builds", op("createBuild", http.MethodPost, "/v1/builds", "Create build run", []string{app.ScopeBuildWrite}), http.HandlerFunc(s.createBuild)}, + {http.MethodGet, "/v1/builds/{id}", op("getBuild", http.MethodGet, "/v1/builds/{id}", "Get build run", []string{app.ScopeBuildRead}), http.HandlerFunc(s.getBuild)}, + {http.MethodPost, "/v1/builds/{id}/attestations", op("uploadBuildAttestation", http.MethodPost, "/v1/builds/{id}/attestations", "Upload build attestation", []string{app.ScopeBuildWrite}), http.HandlerFunc(s.uploadBuildAttestation)}, + {http.MethodPost, "/v1/build-attestations/{id}/verify-signature", op("verifyBuildAttestationSignature", http.MethodPost, "/v1/build-attestations/{id}/verify-signature", "Verify build attestation signature", []string{app.ScopeVerifyRead}), http.HandlerFunc(s.verifyBuildAttestationSignature)}, + {http.MethodPost, "/v1/dsse-trust-roots", op("createDSSETrustRoot", http.MethodPost, "/v1/dsse-trust-roots", "Create DSSE trust root", []string{app.ScopeKeysAdmin}), http.HandlerFunc(s.createDSSETrustRoot)}, + {http.MethodPost, "/v1/source/repositories", op("createSourceRepository", http.MethodPost, "/v1/source/repositories", "Create source repository", []string{app.ScopeSourceWrite}), http.HandlerFunc(s.createSourceRepository)}, + {http.MethodGet, "/v1/source/repositories", op("listSourceRepositories", http.MethodGet, "/v1/source/repositories", "List source repositories", []string{app.ScopeSourceRead}), http.HandlerFunc(s.listSourceRepositories)}, + {http.MethodPost, "/v1/source/commits", op("recordSourceCommit", http.MethodPost, "/v1/source/commits", "Record source commit", []string{app.ScopeSourceWrite}), http.HandlerFunc(s.recordSourceCommit)}, + {http.MethodPost, "/v1/source/branches", op("upsertSourceBranch", http.MethodPost, "/v1/source/branches", "Record source branch", []string{app.ScopeSourceWrite}), http.HandlerFunc(s.upsertSourceBranch)}, + {http.MethodPost, "/v1/source/pull-requests", op("recordPullRequest", http.MethodPost, "/v1/source/pull-requests", "Record pull request", []string{app.ScopeSourceWrite}), http.HandlerFunc(s.recordPullRequest)}, + {http.MethodPost, "/v1/collectors/github/source-snapshots", op("uploadGitHubSourceSnapshot", http.MethodPost, "/v1/collectors/github/source-snapshots", "Upload GitHub source snapshot", []string{app.ScopeSourceWrite}), http.HandlerFunc(s.uploadGitHubSourceSnapshot)}, + {http.MethodPost, "/v1/collectors/gitlab/source-snapshots", op("uploadGitLabSourceSnapshot", http.MethodPost, "/v1/collectors/gitlab/source-snapshots", "Upload GitLab source snapshot", []string{app.ScopeSourceWrite}), http.HandlerFunc(s.uploadGitLabSourceSnapshot)}, + } +} + +func (s *Server) deploymentIncidentSecurityRoutes() []routeDef { + return []routeDef{ + {http.MethodPost, "/v1/environments", op("createDeploymentEnvironment", http.MethodPost, "/v1/environments", "Create deployment environment", []string{app.ScopeDeploymentWrite}), http.HandlerFunc(s.createDeploymentEnvironment)}, + {http.MethodGet, "/v1/environments", op("listDeploymentEnvironments", http.MethodGet, "/v1/environments", "List deployment environments", []string{app.ScopeDeploymentRead}), http.HandlerFunc(s.listDeploymentEnvironments)}, + {http.MethodPost, "/v1/deployments", op("recordDeployment", http.MethodPost, "/v1/deployments", "Record deployment", []string{app.ScopeDeploymentWrite}), http.HandlerFunc(s.recordDeployment)}, + {http.MethodGet, "/v1/deployments", op("listDeployments", http.MethodGet, "/v1/deployments", "List deployments", []string{app.ScopeDeploymentRead}), http.HandlerFunc(s.listDeployments)}, + {http.MethodGet, "/v1/deployments/{id}", op("getDeployment", http.MethodGet, "/v1/deployments/{id}", "Get deployment", []string{app.ScopeDeploymentRead}), http.HandlerFunc(s.getDeployment)}, + {http.MethodPost, "/v1/incidents", op("createIncident", http.MethodPost, "/v1/incidents", "Create incident", []string{app.ScopeIncidentWrite}), http.HandlerFunc(s.createIncident)}, + {http.MethodPost, "/v1/incidents/{id}/timeline", op("recordIncidentTimeline", http.MethodPost, "/v1/incidents/{id}/timeline", "Record incident timeline event", []string{app.ScopeIncidentWrite}), http.HandlerFunc(s.recordIncidentTimeline)}, + {http.MethodPost, "/v1/incidents/{id}/webhook-receivers", op("createIncidentWebhookReceiver", http.MethodPost, "/v1/incidents/{id}/webhook-receivers", "Create signed incident webhook receiver", []string{app.ScopeIncidentWrite}), http.HandlerFunc(s.createIncidentWebhookReceiver)}, + {http.MethodPost, "/v1/incident-webhooks/{receiver_id}", op("receiveIncidentWebhook", http.MethodPost, "/v1/incident-webhooks/{receiver_id}", "Receive signed incident webhook event", nil), http.HandlerFunc(s.receiveIncidentWebhook)}, + {http.MethodPost, "/v1/remediation-tasks", op("createRemediationTask", http.MethodPost, "/v1/remediation-tasks", "Create remediation task", []string{app.ScopeIncidentWrite}), http.HandlerFunc(s.createRemediationTask)}, + {http.MethodGet, "/v1/reports/incident-package", op("incidentReport", http.MethodGet, "/v1/reports/incident-package", "Incident package report", []string{app.ScopeIncidentRead}), http.HandlerFunc(s.incidentReport)}, + {http.MethodPost, "/v1/security-scans", op("uploadSecurityScan", http.MethodPost, "/v1/security-scans", "Upload security scan", []string{app.ScopeSecurityWrite}), http.HandlerFunc(s.uploadSecurityScan)}, + {http.MethodPost, "/v1/api-security-scans", op("uploadAPISecurityScan", http.MethodPost, "/v1/api-security-scans", "Upload API security scan", []string{app.ScopeSecurityWrite}), http.HandlerFunc(s.uploadAPISecurityScan)}, + {http.MethodPost, "/v1/security-documents", op("uploadManualSecurityDocument", http.MethodPost, "/v1/security-documents", "Upload manual security document", []string{app.ScopeSecurityWrite}), http.HandlerFunc(s.uploadManualSecurityDocument)}, + } +} + +func (s *Server) packagePortalRoutes() []routeDef { + return []routeDef{ + {http.MethodPost, "/v1/waivers", op("createWaiver", http.MethodPost, "/v1/waivers", "Create waiver", []string{app.ScopePolicyWrite}), http.HandlerFunc(s.createWaiver)}, + {http.MethodPost, "/v1/waivers/{id}/approve", op("approveWaiver", http.MethodPost, "/v1/waivers/{id}/approve", "Approve waiver", []string{app.ScopePolicyWrite}), http.HandlerFunc(s.approveWaiver)}, + {http.MethodPost, "/v1/approvals", op("createApproval", http.MethodPost, "/v1/approvals", "Create approval record", []string{app.ScopeReleaseWrite}), http.HandlerFunc(s.createApproval)}, + {http.MethodPost, "/v1/redaction-profiles", op("createRedactionProfile", http.MethodPost, "/v1/redaction-profiles", "Create redaction profile", []string{app.ScopePackageWrite}), http.HandlerFunc(s.createRedactionProfile)}, + {http.MethodPost, "/v1/customer-packages", op("createCustomerPackage", http.MethodPost, "/v1/customer-packages", "Create customer security package", []string{app.ScopePackageWrite}), http.HandlerFunc(s.createCustomerPackage)}, + {http.MethodGet, "/v1/customer-packages/{id}", op("getCustomerPackage", http.MethodGet, "/v1/customer-packages/{id}", "Get customer security package", []string{app.ScopePackageRead}), http.HandlerFunc(s.getCustomerPackage)}, + {http.MethodGet, "/v1/customer-packages/{id}/download", op("downloadCustomerPackage", http.MethodGet, "/v1/customer-packages/{id}/download", "Download customer security package ZIP", []string{app.ScopePackageRead}), http.HandlerFunc(s.downloadCustomerPackage)}, + {http.MethodPost, "/v1/customer-portal/access", op("createCustomerPortalAccess", http.MethodPost, "/v1/customer-portal/access", "Create customer portal access", []string{app.ScopePackageWrite}), http.HandlerFunc(s.createCustomerPortalAccess)}, + {http.MethodGet, "/v1/customer-portal/access", op("listCustomerPortalAccess", http.MethodGet, "/v1/customer-portal/access", "List customer portal access records", []string{app.ScopePackageRead}), http.HandlerFunc(s.listCustomerPortalAccess)}, + {http.MethodPost, "/v1/customer-portal/access/{id}/revoke", op("revokeCustomerPortalAccess", http.MethodPost, "/v1/customer-portal/access/{id}/revoke", "Revoke customer portal access", []string{app.ScopePackageWrite}), http.HandlerFunc(s.revokeCustomerPortalAccess)}, + {http.MethodPost, "/v1/customer-portal/package", op("accessCustomerPortalPackage", http.MethodPost, "/v1/customer-portal/package", "Access customer portal package", nil), http.HandlerFunc(s.accessCustomerPortalPackage)}, + {http.MethodPost, "/v1/customer-portal/package/download", op("downloadCustomerPortalPackage", http.MethodPost, "/v1/customer-portal/package/download", "Download customer portal package ZIP", nil), http.HandlerFunc(s.downloadCustomerPortalPackage)}, + {http.MethodGet, "/v1/customer-portal/package/view", op("customerPortalPackageViewForm", http.MethodGet, "/v1/customer-portal/package/view", "Customer portal package review form", nil), http.HandlerFunc(s.customerPortalPackageViewForm)}, + {http.MethodPost, "/v1/customer-portal/package/view", op("customerPortalPackageView", http.MethodPost, "/v1/customer-portal/package/view", "Customer portal package review page", nil), http.HandlerFunc(s.customerPortalPackageView)}, + {http.MethodPost, "/v1/customer-portal/package/view/download", op("downloadCustomerPortalPackageView", http.MethodPost, "/v1/customer-portal/package/view/download", "Download customer portal package ZIP from review form", nil), http.HandlerFunc(s.downloadCustomerPortalPackageView)}, + {http.MethodPost, "/v1/questionnaire-templates", op("createQuestionnaireTemplate", http.MethodPost, "/v1/questionnaire-templates", "Create questionnaire template", []string{app.ScopePackageWrite}), http.HandlerFunc(s.createQuestionnaireTemplate)}, + {http.MethodPost, "/v1/questionnaire-packages", op("createQuestionnairePackage", http.MethodPost, "/v1/questionnaire-packages", "Create questionnaire package", []string{app.ScopePackageWrite}), http.HandlerFunc(s.createQuestionnairePackage)}, + {http.MethodPost, "/v1/questionnaire-drafts", op("createQuestionnaireDraft", http.MethodPost, "/v1/questionnaire-drafts", "Create evidence-backed questionnaire draft", []string{app.ScopePackageRead}), http.HandlerFunc(s.createQuestionnaireDraft)}, + {http.MethodPost, "/v1/questionnaire-answer-library", op("createQuestionnaireAnswerLibraryEntry", http.MethodPost, "/v1/questionnaire-answer-library", "Create questionnaire answer library entry", []string{app.ScopePackageWrite}), http.HandlerFunc(s.createQuestionnaireAnswerLibraryEntry)}, + {http.MethodGet, "/v1/questionnaire-answer-library", op("listQuestionnaireAnswerLibrary", http.MethodGet, "/v1/questionnaire-answer-library", "List questionnaire answer library entries", []string{app.ScopePackageRead}), http.HandlerFunc(s.listQuestionnaireAnswerLibrary)}, + {http.MethodGet, "/v1/reports/security-review-package", op("securityReviewPackageReport", http.MethodGet, "/v1/reports/security-review-package", "Security review package report", []string{app.ScopePackageRead}), http.HandlerFunc(s.securityReviewPackageReport)}, + {http.MethodGet, "/v1/reports/cra-readiness-html", op("craReadinessHTMLPackage", http.MethodGet, "/v1/reports/cra-readiness-html", "CRA readiness HTML package", []string{app.ScopeReportRead}), http.HandlerFunc(s.craReadinessHTMLPackage)}, + {http.MethodPost, "/v1/reports/pdf", op("createPDFReportPackage", http.MethodPost, "/v1/reports/pdf", "Create reproducible PDF report package", []string{app.ScopeReportRead}), http.HandlerFunc(s.createPDFReportPackage)}, + {http.MethodPost, "/v1/report-templates", op("createReportTemplate", http.MethodPost, "/v1/report-templates", "Create report template", []string{app.ScopeReportRead}), http.HandlerFunc(s.createReportTemplate)}, + {http.MethodPost, "/v1/report-templates/{id}/render", op("renderReportTemplate", http.MethodPost, "/v1/report-templates/{id}/render", "Render report template", []string{app.ScopeReportRead}), http.HandlerFunc(s.renderReportTemplate)}, + } +} + +func (s *Server) evidenceRiskPolicyRoutes() []routeDef { + return []routeDef{ + {http.MethodPost, "/v1/evidence-bundles", op("exportEvidenceBundle", http.MethodPost, "/v1/evidence-bundles", "Export evidence bundle", []string{app.ScopeBundleRead}), http.HandlerFunc(s.exportEvidenceBundle)}, + {http.MethodPost, "/v1/evidence-bundles/import", op("importEvidenceBundle", http.MethodPost, "/v1/evidence-bundles/import", "Import evidence bundle", []string{app.ScopeBundleWrite}), http.HandlerFunc(s.importEvidenceBundle)}, + {http.MethodPost, "/v1/sboms/spdx", op("uploadSPDXSBOM", http.MethodPost, "/v1/sboms/spdx", "Upload SPDX SBOM", []string{app.ScopeEvidenceWrite}), http.HandlerFunc(s.uploadSPDXSBOM)}, + {http.MethodPost, "/v1/sbom-diffs", op("createSBOMDiff", http.MethodPost, "/v1/sbom-diffs", "Create SBOM diff", []string{app.ScopeEvidenceRead}), http.HandlerFunc(s.createSBOMDiff)}, + {http.MethodPost, "/v1/evidence", op("createEvidence", http.MethodPost, "/v1/evidence", "Create evidence", []string{app.ScopeEvidenceWrite}), http.HandlerFunc(s.createEvidence)}, + {http.MethodGet, "/v1/evidence", op("listEvidence", http.MethodGet, "/v1/evidence", "List evidence", []string{app.ScopeEvidenceRead}), http.HandlerFunc(s.listEvidence)}, + {http.MethodGet, "/v1/evidence/search", op("searchEvidence", http.MethodGet, "/v1/evidence/search", "Search evidence", []string{app.ScopeEvidenceRead}), http.HandlerFunc(s.searchEvidence)}, + {http.MethodPost, "/v1/evidence-summaries", op("createEvidenceSummary", http.MethodPost, "/v1/evidence-summaries", "Create evidence-backed summary", []string{app.ScopeReportRead}), http.HandlerFunc(s.createEvidenceSummary)}, + {http.MethodPost, "/v1/evidence-graph-snapshots", op("createGraphSnapshot", http.MethodPost, "/v1/evidence-graph-snapshots", "Create evidence graph snapshot", []string{app.ScopeEvidenceRead}), http.HandlerFunc(s.createGraphSnapshot)}, + {http.MethodGet, "/v1/evidence/{id}", op("getEvidence", http.MethodGet, "/v1/evidence/{id}", "Get evidence", []string{app.ScopeEvidenceRead}), http.HandlerFunc(s.getEvidence)}, + {http.MethodPost, "/v1/evidence/{id}/supersede", op("supersedeEvidence", http.MethodPost, "/v1/evidence/{id}/supersede", "Supersede evidence", []string{app.ScopeEvidenceWrite}), http.HandlerFunc(s.supersedeEvidence)}, + {http.MethodPost, "/v1/evidence/{id}/link", op("linkEvidence", http.MethodPost, "/v1/evidence/{id}/link", "Link evidence", []string{app.ScopeEvidenceWrite}), http.HandlerFunc(s.linkEvidence)}, + {http.MethodPost, "/v1/evidence/{id}/lifecycle-events", op("recordEvidenceLifecycleEvent", http.MethodPost, "/v1/evidence/{id}/lifecycle-events", "Record evidence lifecycle event", []string{app.ScopeEvidenceWrite}), http.HandlerFunc(s.recordEvidenceLifecycleEvent)}, + {http.MethodGet, "/v1/evidence/{id}/lifecycle-events", op("listEvidenceLifecycleEvents", http.MethodGet, "/v1/evidence/{id}/lifecycle-events", "List evidence lifecycle events", []string{app.ScopeEvidenceRead}), http.HandlerFunc(s.listEvidenceLifecycleEvents)}, + {http.MethodPost, "/v1/sboms", op("uploadSBOM", http.MethodPost, "/v1/sboms", "Upload CycloneDX SBOM", []string{app.ScopeEvidenceWrite}), http.HandlerFunc(s.uploadSBOM)}, + {http.MethodGet, "/v1/sboms/{id}", op("getSBOM", http.MethodGet, "/v1/sboms/{id}", "Get SBOM", []string{app.ScopeEvidenceRead}), http.HandlerFunc(s.getSBOM)}, + {http.MethodGet, "/v1/sbom-components", op("listSBOMComponents", http.MethodGet, "/v1/sbom-components", "List SBOM components", []string{app.ScopeEvidenceRead}), http.HandlerFunc(s.listSBOMComponents)}, + {http.MethodPost, "/v1/vex", op("uploadVEX", http.MethodPost, "/v1/vex", "Upload OpenVEX document", []string{app.ScopeEvidenceWrite}), http.HandlerFunc(s.uploadVEX)}, + {http.MethodPost, "/v1/vex/preview", readOnlyPostOp("previewVEXImport", http.MethodPost, "/v1/vex/preview", "Preview OpenVEX import", []string{app.ScopeEvidenceRead}), http.HandlerFunc(s.previewVEXImport)}, + {http.MethodPost, "/v1/vex/cyclonedx", op("uploadCycloneDXVEX", http.MethodPost, "/v1/vex/cyclonedx", "Upload CycloneDX VEX document", []string{app.ScopeEvidenceWrite}), http.HandlerFunc(s.uploadCycloneDXVEX)}, + {http.MethodPost, "/v1/vex/cyclonedx/preview", readOnlyPostOp("previewCycloneDXVEXImport", http.MethodPost, "/v1/vex/cyclonedx/preview", "Preview CycloneDX VEX import", []string{app.ScopeEvidenceRead}), http.HandlerFunc(s.previewCycloneDXVEXImport)}, + {http.MethodGet, "/v1/vex/{id}", op("getVEX", http.MethodGet, "/v1/vex/{id}", "Get VEX document", []string{app.ScopeEvidenceRead}), http.HandlerFunc(s.getVEX)}, + {http.MethodGet, "/v1/vex/{id}/import-report", op("getVEXImportReport", http.MethodGet, "/v1/vex/{id}/import-report", "Get VEX import report", []string{app.ScopeEvidenceRead}), http.HandlerFunc(s.getVEXImportReport)}, + {http.MethodPost, "/v1/vulnerability-scans", op("uploadVulnerabilityScan", http.MethodPost, "/v1/vulnerability-scans", "Upload vulnerability scan", []string{app.ScopeEvidenceWrite}), http.HandlerFunc(s.uploadVulnerabilityScan)}, + {http.MethodGet, "/v1/vulnerability-scans/{id}", op("getVulnerabilityScan", http.MethodGet, "/v1/vulnerability-scans/{id}", "Get vulnerability scan", []string{app.ScopeEvidenceRead}), http.HandlerFunc(s.getVulnerabilityScan)}, + {http.MethodPost, "/v1/vulnerability-findings/{id}/decisions", op("createVulnerabilityDecision", http.MethodPost, "/v1/vulnerability-findings/{id}/decisions", "Create vulnerability decision", []string{app.ScopeEvidenceWrite}), http.HandlerFunc(s.createVulnerabilityDecision)}, + {http.MethodGet, "/v1/vulnerability-decisions", op("listVulnerabilityDecisions", http.MethodGet, "/v1/vulnerability-decisions", "List vulnerability decisions", []string{app.ScopeEvidenceRead}), http.HandlerFunc(s.listVulnerabilityDecisions)}, + {http.MethodPost, "/v1/vulnerability-findings/{id}/workflow", op("recordVulnerabilityWorkflow", http.MethodPost, "/v1/vulnerability-findings/{id}/workflow", "Record vulnerability workflow event", []string{app.ScopeSecurityWrite}), http.HandlerFunc(s.recordVulnerabilityWorkflow)}, + {http.MethodGet, "/v1/reports/vulnerability-posture", op("vulnerabilityPostureReport", http.MethodGet, "/v1/reports/vulnerability-posture", "Vulnerability posture report", []string{app.ScopeSecurityRead}), http.HandlerFunc(s.vulnerabilityPostureReport)}, + {http.MethodGet, "/v1/reports/vulnerability-decision-summary", op("vulnerabilityDecisionSummaryReport", http.MethodGet, "/v1/reports/vulnerability-decision-summary", "Customer-safe vulnerability decision summary", []string{app.ScopeReportRead}), http.HandlerFunc(s.vulnerabilityDecisionSummaryReport)}, + {http.MethodPost, "/v1/openapi-contracts", op("uploadOpenAPIContract", http.MethodPost, "/v1/openapi-contracts", "Upload OpenAPI contract", []string{app.ScopeEvidenceWrite}), http.HandlerFunc(s.uploadOpenAPIContract)}, + {http.MethodGet, "/v1/openapi-contracts/{id}", op("getOpenAPIContract", http.MethodGet, "/v1/openapi-contracts/{id}", "Get OpenAPI contract", []string{app.ScopeEvidenceRead}), http.HandlerFunc(s.getOpenAPIContract)}, + {http.MethodPost, "/v1/openapi-diffs", op("createOpenAPIDiff", http.MethodPost, "/v1/openapi-diffs", "Create OpenAPI contract diff", []string{app.ScopeEvidenceRead}), http.HandlerFunc(s.createOpenAPIDiff)}, + {http.MethodPost, "/v1/policies/evaluate", op("evaluatePolicy", http.MethodPost, "/v1/policies/evaluate", "Evaluate release policy", []string{app.ScopeVerifyRead}), http.HandlerFunc(s.evaluatePolicy)}, + {http.MethodPost, "/v1/custom-policies", op("createCustomPolicy", http.MethodPost, "/v1/custom-policies", "Create custom policy", []string{app.ScopePolicyWrite}), http.HandlerFunc(s.createCustomPolicy)}, + {http.MethodPost, "/v1/custom-policies/{id}/evaluate", op("evaluateCustomPolicy", http.MethodPost, "/v1/custom-policies/{id}/evaluate", "Evaluate custom policy", []string{app.ScopePolicyRead}), http.HandlerFunc(s.evaluateCustomPolicy)}, + {http.MethodPost, "/v1/exceptions", op("createException", http.MethodPost, "/v1/exceptions", "Create exception", []string{app.ScopeReleaseWrite}), http.HandlerFunc(s.createException)}, + {http.MethodGet, "/v1/exceptions", op("listExceptions", http.MethodGet, "/v1/exceptions", "List exceptions", []string{app.ScopeVerifyRead}), http.HandlerFunc(s.listExceptions)}, + {http.MethodPost, "/v1/exceptions/{id}/approve", op("approveException", http.MethodPost, "/v1/exceptions/{id}/approve", "Approve exception", []string{app.ScopeReleaseWrite}), http.HandlerFunc(s.approveException)}, + {http.MethodGet, "/v1/reports/missing-evidence", op("missingEvidenceReport", http.MethodGet, "/v1/reports/missing-evidence", "Missing evidence report", []string{app.ScopeVerifyRead}), http.HandlerFunc(s.missingEvidenceReport)}, + {http.MethodGet, "/v1/reports/release-readiness", op("releaseReadinessReport", http.MethodGet, "/v1/reports/release-readiness", "Release readiness report", []string{app.ScopeVerifyRead}), http.HandlerFunc(s.releaseReadinessReport)}, + {http.MethodPost, "/v1/reports/anomaly", op("generateAnomalyReport", http.MethodPost, "/v1/reports/anomaly", "Generate deterministic anomaly report", []string{app.ScopeReportRead}), http.HandlerFunc(s.generateAnomalyReport)}, + } +} + +func (s *Server) integrityOpsRoutes() []routeDef { + return []routeDef{ + {http.MethodPost, "/v1/release-bundles", op("createReleaseBundle", http.MethodPost, "/v1/release-bundles", "Create release bundle", []string{app.ScopeBundleWrite}), http.HandlerFunc(s.createReleaseBundle)}, + {http.MethodGet, "/v1/release-bundles/{id}", op("getReleaseBundle", http.MethodGet, "/v1/release-bundles/{id}", "Get release bundle", []string{app.ScopeBundleRead}), http.HandlerFunc(s.getReleaseBundle)}, + {http.MethodGet, "/v1/release-bundles/{id}/manifest", op("getReleaseBundleManifest", http.MethodGet, "/v1/release-bundles/{id}/manifest", "Get release bundle manifest", []string{app.ScopeBundleRead}), http.HandlerFunc(s.getReleaseBundleManifest)}, + {http.MethodGet, "/v1/release-bundles/{id}/verify", op("verifyReleaseBundle", http.MethodGet, "/v1/release-bundles/{id}/verify", "Verify release bundle", []string{app.ScopeVerifyRead}), http.HandlerFunc(s.verifyReleaseBundle)}, + {http.MethodGet, "/v1/audit-chain/verify", op("verifyAuditChain", http.MethodGet, "/v1/audit-chain/verify", "Verify audit chain", []string{app.ScopeVerifyRead}), http.HandlerFunc(s.verifyAuditChain)}, + {http.MethodGet, "/v1/audit-log", op("listAuditLog", http.MethodGet, "/v1/audit-log", "List tenant audit log", []string{app.ScopeAdmin}), http.HandlerFunc(s.listAuditLog)}, + {http.MethodPost, "/v1/merkle-batches", op("createMerkleBatch", http.MethodPost, "/v1/merkle-batches", "Create Merkle checkpoint batch", []string{app.ScopeKeysAdmin}), http.HandlerFunc(s.createMerkleBatch)}, + {http.MethodGet, "/v1/merkle-batches/{id}/verify", op("verifyMerkleBatch", http.MethodGet, "/v1/merkle-batches/{id}/verify", "Verify Merkle checkpoint batch", []string{app.ScopeVerifyRead}), http.HandlerFunc(s.verifyMerkleBatch)}, + {http.MethodPost, "/v1/transparency-checkpoints", op("createTransparencyCheckpoint", http.MethodPost, "/v1/transparency-checkpoints", "Record external transparency checkpoint", []string{app.ScopeKeysAdmin}), http.HandlerFunc(s.createTransparencyCheckpoint)}, + {http.MethodPost, "/v1/public-transparency-logs", op("createPublicTransparencyLog", http.MethodPost, "/v1/public-transparency-logs", "Create public transparency log record", []string{app.ScopeKeysAdmin}), http.HandlerFunc(s.createPublicTransparencyLog)}, + {http.MethodPost, "/v1/public-transparency-log-entries", op("publishPublicTransparencyLogEntry", http.MethodPost, "/v1/public-transparency-log-entries", "Publish public transparency log entry record", []string{app.ScopeKeysAdmin}), http.HandlerFunc(s.publishPublicTransparencyLogEntry)}, + {http.MethodPost, "/v1/public-transparency-log-entries/{id}/verify", op("verifyPublicTransparencyLogEntry", http.MethodPost, "/v1/public-transparency-log-entries/{id}/verify", "Verify public transparency log inclusion proof", []string{app.ScopeKeysAdmin}), http.HandlerFunc(s.verifyPublicTransparencyLogEntry)}, + {http.MethodPost, "/v1/public-transparency-log-entries/{id}/fetch-proof", op("fetchPublicTransparencyLogEntryProof", http.MethodPost, "/v1/public-transparency-log-entries/{id}/fetch-proof", "Fetch and verify public transparency log inclusion proof", []string{app.ScopeKeysAdmin}), http.HandlerFunc(s.fetchPublicTransparencyLogEntryProof)}, + {http.MethodPost, "/v1/object-retention-policies", op("createObjectRetentionPolicy", http.MethodPost, "/v1/object-retention-policies", "Create object retention policy record", []string{app.ScopeAdmin}), http.HandlerFunc(s.createObjectRetentionPolicy)}, + {http.MethodPost, "/v1/object-retention-policies/{id}/verify", op("verifyObjectRetentionPolicy", http.MethodPost, "/v1/object-retention-policies/{id}/verify", "Verify object retention policy record", []string{app.ScopeVerifyRead}), http.HandlerFunc(s.verifyObjectRetentionPolicy)}, + {http.MethodPost, "/v1/legal-holds", op("createLegalHold", http.MethodPost, "/v1/legal-holds", "Create legal hold", []string{app.ScopeAdmin}), http.HandlerFunc(s.createLegalHold)}, + {http.MethodPost, "/v1/retention-overrides", op("createRetentionOverride", http.MethodPost, "/v1/retention-overrides", "Create retention override", []string{app.ScopeAdmin}), http.HandlerFunc(s.createRetentionOverride)}, + {http.MethodGet, "/v1/reports/retention", op("retentionReport", http.MethodGet, "/v1/reports/retention", "Retention report", []string{app.ScopeAdmin}), http.HandlerFunc(s.retentionReport)}, + {http.MethodGet, "/v1/reports/custody-review", op("signingCustodyReviewReport", http.MethodGet, "/v1/reports/custody-review", "Signing custody review report", []string{app.ScopeKeysAdmin}), http.HandlerFunc(s.signingCustodyReviewReport)}, + {http.MethodPost, "/v1/backup-manifests", op("generateBackupManifest", http.MethodPost, "/v1/backup-manifests", "Generate backup manifest", []string{app.ScopeAdmin}), http.HandlerFunc(s.generateBackupManifest)}, + {http.MethodGet, "/v1/backup-manifests/{id}/verify", op("verifyBackupManifest", http.MethodGet, "/v1/backup-manifests/{id}/verify", "Verify backup manifest", []string{app.ScopeVerifyRead}), http.HandlerFunc(s.verifyBackupManifest)}, + } +} + +func (s *Server) keyAndAdminRoutes() []routeDef { + return []routeDef{ + {http.MethodGet, "/v1/signing-keys", op("listSigningKeys", http.MethodGet, "/v1/signing-keys", "List signing keys", []string{app.ScopeVerifyRead}), http.HandlerFunc(s.listSigningKeys)}, + {http.MethodPost, "/v1/signing-keys/rotate", op("rotateSigningKey", http.MethodPost, "/v1/signing-keys/rotate", "Rotate signing key", []string{app.ScopeKeysAdmin}), http.HandlerFunc(s.rotateSigningKey)}, + {http.MethodPost, "/v1/signing-keys/{id}/revoke", op("revokeSigningKey", http.MethodPost, "/v1/signing-keys/{id}/revoke", "Revoke signing key", []string{app.ScopeKeysAdmin}), http.HandlerFunc(s.revokeSigningKey)}, + {http.MethodPost, "/v1/signing-providers", op("createSigningProvider", http.MethodPost, "/v1/signing-providers", "Create signing provider record", []string{app.ScopeKeysAdmin}), http.HandlerFunc(s.createSigningProvider)}, + {http.MethodPost, "/v1/signing-operations", op("createSigningOperation", http.MethodPost, "/v1/signing-operations", "Create signing provider operation receipt", []string{app.ScopeKeysAdmin}), http.HandlerFunc(s.createSigningOperation)}, + {http.MethodPost, "/v1/provider-verifications", op("verifyProviderIdentity", http.MethodPost, "/v1/provider-verifications", "Verify stored provider identity metadata", []string{app.ScopeIdentityAdmin}), http.HandlerFunc(s.verifyProviderIdentity)}, + {http.MethodPost, "/v1/saas/profiles", op("createSaaSEditionProfile", http.MethodPost, "/v1/saas/profiles", "Create SaaS edition profile", []string{app.ScopeInstanceAdmin}), http.HandlerFunc(s.createSaaSEditionProfile)}, + {http.MethodPost, "/v1/verify", op("verify", http.MethodPost, "/v1/verify", "Verify subject", []string{app.ScopeVerifyRead}), http.HandlerFunc(s.verifySubject)}, + {http.MethodPost, "/v1/api-keys", op("createAPIKey", http.MethodPost, "/v1/api-keys", "Create API key", []string{app.ScopeAdmin}), http.HandlerFunc(s.createAPIKey)}, + {http.MethodGet, "/v1/api-keys", op("listAPIKeys", http.MethodGet, "/v1/api-keys", "List API keys", []string{app.ScopeAdmin}), http.HandlerFunc(s.listAPIKeys)}, + } +} diff --git a/internal/adapters/httpapi/router.go b/internal/adapters/httpapi/router.go index 011bddd..b120303 100644 --- a/internal/adapters/httpapi/router.go +++ b/internal/adapters/httpapi/router.go @@ -2,12 +2,17 @@ package httpapi import ( "bytes" + "context" + "crypto/rand" + "encoding/hex" "encoding/json" "errors" "io" + "net" "net/http" "strconv" "strings" + "sync" "time" "github.com/aatuh/api-toolkit/v3/httpx" @@ -19,16 +24,28 @@ import ( "github.com/aatuh/evydence/internal/domain" ) +type requestContext = context.Context + const maxJSONBody = 2 << 20 +const requestIDHeader = "X-Request-ID" type Server struct { - ledger *app.Ledger - mux *http.ServeMux - specs *specs.Registry - routes *routecontracts.Registry + ledger *app.Ledger + mux *http.ServeMux + specs *specs.Registry + routes *routecontracts.Registry + limiter *requestRateLimiter +} + +type ServerOptions struct { + RateLimitRequestsPerMinute int } func NewServer(ledger *app.Ledger) (*Server, error) { + return NewServerWithOptions(ledger, ServerOptions{}) +} + +func NewServerWithOptions(ledger *app.Ledger, opts ServerOptions) (*Server, error) { if ledger == nil { ledger = app.NewLedger(app.Config{}) } @@ -36,7 +53,7 @@ func NewServer(ledger *app.Ledger) (*Server, error) { specRegistry := NewSpecRegistry() router := &serveMuxRouter{mux: mux} routeRegistry := routecontracts.NewRegistry(router, specRegistry) - server := &Server{ledger: ledger, mux: mux, specs: specRegistry, routes: routeRegistry} + server := &Server{ledger: ledger, mux: mux, specs: specRegistry, routes: routeRegistry, limiter: newRequestRateLimiter(opts.RateLimitRequestsPerMinute)} if err := server.registerRoutes(); err != nil { return nil, err } @@ -44,7 +61,7 @@ func NewServer(ledger *app.Ledger) (*Server, error) { } func (s *Server) Handler() http.Handler { - return secureHeaders(s.mux) + return secureHeaders(requestIDMiddleware(s.rateLimitMiddleware(s.mux))) } func (s *Server) OpenAPI() ([]byte, error) { @@ -55,341 +72,6 @@ func (s *Server) ValidateRoutes() error { return s.routes.Validate() } -func (s *Server) registerRoutes() error { - routes := []struct { - method string - path string - op specs.Operation - handler http.Handler - }{ - {http.MethodGet, "/v1/health", op("health", http.MethodGet, "/v1/health", "Health", nil), http.HandlerFunc(s.health)}, - {http.MethodGet, "/v1/ready", op("ready", http.MethodGet, "/v1/ready", "Readiness", nil), http.HandlerFunc(s.ready)}, - {http.MethodGet, "/v1/version", op("version", http.MethodGet, "/v1/version", "Version", nil), http.HandlerFunc(s.version)}, - {http.MethodGet, "/v1/metrics", op("metrics", http.MethodGet, "/v1/metrics", "Safe tenant metrics", []string{app.ScopeAdmin}), http.HandlerFunc(s.metrics)}, - {http.MethodGet, "/v1/openapi.json", op("openapi", http.MethodGet, "/v1/openapi.json", "OpenAPI", nil), http.HandlerFunc(s.openapi)}, - {http.MethodGet, "/v1/admin/instance", op("instanceAdminSnapshot", http.MethodGet, "/v1/admin/instance", "Instance admin snapshot", []string{app.ScopeInstanceAdmin}), http.HandlerFunc(s.instanceAdminSnapshot)}, - {http.MethodPost, "/v1/organizations", op("createOrganization", http.MethodPost, "/v1/organizations", "Create organization", []string{app.ScopeIdentityAdmin}), http.HandlerFunc(s.createOrganization)}, - {http.MethodPost, "/v1/users", op("createUser", http.MethodPost, "/v1/users", "Create user", []string{app.ScopeIdentityAdmin}), http.HandlerFunc(s.createUser)}, - {http.MethodPost, "/v1/users/{id}/deactivate", op("deactivateUser", http.MethodPost, "/v1/users/{id}/deactivate", "Deactivate user", []string{app.ScopeIdentityAdmin}), http.HandlerFunc(s.deactivateUser)}, - {http.MethodPost, "/v1/role-bindings", op("createRoleBinding", http.MethodPost, "/v1/role-bindings", "Create role binding", []string{app.ScopeIdentityAdmin}), http.HandlerFunc(s.createRoleBinding)}, - {http.MethodGet, "/v1/role-bindings", op("listRoleBindings", http.MethodGet, "/v1/role-bindings", "List role bindings", []string{app.ScopeIdentityAdmin}), http.HandlerFunc(s.listRoleBindings)}, - {http.MethodPost, "/v1/sso/providers", op("createSSOProvider", http.MethodPost, "/v1/sso/providers", "Create SSO provider", []string{app.ScopeIdentityAdmin}), http.HandlerFunc(s.createSSOProvider)}, - {http.MethodPost, "/v1/sso/identity-links", op("linkSSOIdentity", http.MethodPost, "/v1/sso/identity-links", "Link SSO identity", []string{app.ScopeIdentityAdmin}), http.HandlerFunc(s.linkSSOIdentity)}, - {http.MethodPost, "/v1/sso/sessions", op("createSSOSession", http.MethodPost, "/v1/sso/sessions", "Create SSO session", []string{app.ScopeIdentityAdmin}), http.HandlerFunc(s.createSSOSession)}, - {http.MethodPost, "/v1/sso/sessions/{id}/revoke", op("revokeSSOSession", http.MethodPost, "/v1/sso/sessions/{id}/revoke", "Revoke SSO session", []string{app.ScopeIdentityAdmin}), http.HandlerFunc(s.revokeSSOSession)}, - {http.MethodPost, "/v1/collectors", op("createCollector", http.MethodPost, "/v1/collectors", "Create collector", []string{app.ScopeCollectorAdmin}), http.HandlerFunc(s.createCollector)}, - {http.MethodGet, "/v1/collectors", op("listCollectors", http.MethodGet, "/v1/collectors", "List collectors", []string{app.ScopeCollectorRead}), http.HandlerFunc(s.listCollectors)}, - {http.MethodPost, "/v1/collectors/{id}/releases", op("recordCollectorRelease", http.MethodPost, "/v1/collectors/{id}/releases", "Record collector release evidence", []string{app.ScopeCollectorAdmin}), http.HandlerFunc(s.recordCollectorRelease)}, - {http.MethodGet, "/v1/collectors/{id}/health", op("collectorHealthReport", http.MethodGet, "/v1/collectors/{id}/health", "Collector health report", []string{app.ScopeCollectorRead}), http.HandlerFunc(s.collectorHealthReport)}, - {http.MethodPost, "/v1/control-frameworks", op("createControlFramework", http.MethodPost, "/v1/control-frameworks", "Create control framework", []string{app.ScopeControlsAdmin}), http.HandlerFunc(s.createControlFramework)}, - {http.MethodGet, "/v1/control-frameworks", op("listControlFrameworks", http.MethodGet, "/v1/control-frameworks", "List control frameworks", []string{app.ScopeControlsRead}), http.HandlerFunc(s.listControlFrameworks)}, - {http.MethodGet, "/v1/control-framework-template-packs", op("listControlFrameworkTemplatePacks", http.MethodGet, "/v1/control-framework-template-packs", "List control framework template packs", []string{app.ScopeControlsRead}), http.HandlerFunc(s.listControlFrameworkTemplatePacks)}, - {http.MethodPost, "/v1/control-framework-template-packs/{slug}/install", op("installControlFrameworkTemplatePack", http.MethodPost, "/v1/control-framework-template-packs/{slug}/install", "Install control framework template pack", []string{app.ScopeControlsAdmin}), http.HandlerFunc(s.installControlFrameworkTemplatePack)}, - {http.MethodPost, "/v1/controls", op("createSecurityControl", http.MethodPost, "/v1/controls", "Create security control", []string{app.ScopeControlsAdmin}), http.HandlerFunc(s.createSecurityControl)}, - {http.MethodGet, "/v1/controls/{id}", op("getSecurityControl", http.MethodGet, "/v1/controls/{id}", "Get security control", []string{app.ScopeControlsRead}), http.HandlerFunc(s.getSecurityControl)}, - {http.MethodPost, "/v1/controls/{id}/evidence", op("linkControlEvidence", http.MethodPost, "/v1/controls/{id}/evidence", "Link control evidence", []string{app.ScopeControlsWrite}), http.HandlerFunc(s.linkControlEvidence)}, - {http.MethodGet, "/v1/control-evidence", op("listControlEvidence", http.MethodGet, "/v1/control-evidence", "List control evidence", []string{app.ScopeControlsRead}), http.HandlerFunc(s.listControlEvidence)}, - {http.MethodPost, "/v1/products", op("createProduct", http.MethodPost, "/v1/products", "Create product", []string{app.ScopeProductWrite}), http.HandlerFunc(s.createProduct)}, - {http.MethodGet, "/v1/products", op("listProducts", http.MethodGet, "/v1/products", "List products", []string{app.ScopeProductRead}), http.HandlerFunc(s.listProducts)}, - {http.MethodPost, "/v1/projects", op("createProject", http.MethodPost, "/v1/projects", "Create project", []string{app.ScopeProjectWrite}), http.HandlerFunc(s.createProject)}, - {http.MethodPost, "/v1/releases", op("createRelease", http.MethodPost, "/v1/releases", "Create release", []string{app.ScopeReleaseWrite}), http.HandlerFunc(s.createRelease)}, - {http.MethodGet, "/v1/releases/{id}", op("getRelease", http.MethodGet, "/v1/releases/{id}", "Get release", []string{app.ScopeReleaseRead}), http.HandlerFunc(s.getRelease)}, - {http.MethodPost, "/v1/releases/{id}/freeze", op("freezeRelease", http.MethodPost, "/v1/releases/{id}/freeze", "Freeze release", []string{app.ScopeReleaseWrite}), http.HandlerFunc(s.freezeRelease)}, - {http.MethodPost, "/v1/releases/{id}/approve", op("approveRelease", http.MethodPost, "/v1/releases/{id}/approve", "Approve release", []string{app.ScopeReleaseWrite}), http.HandlerFunc(s.approveRelease)}, - {http.MethodPost, "/v1/release-candidates", op("createReleaseCandidate", http.MethodPost, "/v1/release-candidates", "Create release candidate", []string{app.ScopeReleaseWrite}), http.HandlerFunc(s.createReleaseCandidate)}, - {http.MethodGet, "/v1/release-candidates", op("listReleaseCandidates", http.MethodGet, "/v1/release-candidates", "List release candidates", []string{app.ScopeReleaseRead}), http.HandlerFunc(s.listReleaseCandidates)}, - {http.MethodGet, "/v1/release-candidates/{id}", op("getReleaseCandidate", http.MethodGet, "/v1/release-candidates/{id}", "Get release candidate", []string{app.ScopeReleaseRead}), http.HandlerFunc(s.getReleaseCandidate)}, - {http.MethodPost, "/v1/release-candidates/{id}/promote", op("promoteReleaseCandidate", http.MethodPost, "/v1/release-candidates/{id}/promote", "Promote release candidate", []string{app.ScopeReleaseWrite}), http.HandlerFunc(s.promoteReleaseCandidate)}, - {http.MethodPost, "/v1/release-candidates/{id}/reject", op("rejectReleaseCandidate", http.MethodPost, "/v1/release-candidates/{id}/reject", "Reject release candidate", []string{app.ScopeReleaseWrite}), http.HandlerFunc(s.rejectReleaseCandidate)}, - {http.MethodPost, "/v1/artifacts", op("registerArtifact", http.MethodPost, "/v1/artifacts", "Register artifact", []string{app.ScopeEvidenceWrite}), http.HandlerFunc(s.registerArtifact)}, - {http.MethodPost, "/v1/container-images", op("registerContainerImage", http.MethodPost, "/v1/container-images", "Register container image", []string{app.ScopeEvidenceWrite}), http.HandlerFunc(s.registerContainerImage)}, - {http.MethodPost, "/v1/artifact-signatures", op("createArtifactSignature", http.MethodPost, "/v1/artifact-signatures", "Create artifact signature", []string{app.ScopeEvidenceWrite}), http.HandlerFunc(s.createArtifactSignature)}, - {http.MethodGet, "/v1/artifact-signatures/{id}", op("getArtifactSignature", http.MethodGet, "/v1/artifact-signatures/{id}", "Get artifact signature", []string{app.ScopeEvidenceRead}), http.HandlerFunc(s.getArtifactSignature)}, - {http.MethodPost, "/v1/artifact-signatures/{id}/verify-cosign", op("verifyCosignSignature", http.MethodPost, "/v1/artifact-signatures/{id}/verify-cosign", "Verify cosign-style artifact signature", []string{app.ScopeVerifyRead}), http.HandlerFunc(s.verifyCosignSignature)}, - {http.MethodPost, "/v1/builds", op("createBuild", http.MethodPost, "/v1/builds", "Create build run", []string{app.ScopeBuildWrite}), http.HandlerFunc(s.createBuild)}, - {http.MethodGet, "/v1/builds/{id}", op("getBuild", http.MethodGet, "/v1/builds/{id}", "Get build run", []string{app.ScopeBuildRead}), http.HandlerFunc(s.getBuild)}, - {http.MethodPost, "/v1/builds/{id}/attestations", op("uploadBuildAttestation", http.MethodPost, "/v1/builds/{id}/attestations", "Upload build attestation", []string{app.ScopeBuildWrite}), http.HandlerFunc(s.uploadBuildAttestation)}, - {http.MethodPost, "/v1/build-attestations/{id}/verify-signature", op("verifyBuildAttestationSignature", http.MethodPost, "/v1/build-attestations/{id}/verify-signature", "Verify build attestation signature", []string{app.ScopeVerifyRead}), http.HandlerFunc(s.verifyBuildAttestationSignature)}, - {http.MethodPost, "/v1/dsse-trust-roots", op("createDSSETrustRoot", http.MethodPost, "/v1/dsse-trust-roots", "Create DSSE trust root", []string{app.ScopeKeysAdmin}), http.HandlerFunc(s.createDSSETrustRoot)}, - {http.MethodPost, "/v1/source/repositories", op("createSourceRepository", http.MethodPost, "/v1/source/repositories", "Create source repository", []string{app.ScopeSourceWrite}), http.HandlerFunc(s.createSourceRepository)}, - {http.MethodGet, "/v1/source/repositories", op("listSourceRepositories", http.MethodGet, "/v1/source/repositories", "List source repositories", []string{app.ScopeSourceRead}), http.HandlerFunc(s.listSourceRepositories)}, - {http.MethodPost, "/v1/source/commits", op("recordSourceCommit", http.MethodPost, "/v1/source/commits", "Record source commit", []string{app.ScopeSourceWrite}), http.HandlerFunc(s.recordSourceCommit)}, - {http.MethodPost, "/v1/source/branches", op("upsertSourceBranch", http.MethodPost, "/v1/source/branches", "Record source branch", []string{app.ScopeSourceWrite}), http.HandlerFunc(s.upsertSourceBranch)}, - {http.MethodPost, "/v1/source/pull-requests", op("recordPullRequest", http.MethodPost, "/v1/source/pull-requests", "Record pull request", []string{app.ScopeSourceWrite}), http.HandlerFunc(s.recordPullRequest)}, - {http.MethodPost, "/v1/collectors/github/source-snapshots", op("uploadGitHubSourceSnapshot", http.MethodPost, "/v1/collectors/github/source-snapshots", "Upload GitHub source snapshot", []string{app.ScopeSourceWrite}), http.HandlerFunc(s.uploadGitHubSourceSnapshot)}, - {http.MethodPost, "/v1/collectors/gitlab/source-snapshots", op("uploadGitLabSourceSnapshot", http.MethodPost, "/v1/collectors/gitlab/source-snapshots", "Upload GitLab source snapshot", []string{app.ScopeSourceWrite}), http.HandlerFunc(s.uploadGitLabSourceSnapshot)}, - {http.MethodPost, "/v1/environments", op("createDeploymentEnvironment", http.MethodPost, "/v1/environments", "Create deployment environment", []string{app.ScopeDeploymentWrite}), http.HandlerFunc(s.createDeploymentEnvironment)}, - {http.MethodGet, "/v1/environments", op("listDeploymentEnvironments", http.MethodGet, "/v1/environments", "List deployment environments", []string{app.ScopeDeploymentRead}), http.HandlerFunc(s.listDeploymentEnvironments)}, - {http.MethodPost, "/v1/deployments", op("recordDeployment", http.MethodPost, "/v1/deployments", "Record deployment", []string{app.ScopeDeploymentWrite}), http.HandlerFunc(s.recordDeployment)}, - {http.MethodGet, "/v1/deployments", op("listDeployments", http.MethodGet, "/v1/deployments", "List deployments", []string{app.ScopeDeploymentRead}), http.HandlerFunc(s.listDeployments)}, - {http.MethodGet, "/v1/deployments/{id}", op("getDeployment", http.MethodGet, "/v1/deployments/{id}", "Get deployment", []string{app.ScopeDeploymentRead}), http.HandlerFunc(s.getDeployment)}, - {http.MethodPost, "/v1/incidents", op("createIncident", http.MethodPost, "/v1/incidents", "Create incident", []string{app.ScopeIncidentWrite}), http.HandlerFunc(s.createIncident)}, - {http.MethodPost, "/v1/incidents/{id}/timeline", op("recordIncidentTimeline", http.MethodPost, "/v1/incidents/{id}/timeline", "Record incident timeline event", []string{app.ScopeIncidentWrite}), http.HandlerFunc(s.recordIncidentTimeline)}, - {http.MethodPost, "/v1/remediation-tasks", op("createRemediationTask", http.MethodPost, "/v1/remediation-tasks", "Create remediation task", []string{app.ScopeIncidentWrite}), http.HandlerFunc(s.createRemediationTask)}, - {http.MethodGet, "/v1/reports/incident-package", op("incidentReport", http.MethodGet, "/v1/reports/incident-package", "Incident package report", []string{app.ScopeIncidentRead}), http.HandlerFunc(s.incidentReport)}, - {http.MethodPost, "/v1/security-scans", op("uploadSecurityScan", http.MethodPost, "/v1/security-scans", "Upload security scan", []string{app.ScopeSecurityWrite}), http.HandlerFunc(s.uploadSecurityScan)}, - {http.MethodPost, "/v1/api-security-scans", op("uploadAPISecurityScan", http.MethodPost, "/v1/api-security-scans", "Upload API security scan", []string{app.ScopeSecurityWrite}), http.HandlerFunc(s.uploadAPISecurityScan)}, - {http.MethodPost, "/v1/security-documents", op("uploadManualSecurityDocument", http.MethodPost, "/v1/security-documents", "Upload manual security document", []string{app.ScopeSecurityWrite}), http.HandlerFunc(s.uploadManualSecurityDocument)}, - {http.MethodPost, "/v1/waivers", op("createWaiver", http.MethodPost, "/v1/waivers", "Create waiver", []string{app.ScopePolicyWrite}), http.HandlerFunc(s.createWaiver)}, - {http.MethodPost, "/v1/waivers/{id}/approve", op("approveWaiver", http.MethodPost, "/v1/waivers/{id}/approve", "Approve waiver", []string{app.ScopePolicyWrite}), http.HandlerFunc(s.approveWaiver)}, - {http.MethodPost, "/v1/approvals", op("createApproval", http.MethodPost, "/v1/approvals", "Create approval record", []string{app.ScopeReleaseWrite}), http.HandlerFunc(s.createApproval)}, - {http.MethodPost, "/v1/redaction-profiles", op("createRedactionProfile", http.MethodPost, "/v1/redaction-profiles", "Create redaction profile", []string{app.ScopePackageWrite}), http.HandlerFunc(s.createRedactionProfile)}, - {http.MethodPost, "/v1/customer-packages", op("createCustomerPackage", http.MethodPost, "/v1/customer-packages", "Create customer security package", []string{app.ScopePackageWrite}), http.HandlerFunc(s.createCustomerPackage)}, - {http.MethodGet, "/v1/customer-packages/{id}", op("getCustomerPackage", http.MethodGet, "/v1/customer-packages/{id}", "Get customer security package", []string{app.ScopePackageRead}), http.HandlerFunc(s.getCustomerPackage)}, - {http.MethodPost, "/v1/customer-portal/access", op("createCustomerPortalAccess", http.MethodPost, "/v1/customer-portal/access", "Create customer portal access", []string{app.ScopePackageWrite}), http.HandlerFunc(s.createCustomerPortalAccess)}, - {http.MethodPost, "/v1/customer-portal/package", op("accessCustomerPortalPackage", http.MethodPost, "/v1/customer-portal/package", "Access customer portal package", nil), http.HandlerFunc(s.accessCustomerPortalPackage)}, - {http.MethodPost, "/v1/questionnaire-templates", op("createQuestionnaireTemplate", http.MethodPost, "/v1/questionnaire-templates", "Create questionnaire template", []string{app.ScopePackageWrite}), http.HandlerFunc(s.createQuestionnaireTemplate)}, - {http.MethodPost, "/v1/questionnaire-packages", op("createQuestionnairePackage", http.MethodPost, "/v1/questionnaire-packages", "Create questionnaire package", []string{app.ScopePackageWrite}), http.HandlerFunc(s.createQuestionnairePackage)}, - {http.MethodGet, "/v1/reports/security-review-package", op("securityReviewPackageReport", http.MethodGet, "/v1/reports/security-review-package", "Security review package report", []string{app.ScopePackageRead}), http.HandlerFunc(s.securityReviewPackageReport)}, - {http.MethodGet, "/v1/reports/cra-readiness-html", op("craReadinessHTMLPackage", http.MethodGet, "/v1/reports/cra-readiness-html", "CRA readiness HTML package", []string{app.ScopeReportRead}), http.HandlerFunc(s.craReadinessHTMLPackage)}, - {http.MethodPost, "/v1/report-templates", op("createReportTemplate", http.MethodPost, "/v1/report-templates", "Create report template", []string{app.ScopeReportRead}), http.HandlerFunc(s.createReportTemplate)}, - {http.MethodPost, "/v1/report-templates/{id}/render", op("renderReportTemplate", http.MethodPost, "/v1/report-templates/{id}/render", "Render report template", []string{app.ScopeReportRead}), http.HandlerFunc(s.renderReportTemplate)}, - {http.MethodPost, "/v1/evidence-bundles", op("exportEvidenceBundle", http.MethodPost, "/v1/evidence-bundles", "Export evidence bundle", []string{app.ScopeBundleRead}), http.HandlerFunc(s.exportEvidenceBundle)}, - {http.MethodPost, "/v1/evidence-bundles/import", op("importEvidenceBundle", http.MethodPost, "/v1/evidence-bundles/import", "Import evidence bundle", []string{app.ScopeBundleWrite}), http.HandlerFunc(s.importEvidenceBundle)}, - {http.MethodPost, "/v1/sboms/spdx", op("uploadSPDXSBOM", http.MethodPost, "/v1/sboms/spdx", "Upload SPDX SBOM", []string{app.ScopeEvidenceWrite}), http.HandlerFunc(s.uploadSPDXSBOM)}, - {http.MethodPost, "/v1/sbom-diffs", op("createSBOMDiff", http.MethodPost, "/v1/sbom-diffs", "Create SBOM diff", []string{app.ScopeEvidenceRead}), http.HandlerFunc(s.createSBOMDiff)}, - {http.MethodPost, "/v1/evidence", op("createEvidence", http.MethodPost, "/v1/evidence", "Create evidence", []string{app.ScopeEvidenceWrite}), http.HandlerFunc(s.createEvidence)}, - {http.MethodGet, "/v1/evidence", op("listEvidence", http.MethodGet, "/v1/evidence", "List evidence", []string{app.ScopeEvidenceRead}), http.HandlerFunc(s.listEvidence)}, - {http.MethodGet, "/v1/evidence/search", op("searchEvidence", http.MethodGet, "/v1/evidence/search", "Search evidence", []string{app.ScopeEvidenceRead}), http.HandlerFunc(s.searchEvidence)}, - {http.MethodGet, "/v1/evidence/{id}", op("getEvidence", http.MethodGet, "/v1/evidence/{id}", "Get evidence", []string{app.ScopeEvidenceRead}), http.HandlerFunc(s.getEvidence)}, - {http.MethodPost, "/v1/evidence/{id}/supersede", op("supersedeEvidence", http.MethodPost, "/v1/evidence/{id}/supersede", "Supersede evidence", []string{app.ScopeEvidenceWrite}), http.HandlerFunc(s.supersedeEvidence)}, - {http.MethodPost, "/v1/evidence/{id}/link", op("linkEvidence", http.MethodPost, "/v1/evidence/{id}/link", "Link evidence", []string{app.ScopeEvidenceWrite}), http.HandlerFunc(s.linkEvidence)}, - {http.MethodPost, "/v1/evidence/{id}/lifecycle-events", op("recordEvidenceLifecycleEvent", http.MethodPost, "/v1/evidence/{id}/lifecycle-events", "Record evidence lifecycle event", []string{app.ScopeEvidenceWrite}), http.HandlerFunc(s.recordEvidenceLifecycleEvent)}, - {http.MethodGet, "/v1/evidence/{id}/lifecycle-events", op("listEvidenceLifecycleEvents", http.MethodGet, "/v1/evidence/{id}/lifecycle-events", "List evidence lifecycle events", []string{app.ScopeEvidenceRead}), http.HandlerFunc(s.listEvidenceLifecycleEvents)}, - {http.MethodPost, "/v1/sboms", op("uploadSBOM", http.MethodPost, "/v1/sboms", "Upload CycloneDX SBOM", []string{app.ScopeEvidenceWrite}), http.HandlerFunc(s.uploadSBOM)}, - {http.MethodGet, "/v1/sboms/{id}", op("getSBOM", http.MethodGet, "/v1/sboms/{id}", "Get SBOM", []string{app.ScopeEvidenceRead}), http.HandlerFunc(s.getSBOM)}, - {http.MethodPost, "/v1/vex", op("uploadVEX", http.MethodPost, "/v1/vex", "Upload OpenVEX document", []string{app.ScopeEvidenceWrite}), http.HandlerFunc(s.uploadVEX)}, - {http.MethodPost, "/v1/vex/cyclonedx", op("uploadCycloneDXVEX", http.MethodPost, "/v1/vex/cyclonedx", "Upload CycloneDX VEX document", []string{app.ScopeEvidenceWrite}), http.HandlerFunc(s.uploadCycloneDXVEX)}, - {http.MethodGet, "/v1/vex/{id}", op("getVEX", http.MethodGet, "/v1/vex/{id}", "Get VEX document", []string{app.ScopeEvidenceRead}), http.HandlerFunc(s.getVEX)}, - {http.MethodPost, "/v1/vulnerability-scans", op("uploadVulnerabilityScan", http.MethodPost, "/v1/vulnerability-scans", "Upload vulnerability scan", []string{app.ScopeEvidenceWrite}), http.HandlerFunc(s.uploadVulnerabilityScan)}, - {http.MethodGet, "/v1/vulnerability-scans/{id}", op("getVulnerabilityScan", http.MethodGet, "/v1/vulnerability-scans/{id}", "Get vulnerability scan", []string{app.ScopeEvidenceRead}), http.HandlerFunc(s.getVulnerabilityScan)}, - {http.MethodPost, "/v1/vulnerability-findings/{id}/decisions", op("createVulnerabilityDecision", http.MethodPost, "/v1/vulnerability-findings/{id}/decisions", "Create vulnerability decision", []string{app.ScopeEvidenceWrite}), http.HandlerFunc(s.createVulnerabilityDecision)}, - {http.MethodPost, "/v1/vulnerability-findings/{id}/workflow", op("recordVulnerabilityWorkflow", http.MethodPost, "/v1/vulnerability-findings/{id}/workflow", "Record vulnerability workflow event", []string{app.ScopeSecurityWrite}), http.HandlerFunc(s.recordVulnerabilityWorkflow)}, - {http.MethodGet, "/v1/reports/vulnerability-posture", op("vulnerabilityPostureReport", http.MethodGet, "/v1/reports/vulnerability-posture", "Vulnerability posture report", []string{app.ScopeSecurityRead}), http.HandlerFunc(s.vulnerabilityPostureReport)}, - {http.MethodPost, "/v1/openapi-contracts", op("uploadOpenAPIContract", http.MethodPost, "/v1/openapi-contracts", "Upload OpenAPI contract", []string{app.ScopeEvidenceWrite}), http.HandlerFunc(s.uploadOpenAPIContract)}, - {http.MethodGet, "/v1/openapi-contracts/{id}", op("getOpenAPIContract", http.MethodGet, "/v1/openapi-contracts/{id}", "Get OpenAPI contract", []string{app.ScopeEvidenceRead}), http.HandlerFunc(s.getOpenAPIContract)}, - {http.MethodPost, "/v1/openapi-diffs", op("createOpenAPIDiff", http.MethodPost, "/v1/openapi-diffs", "Create OpenAPI contract diff", []string{app.ScopeEvidenceRead}), http.HandlerFunc(s.createOpenAPIDiff)}, - {http.MethodPost, "/v1/policies/evaluate", op("evaluatePolicy", http.MethodPost, "/v1/policies/evaluate", "Evaluate release policy", []string{app.ScopeVerifyRead}), http.HandlerFunc(s.evaluatePolicy)}, - {http.MethodPost, "/v1/custom-policies", op("createCustomPolicy", http.MethodPost, "/v1/custom-policies", "Create custom policy", []string{app.ScopePolicyWrite}), http.HandlerFunc(s.createCustomPolicy)}, - {http.MethodPost, "/v1/custom-policies/{id}/evaluate", op("evaluateCustomPolicy", http.MethodPost, "/v1/custom-policies/{id}/evaluate", "Evaluate custom policy", []string{app.ScopePolicyRead}), http.HandlerFunc(s.evaluateCustomPolicy)}, - {http.MethodPost, "/v1/exceptions", op("createException", http.MethodPost, "/v1/exceptions", "Create exception", []string{app.ScopeReleaseWrite}), http.HandlerFunc(s.createException)}, - {http.MethodGet, "/v1/exceptions", op("listExceptions", http.MethodGet, "/v1/exceptions", "List exceptions", []string{app.ScopeVerifyRead}), http.HandlerFunc(s.listExceptions)}, - {http.MethodPost, "/v1/exceptions/{id}/approve", op("approveException", http.MethodPost, "/v1/exceptions/{id}/approve", "Approve exception", []string{app.ScopeReleaseWrite}), http.HandlerFunc(s.approveException)}, - {http.MethodGet, "/v1/reports/missing-evidence", op("missingEvidenceReport", http.MethodGet, "/v1/reports/missing-evidence", "Missing evidence report", []string{app.ScopeVerifyRead}), http.HandlerFunc(s.missingEvidenceReport)}, - {http.MethodGet, "/v1/reports/release-readiness", op("releaseReadinessReport", http.MethodGet, "/v1/reports/release-readiness", "Release readiness report", []string{app.ScopeVerifyRead}), http.HandlerFunc(s.releaseReadinessReport)}, - {http.MethodGet, "/v1/reports/control-coverage", op("controlCoverageReport", http.MethodGet, "/v1/reports/control-coverage", "Control coverage report", []string{app.ScopeReportRead}), http.HandlerFunc(s.controlCoverageReport)}, - {http.MethodGet, "/v1/reports/cra-readiness", op("craReadinessReport", http.MethodGet, "/v1/reports/cra-readiness", "CRA readiness report", []string{app.ScopeReportRead}), http.HandlerFunc(s.craReadinessReport)}, - {http.MethodPost, "/v1/release-bundles", op("createReleaseBundle", http.MethodPost, "/v1/release-bundles", "Create release bundle", []string{app.ScopeBundleWrite}), http.HandlerFunc(s.createReleaseBundle)}, - {http.MethodGet, "/v1/release-bundles/{id}", op("getReleaseBundle", http.MethodGet, "/v1/release-bundles/{id}", "Get release bundle", []string{app.ScopeBundleRead}), http.HandlerFunc(s.getReleaseBundle)}, - {http.MethodGet, "/v1/release-bundles/{id}/manifest", op("getReleaseBundleManifest", http.MethodGet, "/v1/release-bundles/{id}/manifest", "Get release bundle manifest", []string{app.ScopeBundleRead}), http.HandlerFunc(s.getReleaseBundleManifest)}, - {http.MethodGet, "/v1/release-bundles/{id}/verify", op("verifyReleaseBundle", http.MethodGet, "/v1/release-bundles/{id}/verify", "Verify release bundle", []string{app.ScopeVerifyRead}), http.HandlerFunc(s.verifyReleaseBundle)}, - {http.MethodGet, "/v1/audit-chain/verify", op("verifyAuditChain", http.MethodGet, "/v1/audit-chain/verify", "Verify audit chain", []string{app.ScopeVerifyRead}), http.HandlerFunc(s.verifyAuditChain)}, - {http.MethodGet, "/v1/audit-log", op("listAuditLog", http.MethodGet, "/v1/audit-log", "List tenant audit log", []string{app.ScopeAdmin}), http.HandlerFunc(s.listAuditLog)}, - {http.MethodPost, "/v1/merkle-batches", op("createMerkleBatch", http.MethodPost, "/v1/merkle-batches", "Create Merkle checkpoint batch", []string{app.ScopeKeysAdmin}), http.HandlerFunc(s.createMerkleBatch)}, - {http.MethodGet, "/v1/merkle-batches/{id}/verify", op("verifyMerkleBatch", http.MethodGet, "/v1/merkle-batches/{id}/verify", "Verify Merkle checkpoint batch", []string{app.ScopeVerifyRead}), http.HandlerFunc(s.verifyMerkleBatch)}, - {http.MethodPost, "/v1/transparency-checkpoints", op("createTransparencyCheckpoint", http.MethodPost, "/v1/transparency-checkpoints", "Record external transparency checkpoint", []string{app.ScopeKeysAdmin}), http.HandlerFunc(s.createTransparencyCheckpoint)}, - {http.MethodPost, "/v1/object-retention-policies", op("createObjectRetentionPolicy", http.MethodPost, "/v1/object-retention-policies", "Create object retention policy record", []string{app.ScopeAdmin}), http.HandlerFunc(s.createObjectRetentionPolicy)}, - {http.MethodPost, "/v1/object-retention-policies/{id}/verify", op("verifyObjectRetentionPolicy", http.MethodPost, "/v1/object-retention-policies/{id}/verify", "Verify object retention policy record", []string{app.ScopeVerifyRead}), http.HandlerFunc(s.verifyObjectRetentionPolicy)}, - {http.MethodPost, "/v1/legal-holds", op("createLegalHold", http.MethodPost, "/v1/legal-holds", "Create legal hold", []string{app.ScopeAdmin}), http.HandlerFunc(s.createLegalHold)}, - {http.MethodPost, "/v1/retention-overrides", op("createRetentionOverride", http.MethodPost, "/v1/retention-overrides", "Create retention override", []string{app.ScopeAdmin}), http.HandlerFunc(s.createRetentionOverride)}, - {http.MethodGet, "/v1/reports/retention", op("retentionReport", http.MethodGet, "/v1/reports/retention", "Retention report", []string{app.ScopeAdmin}), http.HandlerFunc(s.retentionReport)}, - {http.MethodPost, "/v1/backup-manifests", op("generateBackupManifest", http.MethodPost, "/v1/backup-manifests", "Generate backup manifest", []string{app.ScopeAdmin}), http.HandlerFunc(s.generateBackupManifest)}, - {http.MethodGet, "/v1/backup-manifests/{id}/verify", op("verifyBackupManifest", http.MethodGet, "/v1/backup-manifests/{id}/verify", "Verify backup manifest", []string{app.ScopeVerifyRead}), http.HandlerFunc(s.verifyBackupManifest)}, - {http.MethodGet, "/v1/signing-keys", op("listSigningKeys", http.MethodGet, "/v1/signing-keys", "List signing keys", []string{app.ScopeVerifyRead}), http.HandlerFunc(s.listSigningKeys)}, - {http.MethodPost, "/v1/signing-keys/rotate", op("rotateSigningKey", http.MethodPost, "/v1/signing-keys/rotate", "Rotate signing key", []string{app.ScopeKeysAdmin}), http.HandlerFunc(s.rotateSigningKey)}, - {http.MethodPost, "/v1/signing-keys/{id}/revoke", op("revokeSigningKey", http.MethodPost, "/v1/signing-keys/{id}/revoke", "Revoke signing key", []string{app.ScopeKeysAdmin}), http.HandlerFunc(s.revokeSigningKey)}, - {http.MethodPost, "/v1/signing-providers", op("createSigningProvider", http.MethodPost, "/v1/signing-providers", "Create signing provider record", []string{app.ScopeKeysAdmin}), http.HandlerFunc(s.createSigningProvider)}, - {http.MethodPost, "/v1/commercial-collectors", op("createCommercialCollector", http.MethodPost, "/v1/commercial-collectors", "Create commercial collector definition", []string{app.ScopeCollectorAdmin}), http.HandlerFunc(s.createCommercialCollector)}, - {http.MethodGet, "/v1/commercial-collectors", op("listCommercialCollectors", http.MethodGet, "/v1/commercial-collectors", "List commercial collector definitions", []string{app.ScopeCollectorRead}), http.HandlerFunc(s.listCommercialCollectors)}, - {http.MethodPost, "/v1/verify", op("verify", http.MethodPost, "/v1/verify", "Verify subject", []string{app.ScopeVerifyRead}), http.HandlerFunc(s.verifySubject)}, - {http.MethodPost, "/v1/api-keys", op("createAPIKey", http.MethodPost, "/v1/api-keys", "Create API key", []string{app.ScopeAdmin}), http.HandlerFunc(s.createAPIKey)}, - {http.MethodGet, "/v1/api-keys", op("listAPIKeys", http.MethodGet, "/v1/api-keys", "List API keys", []string{app.ScopeAdmin}), http.HandlerFunc(s.listAPIKeys)}, - } - for _, route := range routes { - if err := s.routes.Register(routecontracts.Route{Method: route.method, Pattern: route.path, Handler: route.handler, Operation: route.op}); err != nil { - return err - } - } - return nil -} - -func (s *Server) health(w http.ResponseWriter, _ *http.Request) { - writeData(w, http.StatusOK, map[string]string{"status": "ok"}) -} - -func (s *Server) ready(w http.ResponseWriter, r *http.Request) { - status, err := s.ledger.ReadinessStatus(r.Context()) - if err != nil { - writeProblem(w, r, err) - return - } - writeData(w, http.StatusOK, status) -} - -func (s *Server) metrics(w http.ResponseWriter, r *http.Request) { - actor, ok := s.authenticate(w, r) - if !ok { - return - } - metrics, err := s.ledger.Metrics(r.Context(), actor) - if err != nil { - writeProblem(w, r, err) - return - } - writeData(w, http.StatusOK, metrics) -} - -func (s *Server) version(w http.ResponseWriter, _ *http.Request) { - writeData(w, http.StatusOK, map[string]string{"version": "dev"}) -} - -func (s *Server) openapi(w http.ResponseWriter, _ *http.Request) { - doc, err := s.OpenAPI() - if err != nil { - writeProblem(w, nil, err) - return - } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - _, _ = w.Write(doc) -} - -func (s *Server) instanceAdminSnapshot(w http.ResponseWriter, r *http.Request) { - actor, ok := s.authenticate(w, r) - if !ok { - return - } - snapshot, err := s.ledger.InstanceAdminSnapshot(r.Context(), actor) - if err != nil { - writeProblem(w, r, err) - return - } - writeData(w, http.StatusOK, snapshot) -} - -func (s *Server) createOrganization(w http.ResponseWriter, r *http.Request) { - var req struct { - Name string `json:"name"` - Slug string `json:"slug"` - } - s.create(w, r, func(actor domain.Actor, body []byte) (int, any, error) { - if err := decodeJSON(body, &req); err != nil { - return 0, nil, err - } - org, err := s.ledger.CreateOrganization(r.Context(), actor, app.CreateOrganizationInput{Name: req.Name, Slug: req.Slug}) - return http.StatusCreated, org, err - }) -} - -func (s *Server) createUser(w http.ResponseWriter, r *http.Request) { - var req struct { - OrganizationID string `json:"organization_id"` - Email string `json:"email"` - DisplayName string `json:"display_name"` - } - s.create(w, r, func(actor domain.Actor, body []byte) (int, any, error) { - if err := decodeJSON(body, &req); err != nil { - return 0, nil, err - } - user, err := s.ledger.CreateUser(r.Context(), actor, app.CreateUserInput{OrganizationID: req.OrganizationID, Email: req.Email, DisplayName: req.DisplayName}) - return http.StatusCreated, user, err - }) -} - -func (s *Server) deactivateUser(w http.ResponseWriter, r *http.Request) { - s.create(w, r, func(actor domain.Actor, _ []byte) (int, any, error) { - user, err := s.ledger.DeactivateUser(r.Context(), actor, r.PathValue("id")) - return http.StatusOK, user, err - }) -} - -func (s *Server) createRoleBinding(w http.ResponseWriter, r *http.Request) { - var req struct { - SubjectType string `json:"subject_type"` - SubjectID string `json:"subject_id"` - Role string `json:"role"` - ResourceType string `json:"resource_type"` - ResourceID string `json:"resource_id"` - } - s.create(w, r, func(actor domain.Actor, body []byte) (int, any, error) { - if err := decodeJSON(body, &req); err != nil { - return 0, nil, err - } - binding, err := s.ledger.CreateRoleBinding(r.Context(), actor, app.CreateRoleBindingInput{SubjectType: req.SubjectType, SubjectID: req.SubjectID, Role: req.Role, ResourceType: req.ResourceType, ResourceID: req.ResourceID}) - return http.StatusCreated, binding, err - }) -} - -func (s *Server) listRoleBindings(w http.ResponseWriter, r *http.Request) { - actor, ok := s.authenticate(w, r) - if !ok { - return - } - bindings, err := s.ledger.ListRoleBindings(r.Context(), actor) - if err != nil { - writeProblem(w, r, err) - return - } - writeData(w, http.StatusOK, bindings) -} - -func (s *Server) createSSOProvider(w http.ResponseWriter, r *http.Request) { - var req struct { - Name string `json:"name"` - Type string `json:"type"` - Issuer string `json:"issuer"` - ClientID string `json:"client_id"` - GroupsClaim string `json:"groups_claim"` - RoleMapping map[string]string `json:"role_mapping"` - } - s.create(w, r, func(actor domain.Actor, body []byte) (int, any, error) { - if err := decodeJSON(body, &req); err != nil { - return 0, nil, err - } - provider, err := s.ledger.CreateSSOProvider(r.Context(), actor, app.CreateSSOProviderInput{Name: req.Name, Type: req.Type, Issuer: req.Issuer, ClientID: req.ClientID, GroupsClaim: req.GroupsClaim, RoleMapping: req.RoleMapping}) - return http.StatusCreated, provider, err - }) -} - -func (s *Server) linkSSOIdentity(w http.ResponseWriter, r *http.Request) { - var req struct { - UserID string `json:"user_id"` - ProviderID string `json:"provider_id"` - Subject string `json:"subject"` - Email string `json:"email"` - Verified bool `json:"verified"` - } - s.create(w, r, func(actor domain.Actor, body []byte) (int, any, error) { - if err := decodeJSON(body, &req); err != nil { - return 0, nil, err - } - link, err := s.ledger.LinkSSOIdentity(r.Context(), actor, app.LinkSSOIdentityInput{UserID: req.UserID, ProviderID: req.ProviderID, Subject: req.Subject, Email: req.Email, Verified: req.Verified}) - return http.StatusCreated, link, err - }) -} - -func (s *Server) createSSOSession(w http.ResponseWriter, r *http.Request) { - var req struct { - UserID string `json:"user_id"` - ProviderID string `json:"provider_id"` - ExpiresAt time.Time `json:"expires_at"` - } - s.create(w, r, func(actor domain.Actor, body []byte) (int, any, error) { - if err := decodeJSON(body, &req); err != nil { - return 0, nil, err - } - session, secret, err := s.ledger.CreateSSOSession(r.Context(), actor, app.CreateSSOSessionInput{UserID: req.UserID, ProviderID: req.ProviderID, ExpiresAt: req.ExpiresAt}) - return http.StatusCreated, map[string]any{"session": session, "secret": secret}, err - }) -} - -func (s *Server) revokeSSOSession(w http.ResponseWriter, r *http.Request) { - s.create(w, r, func(actor domain.Actor, _ []byte) (int, any, error) { - session, err := s.ledger.RevokeSSOSession(r.Context(), actor, r.PathValue("id")) - return http.StatusOK, session, err - }) -} - func (s *Server) createCollector(w http.ResponseWriter, r *http.Request) { var req struct { Name string `json:"name"` @@ -397,11 +79,11 @@ func (s *Server) createCollector(w http.ResponseWriter, r *http.Request) { Version string `json:"version"` Scopes []string `json:"scopes"` } - s.create(w, r, func(actor domain.Actor, body []byte) (int, any, error) { + s.create(w, r, func(ctx requestContext, actor domain.Actor, body []byte) (int, any, error) { if err := decodeJSON(body, &req); err != nil { return 0, nil, err } - collector, key, secret, err := s.ledger.CreateCollector(r.Context(), actor, app.CreateCollectorInput{ + collector, key, secret, err := s.ledger.CreateCollector(ctx, actor, app.CreateCollectorInput{ Name: req.Name, Type: req.Type, Version: req.Version, @@ -433,11 +115,11 @@ func (s *Server) recordCollectorRelease(w http.ResponseWriter, r *http.Request) ScanID string `json:"scan_id"` Pinned bool `json:"pinned"` } - s.create(w, r, func(actor domain.Actor, body []byte) (int, any, error) { + s.create(w, r, func(ctx requestContext, actor domain.Actor, body []byte) (int, any, error) { if err := decodeJSON(body, &req); err != nil { return 0, nil, err } - release, err := s.ledger.RecordCollectorRelease(r.Context(), actor, app.RecordCollectorReleaseInput{ + release, err := s.ledger.RecordCollectorRelease(ctx, actor, app.RecordCollectorReleaseInput{ CollectorID: r.PathValue("id"), Version: req.Version, ArtifactDigest: req.ArtifactDigest, @@ -470,11 +152,11 @@ func (s *Server) createControlFramework(w http.ResponseWriter, r *http.Request) Version string `json:"version"` Description string `json:"description"` } - s.create(w, r, func(actor domain.Actor, body []byte) (int, any, error) { + s.create(w, r, func(ctx requestContext, actor domain.Actor, body []byte) (int, any, error) { if err := decodeJSON(body, &req); err != nil { return 0, nil, err } - framework, err := s.ledger.CreateControlFramework(r.Context(), actor, app.CreateControlFrameworkInput{ + framework, err := s.ledger.CreateControlFramework(ctx, actor, app.CreateControlFrameworkInput{ Name: req.Name, Slug: req.Slug, Version: req.Version, @@ -511,8 +193,8 @@ func (s *Server) listControlFrameworkTemplatePacks(w http.ResponseWriter, r *htt } func (s *Server) installControlFrameworkTemplatePack(w http.ResponseWriter, r *http.Request) { - s.create(w, r, func(actor domain.Actor, _ []byte) (int, any, error) { - framework, err := s.ledger.InstallControlFrameworkTemplatePack(r.Context(), actor, r.PathValue("slug")) + s.create(w, r, func(ctx requestContext, actor domain.Actor, _ []byte) (int, any, error) { + framework, err := s.ledger.InstallControlFrameworkTemplatePack(ctx, actor, r.PathValue("slug")) return http.StatusCreated, framework, err }) } @@ -527,11 +209,11 @@ func (s *Server) createSecurityControl(w http.ResponseWriter, r *http.Request) { Applicability []string `json:"applicability"` Limitations []string `json:"limitations"` } - s.create(w, r, func(actor domain.Actor, body []byte) (int, any, error) { + s.create(w, r, func(ctx requestContext, actor domain.Actor, body []byte) (int, any, error) { if err := decodeJSON(body, &req); err != nil { return 0, nil, err } - control, err := s.ledger.CreateSecurityControl(r.Context(), actor, app.CreateSecurityControlInput{ + control, err := s.ledger.CreateSecurityControl(ctx, actor, app.CreateSecurityControlInput{ FrameworkID: req.FrameworkID, Code: req.Code, Title: req.Title, @@ -567,11 +249,11 @@ func (s *Server) linkControlEvidence(w http.ResponseWriter, r *http.Request) { Confidence string `json:"confidence"` Notes string `json:"notes"` } - s.create(w, r, func(actor domain.Actor, body []byte) (int, any, error) { + s.create(w, r, func(ctx requestContext, actor domain.Actor, body []byte) (int, any, error) { if err := decodeJSON(body, &req); err != nil { return 0, nil, err } - link, err := s.ledger.LinkControlEvidence(r.Context(), actor, r.PathValue("id"), app.LinkControlEvidenceInput{ + link, err := s.ledger.LinkControlEvidence(ctx, actor, r.PathValue("id"), app.LinkControlEvidenceInput{ EvidenceType: req.EvidenceType, SubjectType: req.SubjectType, SubjectID: req.SubjectID, @@ -599,11 +281,11 @@ func (s *Server) listControlEvidence(w http.ResponseWriter, r *http.Request) { func (s *Server) createProduct(w http.ResponseWriter, r *http.Request) { var req struct{ Name, Slug string } - s.create(w, r, func(actor domain.Actor, body []byte) (int, any, error) { + s.create(w, r, func(ctx requestContext, actor domain.Actor, body []byte) (int, any, error) { if err := decodeJSON(body, &req); err != nil { return 0, nil, err } - product, err := s.ledger.CreateProduct(r.Context(), actor, req.Name, req.Slug) + product, err := s.ledger.CreateProduct(ctx, actor, req.Name, req.Slug) return http.StatusCreated, product, err }) } @@ -621,30 +303,56 @@ func (s *Server) listProducts(w http.ResponseWriter, r *http.Request) { writeData(w, http.StatusOK, products) } +func (s *Server) getProduct(w http.ResponseWriter, r *http.Request) { + actor, ok := s.authenticate(w, r) + if !ok { + return + } + product, err := s.ledger.GetProduct(r.Context(), actor, r.PathValue("id")) + if err != nil { + writeProblem(w, r, err) + return + } + writeData(w, http.StatusOK, product) +} + func (s *Server) createProject(w http.ResponseWriter, r *http.Request) { var req struct { ProductID string `json:"product_id"` Name string `json:"name"` } - s.create(w, r, func(actor domain.Actor, body []byte) (int, any, error) { + s.create(w, r, func(ctx requestContext, actor domain.Actor, body []byte) (int, any, error) { if err := decodeJSON(body, &req); err != nil { return 0, nil, err } - project, err := s.ledger.CreateProject(r.Context(), actor, req.ProductID, req.Name) + project, err := s.ledger.CreateProject(ctx, actor, req.ProductID, req.Name) return http.StatusCreated, project, err }) } +func (s *Server) getProject(w http.ResponseWriter, r *http.Request) { + actor, ok := s.authenticate(w, r) + if !ok { + return + } + project, err := s.ledger.GetProject(r.Context(), actor, r.PathValue("id")) + if err != nil { + writeProblem(w, r, err) + return + } + writeData(w, http.StatusOK, project) +} + func (s *Server) createRelease(w http.ResponseWriter, r *http.Request) { var req struct { ProductID string `json:"product_id"` Version string `json:"version"` } - s.create(w, r, func(actor domain.Actor, body []byte) (int, any, error) { + s.create(w, r, func(ctx requestContext, actor domain.Actor, body []byte) (int, any, error) { if err := decodeJSON(body, &req); err != nil { return 0, nil, err } - release, err := s.ledger.CreateRelease(r.Context(), actor, req.ProductID, req.Version) + release, err := s.ledger.CreateRelease(ctx, actor, req.ProductID, req.Version) return http.StatusCreated, release, err }) } @@ -662,16 +370,42 @@ func (s *Server) getRelease(w http.ResponseWriter, r *http.Request) { writeData(w, http.StatusOK, release) } +func (s *Server) startReleaseEvidenceFlow(w http.ResponseWriter, r *http.Request) { + actor, ok := s.authenticate(w, r) + if !ok { + return + } + flow, err := s.ledger.ReleaseEvidenceFlowPlan(r.Context(), actor, r.PathValue("id")) + if err != nil { + writeProblem(w, r, err) + return + } + writeData(w, http.StatusOK, flow) +} + +func (s *Server) releaseSecuritySummary(w http.ResponseWriter, r *http.Request) { + actor, ok := s.authenticate(w, r) + if !ok { + return + } + summary, err := s.ledger.ReleaseSecuritySummary(r.Context(), actor, r.PathValue("id")) + if err != nil { + writeProblem(w, r, err) + return + } + writeData(w, http.StatusOK, summary) +} + func (s *Server) freezeRelease(w http.ResponseWriter, r *http.Request) { - s.create(w, r, func(actor domain.Actor, _ []byte) (int, any, error) { - release, err := s.ledger.FreezeRelease(r.Context(), actor, r.PathValue("id")) + s.create(w, r, func(ctx requestContext, actor domain.Actor, _ []byte) (int, any, error) { + release, err := s.ledger.FreezeRelease(ctx, actor, r.PathValue("id")) return http.StatusOK, release, err }) } func (s *Server) approveRelease(w http.ResponseWriter, r *http.Request) { - s.create(w, r, func(actor domain.Actor, _ []byte) (int, any, error) { - release, err := s.ledger.ApproveRelease(r.Context(), actor, r.PathValue("id")) + s.create(w, r, func(ctx requestContext, actor domain.Actor, _ []byte) (int, any, error) { + release, err := s.ledger.ApproveRelease(ctx, actor, r.PathValue("id")) return http.StatusOK, release, err }) } @@ -688,11 +422,11 @@ func (s *Server) createReleaseCandidate(w http.ResponseWriter, r *http.Request) ContractIDs []string `json:"contract_ids"` BundleIDs []string `json:"bundle_ids"` } - s.create(w, r, func(actor domain.Actor, body []byte) (int, any, error) { + s.create(w, r, func(ctx requestContext, actor domain.Actor, body []byte) (int, any, error) { if err := decodeJSON(body, &req); err != nil { return 0, nil, err } - candidate, err := s.ledger.CreateReleaseCandidate(r.Context(), actor, app.CreateReleaseCandidateInput{ + candidate, err := s.ledger.CreateReleaseCandidate(ctx, actor, app.CreateReleaseCandidateInput{ ReleaseID: req.ReleaseID, Name: req.Name, BuildIDs: req.BuildIDs, ArtifactIDs: req.ArtifactIDs, SBOMIDs: req.SBOMIDs, ScanIDs: req.ScanIDs, VEXIDs: req.VEXIDs, ContractIDs: req.ContractIDs, BundleIDs: req.BundleIDs, }) @@ -738,11 +472,11 @@ func (s *Server) transitionReleaseCandidate(w http.ResponseWriter, r *http.Reque var req struct { Reason string `json:"reason"` } - s.create(w, r, func(actor domain.Actor, body []byte) (int, any, error) { + s.create(w, r, func(ctx requestContext, actor domain.Actor, body []byte) (int, any, error) { if err := decodeJSON(body, &req); err != nil { return 0, nil, err } - candidate, err := s.ledger.UpdateReleaseCandidateState(r.Context(), actor, r.PathValue("id"), state, req.Reason) + candidate, err := s.ledger.UpdateReleaseCandidateState(ctx, actor, r.PathValue("id"), state, req.Reason) return http.StatusOK, candidate, err }) } @@ -754,15 +488,28 @@ func (s *Server) registerArtifact(w http.ResponseWriter, r *http.Request) { Digest string `json:"digest"` Size int64 `json:"size"` } - s.create(w, r, func(actor domain.Actor, body []byte) (int, any, error) { + s.create(w, r, func(ctx requestContext, actor domain.Actor, body []byte) (int, any, error) { if err := decodeJSON(body, &req); err != nil { return 0, nil, err } - artifact, err := s.ledger.RegisterArtifact(r.Context(), actor, req.Name, req.MediaType, req.Digest, req.Size) + artifact, err := s.ledger.RegisterArtifact(ctx, actor, req.Name, req.MediaType, req.Digest, req.Size) return http.StatusCreated, artifact, err }) } +func (s *Server) getArtifact(w http.ResponseWriter, r *http.Request) { + actor, ok := s.authenticate(w, r) + if !ok { + return + } + artifact, err := s.ledger.GetArtifact(r.Context(), actor, r.PathValue("id")) + if err != nil { + writeProblem(w, r, err) + return + } + writeData(w, http.StatusOK, artifact) +} + func (s *Server) registerContainerImage(w http.ResponseWriter, r *http.Request) { var req struct { ArtifactID string `json:"artifact_id"` @@ -771,11 +518,11 @@ func (s *Server) registerContainerImage(w http.ResponseWriter, r *http.Request) Digest string `json:"digest"` Platform string `json:"platform"` } - s.create(w, r, func(actor domain.Actor, body []byte) (int, any, error) { + s.create(w, r, func(ctx requestContext, actor domain.Actor, body []byte) (int, any, error) { if err := decodeJSON(body, &req); err != nil { return 0, nil, err } - image, err := s.ledger.RegisterContainerImage(r.Context(), actor, app.RegisterContainerImageInput{ + image, err := s.ledger.RegisterContainerImage(ctx, actor, app.RegisterContainerImageInput{ ArtifactID: req.ArtifactID, Repository: req.Repository, Tag: req.Tag, Digest: req.Digest, Platform: req.Platform, }) return http.StatusCreated, image, err @@ -791,11 +538,11 @@ func (s *Server) createArtifactSignature(w http.ResponseWriter, r *http.Request) Payload json.RawMessage `json:"payload"` PayloadMediaType string `json:"payload_media_type"` } - s.create(w, r, func(actor domain.Actor, body []byte) (int, any, error) { + s.create(w, r, func(ctx requestContext, actor domain.Actor, body []byte) (int, any, error) { if err := decodeJSON(body, &req); err != nil { return 0, nil, err } - sig, err := s.ledger.CreateArtifactSignature(r.Context(), actor, app.CreateArtifactSignatureInput{ + sig, err := s.ledger.CreateArtifactSignature(ctx, actor, app.CreateArtifactSignatureInput{ ArtifactID: req.ArtifactID, Algorithm: req.Algorithm, KeyID: req.KeyID, Signature: req.Signature, RawPayload: req.Payload, PayloadMediaType: req.PayloadMediaType, }) @@ -823,11 +570,11 @@ func (s *Server) verifyCosignSignature(w http.ResponseWriter, r *http.Request) { CertificateIdentity string `json:"certificate_identity"` CertificateIssuer string `json:"certificate_issuer"` } - s.create(w, r, func(actor domain.Actor, body []byte) (int, any, error) { + s.create(w, r, func(ctx requestContext, actor domain.Actor, body []byte) (int, any, error) { if err := decodeJSON(body, &req); err != nil { return 0, nil, err } - result, err := s.ledger.VerifyCosignSignature(r.Context(), actor, app.VerifyCosignInput{ + result, err := s.ledger.VerifyCosignSignature(ctx, actor, app.VerifyCosignInput{ ArtifactSignatureID: r.PathValue("id"), RekorUUID: req.RekorUUID, RekorLogIndex: req.RekorLogIndex, @@ -860,11 +607,11 @@ func (s *Server) createBuild(w http.ResponseWriter, r *http.Request) { ProviderMetadata map[string]any `json:"provider_metadata"` Outputs []domain.BuildOutput `json:"outputs"` } - s.create(w, r, func(actor domain.Actor, body []byte) (int, any, error) { + s.create(w, r, func(ctx requestContext, actor domain.Actor, body []byte) (int, any, error) { if err := decodeJSON(body, &req); err != nil { return 0, nil, err } - build, err := s.ledger.CreateBuildRun(r.Context(), actor, app.CreateBuildRunInput{ + build, err := s.ledger.CreateBuildRun(ctx, actor, app.CreateBuildRunInput{ ProjectID: req.ProjectID, ReleaseID: req.ReleaseID, Provider: req.Provider, @@ -903,15 +650,15 @@ func (s *Server) getBuild(w http.ResponseWriter, r *http.Request) { } func (s *Server) uploadBuildAttestation(w http.ResponseWriter, r *http.Request) { - s.create(w, r, func(actor domain.Actor, body []byte) (int, any, error) { - attestation, err := s.ledger.UploadBuildAttestation(r.Context(), actor, r.PathValue("id"), body) + s.create(w, r, func(ctx requestContext, actor domain.Actor, body []byte) (int, any, error) { + attestation, err := s.ledger.UploadBuildAttestation(ctx, actor, r.PathValue("id"), body) return http.StatusCreated, attestation, err }) } func (s *Server) verifyBuildAttestationSignature(w http.ResponseWriter, r *http.Request) { - s.create(w, r, func(actor domain.Actor, _ []byte) (int, any, error) { - result, err := s.ledger.VerifyDSSEAttestationSignature(r.Context(), actor, r.PathValue("id")) + s.create(w, r, func(ctx requestContext, actor domain.Actor, _ []byte) (int, any, error) { + result, err := s.ledger.VerifyDSSEAttestationSignature(ctx, actor, r.PathValue("id")) return http.StatusOK, result, err }) } @@ -923,11 +670,11 @@ func (s *Server) createDSSETrustRoot(w http.ResponseWriter, r *http.Request) { Algorithm string `json:"algorithm"` PublicKey string `json:"public_key"` } - s.create(w, r, func(actor domain.Actor, body []byte) (int, any, error) { + s.create(w, r, func(ctx requestContext, actor domain.Actor, body []byte) (int, any, error) { if err := decodeJSON(body, &req); err != nil { return 0, nil, err } - root, err := s.ledger.CreateDSSETrustRoot(r.Context(), actor, app.CreateDSSETrustRootInput{Name: req.Name, KeyID: req.KeyID, Algorithm: req.Algorithm, PublicKey: req.PublicKey}) + root, err := s.ledger.CreateDSSETrustRoot(ctx, actor, app.CreateDSSETrustRootInput{Name: req.Name, KeyID: req.KeyID, Algorithm: req.Algorithm, PublicKey: req.PublicKey}) return http.StatusCreated, root, err }) } @@ -940,11 +687,11 @@ func (s *Server) createSourceRepository(w http.ResponseWriter, r *http.Request) CloneURL string `json:"clone_url"` DefaultBranch string `json:"default_branch"` } - s.create(w, r, func(actor domain.Actor, body []byte) (int, any, error) { + s.create(w, r, func(ctx requestContext, actor domain.Actor, body []byte) (int, any, error) { if err := decodeJSON(body, &req); err != nil { return 0, nil, err } - repo, err := s.ledger.CreateSourceRepository(r.Context(), actor, app.CreateRepositoryInput{ + repo, err := s.ledger.CreateSourceRepository(ctx, actor, app.CreateRepositoryInput{ ProjectID: req.ProjectID, Provider: req.Provider, FullName: req.FullName, CloneURL: req.CloneURL, DefaultBranch: req.DefaultBranch, }) return http.StatusCreated, repo, err @@ -972,11 +719,11 @@ func (s *Server) recordSourceCommit(w http.ResponseWriter, r *http.Request) { Message string `json:"message"` CommittedAt time.Time `json:"committed_at"` } - s.create(w, r, func(actor domain.Actor, body []byte) (int, any, error) { + s.create(w, r, func(ctx requestContext, actor domain.Actor, body []byte) (int, any, error) { if err := decodeJSON(body, &req); err != nil { return 0, nil, err } - commit, err := s.ledger.RecordSourceCommit(r.Context(), actor, app.RecordCommitInput{ + commit, err := s.ledger.RecordSourceCommit(ctx, actor, app.RecordCommitInput{ RepositoryID: req.RepositoryID, SHA: req.SHA, Author: req.Author, Message: req.Message, CommittedAt: req.CommittedAt, }) return http.StatusCreated, commit, err @@ -991,11 +738,11 @@ func (s *Server) upsertSourceBranch(w http.ResponseWriter, r *http.Request) { Protected bool `json:"protected"` ProtectionHash string `json:"protection_hash"` } - s.create(w, r, func(actor domain.Actor, body []byte) (int, any, error) { + s.create(w, r, func(ctx requestContext, actor domain.Actor, body []byte) (int, any, error) { if err := decodeJSON(body, &req); err != nil { return 0, nil, err } - branch, err := s.ledger.UpsertSourceBranch(r.Context(), actor, app.UpsertBranchInput{ + branch, err := s.ledger.UpsertSourceBranch(ctx, actor, app.UpsertBranchInput{ RepositoryID: req.RepositoryID, Name: req.Name, HeadCommitID: req.HeadCommitID, Protected: req.Protected, ProtectionHash: req.ProtectionHash, }) return http.StatusCreated, branch, err @@ -1014,11 +761,11 @@ func (s *Server) recordPullRequest(w http.ResponseWriter, r *http.Request) { HeadCommitID string `json:"head_commit_id"` ReviewDecision string `json:"review_decision"` } - s.create(w, r, func(actor domain.Actor, body []byte) (int, any, error) { + s.create(w, r, func(ctx requestContext, actor domain.Actor, body []byte) (int, any, error) { if err := decodeJSON(body, &req); err != nil { return 0, nil, err } - pr, err := s.ledger.RecordPullRequest(r.Context(), actor, app.RecordPullRequestInput{ + pr, err := s.ledger.RecordPullRequest(ctx, actor, app.RecordPullRequestInput{ RepositoryID: req.RepositoryID, Provider: req.Provider, ProviderID: req.ProviderID, Title: req.Title, State: req.State, SourceBranch: req.SourceBranch, TargetBranch: req.TargetBranch, HeadCommitID: req.HeadCommitID, ReviewDecision: req.ReviewDecision, }) @@ -1027,15 +774,15 @@ func (s *Server) recordPullRequest(w http.ResponseWriter, r *http.Request) { } func (s *Server) uploadGitHubSourceSnapshot(w http.ResponseWriter, r *http.Request) { - s.create(w, r, func(actor domain.Actor, body []byte) (int, any, error) { - result, err := s.ledger.UploadGitHubSourceSnapshot(r.Context(), actor, body) + s.create(w, r, func(ctx requestContext, actor domain.Actor, body []byte) (int, any, error) { + result, err := s.ledger.UploadGitHubSourceSnapshot(ctx, actor, body) return http.StatusCreated, result, err }) } func (s *Server) uploadGitLabSourceSnapshot(w http.ResponseWriter, r *http.Request) { - s.create(w, r, func(actor domain.Actor, body []byte) (int, any, error) { - result, err := s.ledger.UploadGitLabSourceSnapshot(r.Context(), actor, body) + s.create(w, r, func(ctx requestContext, actor domain.Actor, body []byte) (int, any, error) { + result, err := s.ledger.UploadGitLabSourceSnapshot(ctx, actor, body) return http.StatusCreated, result, err }) } @@ -1046,11 +793,11 @@ func (s *Server) createDeploymentEnvironment(w http.ResponseWriter, r *http.Requ Name string `json:"name"` Kind string `json:"kind"` } - s.create(w, r, func(actor domain.Actor, body []byte) (int, any, error) { + s.create(w, r, func(ctx requestContext, actor domain.Actor, body []byte) (int, any, error) { if err := decodeJSON(body, &req); err != nil { return 0, nil, err } - env, err := s.ledger.CreateDeploymentEnvironment(r.Context(), actor, app.CreateEnvironmentInput{ProductID: req.ProductID, Name: req.Name, Kind: req.Kind}) + env, err := s.ledger.CreateDeploymentEnvironment(ctx, actor, app.CreateEnvironmentInput{ProductID: req.ProductID, Name: req.Name, Kind: req.Kind}) return http.StatusCreated, env, err }) } @@ -1078,11 +825,11 @@ func (s *Server) recordDeployment(w http.ResponseWriter, r *http.Request) { FinishedAt *time.Time `json:"finished_at"` RollbackOf string `json:"rollback_of"` } - s.create(w, r, func(actor domain.Actor, body []byte) (int, any, error) { + s.create(w, r, func(ctx requestContext, actor domain.Actor, body []byte) (int, any, error) { if err := decodeJSON(body, &req); err != nil { return 0, nil, err } - deployment, err := s.ledger.RecordDeployment(r.Context(), actor, app.RecordDeploymentInput{ + deployment, err := s.ledger.RecordDeployment(ctx, actor, app.RecordDeploymentInput{ EnvironmentID: req.EnvironmentID, ReleaseID: req.ReleaseID, ArtifactIDs: req.ArtifactIDs, Status: req.Status, StartedAt: req.StartedAt, FinishedAt: req.FinishedAt, RollbackOf: req.RollbackOf, }) @@ -1124,11 +871,11 @@ func (s *Server) createIncident(w http.ResponseWriter, r *http.Request) { Severity string `json:"severity"` OpenedAt time.Time `json:"opened_at"` } - s.create(w, r, func(actor domain.Actor, body []byte) (int, any, error) { + s.create(w, r, func(ctx requestContext, actor domain.Actor, body []byte) (int, any, error) { if err := decodeJSON(body, &req); err != nil { return 0, nil, err } - incident, err := s.ledger.CreateIncident(r.Context(), actor, app.CreateIncidentInput{ProductID: req.ProductID, ReleaseID: req.ReleaseID, Title: req.Title, Severity: req.Severity, OpenedAt: req.OpenedAt}) + incident, err := s.ledger.CreateIncident(ctx, actor, app.CreateIncidentInput{ProductID: req.ProductID, ReleaseID: req.ReleaseID, Title: req.Title, Severity: req.Severity, OpenedAt: req.OpenedAt}) return http.StatusCreated, incident, err }) } @@ -1140,15 +887,55 @@ func (s *Server) recordIncidentTimeline(w http.ResponseWriter, r *http.Request) EvidenceID string `json:"evidence_id"` OccurredAt time.Time `json:"occurred_at"` } - s.create(w, r, func(actor domain.Actor, body []byte) (int, any, error) { + s.create(w, r, func(ctx requestContext, actor domain.Actor, body []byte) (int, any, error) { if err := decodeJSON(body, &req); err != nil { return 0, nil, err } - event, err := s.ledger.RecordIncidentTimelineEvent(r.Context(), actor, r.PathValue("id"), app.RecordIncidentTimelineInput{EventType: req.EventType, Summary: req.Summary, EvidenceID: req.EvidenceID, OccurredAt: req.OccurredAt}) + event, err := s.ledger.RecordIncidentTimelineEvent(ctx, actor, r.PathValue("id"), app.RecordIncidentTimelineInput{EventType: req.EventType, Summary: req.Summary, EvidenceID: req.EvidenceID, OccurredAt: req.OccurredAt}) return http.StatusCreated, event, err }) } +func (s *Server) createIncidentWebhookReceiver(w http.ResponseWriter, r *http.Request) { + var req struct { + Name string `json:"name"` + Provider string `json:"provider"` + PublicKey string `json:"public_key"` + } + s.create(w, r, func(ctx requestContext, actor domain.Actor, body []byte) (int, any, error) { + if err := decodeJSON(body, &req); err != nil { + return 0, nil, err + } + receiver, err := s.ledger.CreateIncidentWebhookReceiver(ctx, actor, app.CreateIncidentWebhookReceiverInput{IncidentID: r.PathValue("id"), Name: req.Name, Provider: req.Provider, PublicKey: req.PublicKey}) + return http.StatusCreated, receiver, err + }) +} + +func (s *Server) receiveIncidentWebhook(w http.ResponseWriter, r *http.Request) { + body, err := readBody(r) + if err != nil { + writeProblem(w, r, err) + return + } + timestamp, err := time.Parse(time.RFC3339, strings.TrimSpace(r.Header.Get("X-Evydence-Webhook-Timestamp"))) + if err != nil { + writeProblem(w, r, app.ErrValidation) + return + } + record, event, err := s.ledger.HandleIncidentWebhook(r.Context(), app.HandleIncidentWebhookInput{ + ReceiverID: r.PathValue("receiver_id"), + EventID: r.Header.Get("X-Evydence-Webhook-Event-ID"), + Timestamp: timestamp, + Signature: r.Header.Get("X-Evydence-Webhook-Signature"), + Body: body, + }) + if err != nil { + writeProblem(w, r, err) + return + } + writeData(w, http.StatusCreated, map[string]any{"webhook_event": record, "timeline_event": event}) +} + func (s *Server) createRemediationTask(w http.ResponseWriter, r *http.Request) { var req struct { IncidentID string `json:"incident_id"` @@ -1158,11 +945,11 @@ func (s *Server) createRemediationTask(w http.ResponseWriter, r *http.Request) { DueAt *time.Time `json:"due_at"` EvidenceID string `json:"evidence_id"` } - s.create(w, r, func(actor domain.Actor, body []byte) (int, any, error) { + s.create(w, r, func(ctx requestContext, actor domain.Actor, body []byte) (int, any, error) { if err := decodeJSON(body, &req); err != nil { return 0, nil, err } - task, err := s.ledger.CreateRemediationTask(r.Context(), actor, app.CreateRemediationTaskInput{IncidentID: req.IncidentID, ReleaseID: req.ReleaseID, Title: req.Title, Owner: req.Owner, DueAt: req.DueAt, EvidenceID: req.EvidenceID}) + task, err := s.ledger.CreateRemediationTask(ctx, actor, app.CreateRemediationTaskInput{IncidentID: req.IncidentID, ReleaseID: req.ReleaseID, Title: req.Title, Owner: req.Owner, DueAt: req.DueAt, EvidenceID: req.EvidenceID}) return http.StatusCreated, task, err }) } @@ -1191,11 +978,11 @@ func (s *Server) uploadSecurityScan(w http.ResponseWriter, r *http.Request) { TargetRef string `json:"target_ref"` Payload json.RawMessage `json:"payload"` } - s.create(w, r, func(actor domain.Actor, body []byte) (int, any, error) { + s.create(w, r, func(ctx requestContext, actor domain.Actor, body []byte) (int, any, error) { if err := decodeJSON(body, &req); err != nil { return 0, nil, err } - scan, err := s.ledger.UploadSecurityScan(r.Context(), actor, app.UploadSecurityScanInput{ProductID: req.ProductID, ReleaseID: req.ReleaseID, ArtifactID: req.ArtifactID, Category: req.Category, Format: req.Format, Scanner: req.Scanner, TargetRef: req.TargetRef, Raw: req.Payload}) + scan, err := s.ledger.UploadSecurityScan(ctx, actor, app.UploadSecurityScanInput{ProductID: req.ProductID, ReleaseID: req.ReleaseID, ArtifactID: req.ArtifactID, Category: req.Category, Format: req.Format, Scanner: req.Scanner, TargetRef: req.TargetRef, Raw: req.Payload}) return http.StatusCreated, scan, err }) } @@ -1210,11 +997,11 @@ func (s *Server) uploadAPISecurityScan(w http.ResponseWriter, r *http.Request) { TargetRef string `json:"target_ref"` Payload json.RawMessage `json:"payload"` } - s.create(w, r, func(actor domain.Actor, body []byte) (int, any, error) { + s.create(w, r, func(ctx requestContext, actor domain.Actor, body []byte) (int, any, error) { if err := decodeJSON(body, &req); err != nil { return 0, nil, err } - scan, err := s.ledger.UploadAPISecurityScan(r.Context(), actor, app.UploadSecurityScanInput{ProductID: req.ProductID, ReleaseID: req.ReleaseID, ArtifactID: req.ArtifactID, Format: req.Format, Scanner: req.Scanner, TargetRef: req.TargetRef, Raw: req.Payload}) + scan, err := s.ledger.UploadAPISecurityScan(ctx, actor, app.UploadSecurityScanInput{ProductID: req.ProductID, ReleaseID: req.ReleaseID, ArtifactID: req.ArtifactID, Format: req.Format, Scanner: req.Scanner, TargetRef: req.TargetRef, Raw: req.Payload}) return http.StatusCreated, scan, err }) } @@ -1229,11 +1016,11 @@ func (s *Server) uploadManualSecurityDocument(w http.ResponseWriter, r *http.Req MediaType string `json:"media_type"` Payload json.RawMessage `json:"payload"` } - s.create(w, r, func(actor domain.Actor, body []byte) (int, any, error) { + s.create(w, r, func(ctx requestContext, actor domain.Actor, body []byte) (int, any, error) { if err := decodeJSON(body, &req); err != nil { return 0, nil, err } - doc, err := s.ledger.UploadManualSecurityDocument(r.Context(), actor, app.UploadManualSecurityDocumentInput{ProductID: req.ProductID, ReleaseID: req.ReleaseID, DocumentType: req.DocumentType, Title: req.Title, Sensitivity: req.Sensitivity, Raw: req.Payload, MediaType: req.MediaType}) + doc, err := s.ledger.UploadManualSecurityDocument(ctx, actor, app.UploadManualSecurityDocumentInput{ProductID: req.ProductID, ReleaseID: req.ReleaseID, DocumentType: req.DocumentType, Title: req.Title, Sensitivity: req.Sensitivity, Raw: req.Payload, MediaType: req.MediaType}) return http.StatusCreated, doc, err }) } @@ -1250,18 +1037,18 @@ func (s *Server) createWaiver(w http.ResponseWriter, r *http.Request) { ExpiresAt time.Time `json:"expires_at"` Supersedes string `json:"supersedes"` } - s.create(w, r, func(actor domain.Actor, body []byte) (int, any, error) { + s.create(w, r, func(ctx requestContext, actor domain.Actor, body []byte) (int, any, error) { if err := decodeJSON(body, &req); err != nil { return 0, nil, err } - waiver, err := s.ledger.CreateWaiver(r.Context(), actor, app.CreateWaiverInput{ScopeType: req.ScopeType, ScopeID: req.ScopeID, ControlID: req.ControlID, PolicyID: req.PolicyID, Owner: req.Owner, Risk: req.Risk, Reason: req.Reason, ExpiresAt: req.ExpiresAt, Supersedes: req.Supersedes}) + waiver, err := s.ledger.CreateWaiver(ctx, actor, app.CreateWaiverInput{ScopeType: req.ScopeType, ScopeID: req.ScopeID, ControlID: req.ControlID, PolicyID: req.PolicyID, Owner: req.Owner, Risk: req.Risk, Reason: req.Reason, ExpiresAt: req.ExpiresAt, Supersedes: req.Supersedes}) return http.StatusCreated, waiver, err }) } func (s *Server) approveWaiver(w http.ResponseWriter, r *http.Request) { - s.create(w, r, func(actor domain.Actor, _ []byte) (int, any, error) { - waiver, err := s.ledger.ApproveWaiver(r.Context(), actor, r.PathValue("id")) + s.create(w, r, func(ctx requestContext, actor domain.Actor, _ []byte) (int, any, error) { + waiver, err := s.ledger.ApproveWaiver(ctx, actor, r.PathValue("id")) return http.StatusOK, waiver, err }) } @@ -1274,11 +1061,11 @@ func (s *Server) createApproval(w http.ResponseWriter, r *http.Request) { Reason string `json:"reason"` EvidenceID string `json:"evidence_id"` } - s.create(w, r, func(actor domain.Actor, body []byte) (int, any, error) { + s.create(w, r, func(ctx requestContext, actor domain.Actor, body []byte) (int, any, error) { if err := decodeJSON(body, &req); err != nil { return 0, nil, err } - approval, err := s.ledger.CreateApprovalRecord(r.Context(), actor, app.CreateApprovalInput{SubjectType: req.SubjectType, SubjectID: req.SubjectID, Decision: req.Decision, Reason: req.Reason, EvidenceID: req.EvidenceID}) + approval, err := s.ledger.CreateApprovalRecord(ctx, actor, app.CreateApprovalInput{SubjectType: req.SubjectType, SubjectID: req.SubjectID, Decision: req.Decision, Reason: req.Reason, EvidenceID: req.EvidenceID}) return http.StatusCreated, approval, err }) } @@ -1287,14 +1074,15 @@ func (s *Server) createRedactionProfile(w http.ResponseWriter, r *http.Request) var req struct { Name string `json:"name"` Description string `json:"description"` + Preset string `json:"preset"` AllowedTypes []string `json:"allowed_types"` ExcludedFields []string `json:"excluded_fields"` } - s.create(w, r, func(actor domain.Actor, body []byte) (int, any, error) { + s.create(w, r, func(ctx requestContext, actor domain.Actor, body []byte) (int, any, error) { if err := decodeJSON(body, &req); err != nil { return 0, nil, err } - profile, err := s.ledger.CreateRedactionProfile(r.Context(), actor, app.CreateRedactionProfileInput{Name: req.Name, Description: req.Description, AllowedTypes: req.AllowedTypes, ExcludedFields: req.ExcludedFields}) + profile, err := s.ledger.CreateRedactionProfile(ctx, actor, app.CreateRedactionProfileInput{Name: req.Name, Description: req.Description, Preset: req.Preset, AllowedTypes: req.AllowedTypes, ExcludedFields: req.ExcludedFields}) return http.StatusCreated, profile, err }) } @@ -1307,11 +1095,11 @@ func (s *Server) createCustomerPackage(w http.ResponseWriter, r *http.Request) { Title string `json:"title"` ExpiresAt time.Time `json:"expires_at"` } - s.create(w, r, func(actor domain.Actor, body []byte) (int, any, error) { + s.create(w, r, func(ctx requestContext, actor domain.Actor, body []byte) (int, any, error) { if err := decodeJSON(body, &req); err != nil { return 0, nil, err } - pkg, err := s.ledger.CreateCustomerSecurityPackage(r.Context(), actor, app.CreateCustomerPackageInput{ProductID: req.ProductID, ReleaseID: req.ReleaseID, RedactionProfileID: req.RedactionProfileID, Title: req.Title, ExpiresAt: req.ExpiresAt}) + pkg, err := s.ledger.CreateCustomerSecurityPackage(ctx, actor, app.CreateCustomerPackageInput{ProductID: req.ProductID, ReleaseID: req.ReleaseID, RedactionProfileID: req.RedactionProfileID, Title: req.Title, ExpiresAt: req.ExpiresAt}) return http.StatusCreated, pkg, err }) } @@ -1329,71 +1117,17 @@ func (s *Server) getCustomerPackage(w http.ResponseWriter, r *http.Request) { writeData(w, http.StatusOK, pkg) } -func (s *Server) createCustomerPortalAccess(w http.ResponseWriter, r *http.Request) { - var req struct { - PackageID string `json:"package_id"` - CustomerName string `json:"customer_name"` - ExpiresAt time.Time `json:"expires_at"` - } - s.create(w, r, func(actor domain.Actor, body []byte) (int, any, error) { - if err := decodeJSON(body, &req); err != nil { - return 0, nil, err - } - access, secret, err := s.ledger.CreateCustomerPortalAccess(r.Context(), actor, app.CreateCustomerPortalAccessInput{PackageID: req.PackageID, CustomerName: req.CustomerName, ExpiresAt: req.ExpiresAt}) - return http.StatusCreated, map[string]any{"access": access, "secret": secret}, err - }) -} - -func (s *Server) accessCustomerPortalPackage(w http.ResponseWriter, r *http.Request) { - var req struct { - Token string `json:"token"` - } - body, err := readBody(r) - if err != nil { - writeProblem(w, r, err) - return - } - if err := decodeJSON(body, &req); err != nil { - writeProblem(w, r, err) +func (s *Server) downloadCustomerPackage(w http.ResponseWriter, r *http.Request) { + actor, ok := s.authenticate(w, r) + if !ok { return } - pkg, err := s.ledger.AccessCustomerPortalPackage(r.Context(), req.Token) + archive, err := s.ledger.ExportCustomerSecurityPackageArchive(r.Context(), actor, r.PathValue("id")) if err != nil { writeProblem(w, r, err) return } - writeData(w, http.StatusOK, pkg) -} - -func (s *Server) createQuestionnaireTemplate(w http.ResponseWriter, r *http.Request) { - var req struct { - Name string `json:"name"` - Version string `json:"version"` - Questions []domain.QuestionnaireQuestion `json:"questions"` - } - s.create(w, r, func(actor domain.Actor, body []byte) (int, any, error) { - if err := decodeJSON(body, &req); err != nil { - return 0, nil, err - } - tpl, err := s.ledger.CreateQuestionnaireTemplate(r.Context(), actor, app.CreateQuestionnaireTemplateInput{Name: req.Name, Version: req.Version, Questions: req.Questions}) - return http.StatusCreated, tpl, err - }) -} - -func (s *Server) createQuestionnairePackage(w http.ResponseWriter, r *http.Request) { - var req struct { - TemplateID string `json:"template_id"` - PackageID string `json:"package_id"` - ProductID string `json:"product_id"` - ReleaseID string `json:"release_id"` - } - s.create(w, r, func(actor domain.Actor, body []byte) (int, any, error) { - if err := decodeJSON(body, &req); err != nil { - return 0, nil, err - } - pkg, err := s.ledger.CreateQuestionnairePackage(r.Context(), actor, app.CreateQuestionnairePackageInput{TemplateID: req.TemplateID, PackageID: req.PackageID, ProductID: req.ProductID, ReleaseID: req.ReleaseID}) - return http.StatusCreated, pkg, err - }) + writeArchive(w, archive) } func (s *Server) securityReviewPackageReport(w http.ResponseWriter, r *http.Request) { @@ -1430,11 +1164,11 @@ func (s *Server) createReportTemplate(w http.ResponseWriter, r *http.Request) { AllowedFields []string `json:"allowed_fields"` Template string `json:"template"` } - s.create(w, r, func(actor domain.Actor, body []byte) (int, any, error) { + s.create(w, r, func(ctx requestContext, actor domain.Actor, body []byte) (int, any, error) { if err := decodeJSON(body, &req); err != nil { return 0, nil, err } - tpl, err := s.ledger.CreateCustomReportTemplate(r.Context(), actor, app.CreateReportTemplateInput{Name: req.Name, Version: req.Version, ReportType: req.ReportType, AllowedFields: req.AllowedFields, Template: req.Template}) + tpl, err := s.ledger.CreateCustomReportTemplate(ctx, actor, app.CreateReportTemplateInput{Name: req.Name, Version: req.Version, ReportType: req.ReportType, AllowedFields: req.AllowedFields, Template: req.Template}) return http.StatusCreated, tpl, err }) } @@ -1444,11 +1178,11 @@ func (s *Server) renderReportTemplate(w http.ResponseWriter, r *http.Request) { SubjectType string `json:"subject_type"` SubjectID string `json:"subject_id"` } - s.create(w, r, func(actor domain.Actor, body []byte) (int, any, error) { + s.create(w, r, func(ctx requestContext, actor domain.Actor, body []byte) (int, any, error) { if err := decodeJSON(body, &req); err != nil { return 0, nil, err } - report, err := s.ledger.RenderCustomReport(r.Context(), actor, app.RenderReportInput{TemplateID: r.PathValue("id"), SubjectType: req.SubjectType, SubjectID: req.SubjectID}) + report, err := s.ledger.RenderCustomReport(ctx, actor, app.RenderReportInput{TemplateID: r.PathValue("id"), SubjectType: req.SubjectType, SubjectID: req.SubjectID}) return http.StatusCreated, report, err }) } @@ -1458,22 +1192,22 @@ func (s *Server) exportEvidenceBundle(w http.ResponseWriter, r *http.Request) { ReleaseID string `json:"release_id"` EvidenceIDs []string `json:"evidence_ids"` } - s.create(w, r, func(actor domain.Actor, body []byte) (int, any, error) { + s.create(w, r, func(ctx requestContext, actor domain.Actor, body []byte) (int, any, error) { if err := decodeJSON(body, &req); err != nil { return 0, nil, err } - bundle, err := s.ledger.ExportEvidenceBundle(r.Context(), actor, req.ReleaseID, req.EvidenceIDs) + bundle, err := s.ledger.ExportEvidenceBundle(ctx, actor, req.ReleaseID, req.EvidenceIDs) return http.StatusCreated, bundle, err }) } func (s *Server) importEvidenceBundle(w http.ResponseWriter, r *http.Request) { var req domain.EvidenceBundle - s.create(w, r, func(actor domain.Actor, body []byte) (int, any, error) { + s.create(w, r, func(ctx requestContext, actor domain.Actor, body []byte) (int, any, error) { if err := decodeJSON(body, &req); err != nil { return 0, nil, err } - record, err := s.ledger.ImportEvidenceBundle(r.Context(), actor, req) + record, err := s.ledger.ImportEvidenceBundle(ctx, actor, req) return http.StatusCreated, record, err }) } @@ -1484,11 +1218,11 @@ func (s *Server) uploadSPDXSBOM(w http.ResponseWriter, r *http.Request) { ArtifactID string `json:"artifact_id"` Payload json.RawMessage `json:"payload"` } - s.create(w, r, func(actor domain.Actor, body []byte) (int, any, error) { + s.create(w, r, func(ctx requestContext, actor domain.Actor, body []byte) (int, any, error) { if err := decodeJSON(body, &req); err != nil { return 0, nil, err } - sbom, err := s.ledger.UploadSPDXSBOM(r.Context(), actor, req.ReleaseID, req.ArtifactID, req.Payload) + sbom, err := s.ledger.UploadSPDXSBOM(ctx, actor, req.ReleaseID, req.ArtifactID, req.Payload) return http.StatusCreated, sbom, err }) } @@ -1499,11 +1233,11 @@ func (s *Server) createSBOMDiff(w http.ResponseWriter, r *http.Request) { TargetSBOMID string `json:"target_sbom_id"` ReleaseID string `json:"release_id"` } - s.create(w, r, func(actor domain.Actor, body []byte) (int, any, error) { + s.create(w, r, func(ctx requestContext, actor domain.Actor, body []byte) (int, any, error) { if err := decodeJSON(body, &req); err != nil { return 0, nil, err } - diff, err := s.ledger.CreateSBOMDiff(r.Context(), actor, app.CreateSBOMDiffInput{BaseSBOMID: req.BaseSBOMID, TargetSBOMID: req.TargetSBOMID, ReleaseID: req.ReleaseID}) + diff, err := s.ledger.CreateSBOMDiff(ctx, actor, app.CreateSBOMDiffInput{BaseSBOMID: req.BaseSBOMID, TargetSBOMID: req.TargetSBOMID, ReleaseID: req.ReleaseID}) return http.StatusCreated, diff, err }) } @@ -1529,11 +1263,11 @@ func (s *Server) createEvidence(w http.ResponseWriter, r *http.Request) { Tags []string `json:"tags"` Limitations []string `json:"limitations"` } - s.create(w, r, func(actor domain.Actor, body []byte) (int, any, error) { + s.create(w, r, func(ctx requestContext, actor domain.Actor, body []byte) (int, any, error) { if err := decodeJSON(body, &req); err != nil { return 0, nil, err } - item, err := s.ledger.CreateEvidence(r.Context(), actor, app.CreateEvidenceInput{ + item, err := s.ledger.CreateEvidence(ctx, actor, app.CreateEvidenceInput{ ProductID: req.ProductID, ProjectID: req.ProjectID, ReleaseID: req.ReleaseID, Type: req.Type, Subtype: req.Subtype, Title: req.Title, SourceSystem: req.SourceSystem, SourceIdentity: req.SourceIdentity, CollectorID: req.CollectorID, ObservedAt: req.ObservedAt, PayloadRef: req.PayloadRef, PayloadHash: req.PayloadHash, PayloadMediaType: req.PayloadMediaType, PayloadSize: req.PayloadSize, @@ -1624,11 +1358,11 @@ func (s *Server) supersedeEvidence(w http.ResponseWriter, r *http.Request) { ReplacementEvidenceID string `json:"replacement_evidence_id"` Reason string `json:"reason"` } - s.create(w, r, func(actor domain.Actor, body []byte) (int, any, error) { + s.create(w, r, func(ctx requestContext, actor domain.Actor, body []byte) (int, any, error) { if err := decodeJSON(body, &req); err != nil { return 0, nil, err } - item, err := s.ledger.SupersedeEvidence(r.Context(), actor, r.PathValue("id"), req.ReplacementEvidenceID, req.Reason) + item, err := s.ledger.SupersedeEvidence(ctx, actor, r.PathValue("id"), req.ReplacementEvidenceID, req.Reason) return http.StatusCreated, item, err }) } @@ -1638,11 +1372,11 @@ func (s *Server) linkEvidence(w http.ResponseWriter, r *http.Request) { TargetType string `json:"target_type"` TargetID string `json:"target_id"` } - s.create(w, r, func(actor domain.Actor, body []byte) (int, any, error) { + s.create(w, r, func(ctx requestContext, actor domain.Actor, body []byte) (int, any, error) { if err := decodeJSON(body, &req); err != nil { return 0, nil, err } - item, err := s.ledger.LinkEvidence(r.Context(), actor, r.PathValue("id"), req.TargetType, req.TargetID) + item, err := s.ledger.LinkEvidence(ctx, actor, r.PathValue("id"), req.TargetType, req.TargetID) return http.StatusCreated, item, err }) } @@ -1654,11 +1388,11 @@ func (s *Server) recordEvidenceLifecycleEvent(w http.ResponseWriter, r *http.Req Details map[string]any `json:"details"` ReplacementID string `json:"replacement_id"` } - s.create(w, r, func(actor domain.Actor, body []byte) (int, any, error) { + s.create(w, r, func(ctx requestContext, actor domain.Actor, body []byte) (int, any, error) { if err := decodeJSON(body, &req); err != nil { return 0, nil, err } - event, err := s.ledger.RecordEvidenceLifecycleEvent(r.Context(), actor, r.PathValue("id"), app.RecordEvidenceLifecycleInput{ + event, err := s.ledger.RecordEvidenceLifecycleEvent(ctx, actor, r.PathValue("id"), app.RecordEvidenceLifecycleInput{ Action: req.Action, Reason: req.Reason, Details: req.Details, ReplacementID: req.ReplacementID, }) return http.StatusCreated, event, err @@ -1684,11 +1418,11 @@ func (s *Server) uploadSBOM(w http.ResponseWriter, r *http.Request) { ArtifactID string `json:"artifact_id"` Payload json.RawMessage `json:"payload"` } - s.create(w, r, func(actor domain.Actor, body []byte) (int, any, error) { + s.create(w, r, func(ctx requestContext, actor domain.Actor, body []byte) (int, any, error) { if err := decodeJSON(body, &req); err != nil { return 0, nil, err } - sbom, err := s.ledger.UploadSBOM(r.Context(), actor, req.ReleaseID, req.ArtifactID, req.Payload) + sbom, err := s.ledger.UploadSBOM(ctx, actor, req.ReleaseID, req.ArtifactID, req.Payload) return http.StatusCreated, sbom, err }) } @@ -1706,21 +1440,78 @@ func (s *Server) getSBOM(w http.ResponseWriter, r *http.Request) { writeData(w, http.StatusOK, sbom) } +func (s *Server) listSBOMComponents(w http.ResponseWriter, r *http.Request) { + actor, ok := s.authenticate(w, r) + if !ok { + return + } + query := r.URL.Query() + limit := 0 + if value := strings.TrimSpace(query.Get("limit")); value != "" { + parsed, err := strconv.Atoi(value) + if err != nil { + writeProblem(w, r, app.ErrValidation) + return + } + limit = parsed + } + components, err := s.ledger.ListSBOMComponents(r.Context(), actor, app.ListSBOMComponentsInput{ + SBOMID: query.Get("sbom_id"), + ReleaseID: query.Get("release_id"), + ArtifactID: query.Get("artifact_id"), + Query: query.Get("query"), + PURL: query.Get("purl"), + Limit: limit, + }) + if err != nil { + writeProblem(w, r, err) + return + } + writeData(w, http.StatusOK, components) +} + func (s *Server) uploadVEX(w http.ResponseWriter, r *http.Request) { var req struct { ReleaseID string `json:"release_id"` ArtifactID string `json:"artifact_id"` Payload json.RawMessage `json:"payload"` } - s.create(w, r, func(actor domain.Actor, body []byte) (int, any, error) { + s.create(w, r, func(ctx requestContext, actor domain.Actor, body []byte) (int, any, error) { if err := decodeJSON(body, &req); err != nil { return 0, nil, err } - vex, err := s.ledger.UploadVEX(r.Context(), actor, req.ReleaseID, req.ArtifactID, req.Payload) + vex, err := s.ledger.UploadVEX(ctx, actor, req.ReleaseID, req.ArtifactID, req.Payload) return http.StatusCreated, vex, err }) } +func (s *Server) previewVEXImport(w http.ResponseWriter, r *http.Request) { + var req struct { + ReleaseID string `json:"release_id"` + ArtifactID string `json:"artifact_id"` + Payload json.RawMessage `json:"payload"` + } + actor, ok := s.authenticate(w, r) + if !ok { + return + } + body, err := readBody(r) + if err != nil { + writeProblem(w, r, err) + return + } + if err := decodeJSON(body, &req); err != nil { + writeProblem(w, r, err) + return + } + preview, err := s.ledger.PreviewVEXImport(r.Context(), actor, req.ReleaseID, req.ArtifactID, req.Payload) + if err != nil { + writeProblem(w, r, err) + return + } + writeData(w, http.StatusOK, preview) +} + func (s *Server) getVEX(w http.ResponseWriter, r *http.Request) { actor, ok := s.authenticate(w, r) if !ok { @@ -1734,24 +1525,64 @@ func (s *Server) getVEX(w http.ResponseWriter, r *http.Request) { writeData(w, http.StatusOK, vex) } +func (s *Server) getVEXImportReport(w http.ResponseWriter, r *http.Request) { + actor, ok := s.authenticate(w, r) + if !ok { + return + } + report, err := s.ledger.GetVEXImportReport(r.Context(), actor, r.PathValue("id")) + if err != nil { + writeProblem(w, r, err) + return + } + writeData(w, http.StatusOK, report) +} + func (s *Server) uploadCycloneDXVEX(w http.ResponseWriter, r *http.Request) { var req struct { ReleaseID string `json:"release_id"` ArtifactID string `json:"artifact_id"` Payload json.RawMessage `json:"payload"` } - s.create(w, r, func(actor domain.Actor, body []byte) (int, any, error) { + s.create(w, r, func(ctx requestContext, actor domain.Actor, body []byte) (int, any, error) { if err := decodeJSON(body, &req); err != nil { return 0, nil, err } - vex, err := s.ledger.UploadCycloneDXVEX(r.Context(), actor, req.ReleaseID, req.ArtifactID, req.Payload) + vex, err := s.ledger.UploadCycloneDXVEX(ctx, actor, req.ReleaseID, req.ArtifactID, req.Payload) return http.StatusCreated, vex, err }) } +func (s *Server) previewCycloneDXVEXImport(w http.ResponseWriter, r *http.Request) { + var req struct { + ReleaseID string `json:"release_id"` + ArtifactID string `json:"artifact_id"` + Payload json.RawMessage `json:"payload"` + } + actor, ok := s.authenticate(w, r) + if !ok { + return + } + body, err := readBody(r) + if err != nil { + writeProblem(w, r, err) + return + } + if err := decodeJSON(body, &req); err != nil { + writeProblem(w, r, err) + return + } + preview, err := s.ledger.PreviewCycloneDXVEXImport(r.Context(), actor, req.ReleaseID, req.ArtifactID, req.Payload) + if err != nil { + writeProblem(w, r, err) + return + } + writeData(w, http.StatusOK, preview) +} + func (s *Server) uploadVulnerabilityScan(w http.ResponseWriter, r *http.Request) { - s.create(w, r, func(actor domain.Actor, body []byte) (int, any, error) { - scan, err := s.ledger.UploadVulnerabilityScan(r.Context(), actor, body) + s.create(w, r, func(ctx requestContext, actor domain.Actor, body []byte) (int, any, error) { + scan, err := s.ledger.UploadVulnerabilityScan(ctx, actor, body) return http.StatusCreated, scan, err }) } @@ -1771,35 +1602,79 @@ func (s *Server) getVulnerabilityScan(w http.ResponseWriter, r *http.Request) { func (s *Server) createVulnerabilityDecision(w http.ResponseWriter, r *http.Request) { var req struct { - Status string `json:"status"` - Justification string `json:"justification"` - ImpactStatement string `json:"impact_statement"` - ActionStatement string `json:"action_statement"` + Status string `json:"status"` + Justification string `json:"justification"` + ImpactStatement string `json:"impact_statement"` + ActionStatement string `json:"action_statement"` + CustomerVisible bool `json:"customer_visible"` + InternalNotes string `json:"internal_notes"` + EvidenceIDs []string `json:"evidence_ids"` + SupportingRefs []domain.SubjectRef `json:"supporting_refs"` + VEXDocumentID string `json:"vex_document_id"` + ReviewedAt *time.Time `json:"reviewed_at"` + ReviewDueAt *time.Time `json:"review_due_at"` } - s.create(w, r, func(actor domain.Actor, body []byte) (int, any, error) { + s.create(w, r, func(ctx requestContext, actor domain.Actor, body []byte) (int, any, error) { if err := decodeJSON(body, &req); err != nil { return 0, nil, err } - decision, err := s.ledger.CreateVulnerabilityDecision(r.Context(), actor, r.PathValue("id"), app.CreateVulnerabilityDecisionInput{ + decision, err := s.ledger.CreateVulnerabilityDecision(ctx, actor, r.PathValue("id"), app.CreateVulnerabilityDecisionInput{ Status: req.Status, Justification: req.Justification, ImpactStatement: req.ImpactStatement, ActionStatement: req.ActionStatement, + CustomerVisible: req.CustomerVisible, + InternalNotes: req.InternalNotes, + EvidenceIDs: req.EvidenceIDs, + SupportingRefs: req.SupportingRefs, + VEXDocumentID: req.VEXDocumentID, + ReviewedAt: req.ReviewedAt, + ReviewDueAt: req.ReviewDueAt, }) return http.StatusCreated, decision, err }) } +func (s *Server) listVulnerabilityDecisions(w http.ResponseWriter, r *http.Request) { + actor, ok := s.authenticate(w, r) + if !ok { + return + } + query := r.URL.Query() + var active *bool + if value := strings.TrimSpace(query.Get("active")); value != "" { + parsed, err := strconv.ParseBool(value) + if err != nil { + writeProblem(w, r, app.ErrValidation) + return + } + active = &parsed + } + decisions, err := s.ledger.ListVulnerabilityDecisions(r.Context(), actor, app.ListVulnerabilityDecisionsInput{ + ProductID: query.Get("product_id"), + ReleaseID: query.Get("release_id"), + Vulnerability: query.Get("vulnerability"), + Component: query.Get("component"), + Status: query.Get("status"), + Active: active, + }) + if err != nil { + writeProblem(w, r, err) + return + } + writeData(w, http.StatusOK, decisions) +} + func (s *Server) recordVulnerabilityWorkflow(w http.ResponseWriter, r *http.Request) { var req struct { Action string `json:"action"` Reason string `json:"reason"` } - s.create(w, r, func(actor domain.Actor, body []byte) (int, any, error) { + s.create(w, r, func(ctx requestContext, actor domain.Actor, body []byte) (int, any, error) { if err := decodeJSON(body, &req); err != nil { return 0, nil, err } - record, err := s.ledger.RecordVulnerabilityWorkflow(r.Context(), actor, app.RecordVulnerabilityWorkflowInput{FindingID: r.PathValue("id"), Action: req.Action, Reason: req.Reason}) + record, err := s.ledger.RecordVulnerabilityWorkflow(ctx, actor, app.RecordVulnerabilityWorkflowInput{FindingID: r.PathValue("id"), Action: req.Action, Reason: req.Reason}) return http.StatusCreated, record, err }) } @@ -1817,6 +1692,19 @@ func (s *Server) vulnerabilityPostureReport(w http.ResponseWriter, r *http.Reque writeData(w, http.StatusOK, report) } +func (s *Server) vulnerabilityDecisionSummaryReport(w http.ResponseWriter, r *http.Request) { + actor, ok := s.authenticate(w, r) + if !ok { + return + } + report, err := s.ledger.VulnerabilityDecisionSummaryReport(r.Context(), actor, r.URL.Query().Get("release_id")) + if err != nil { + writeProblem(w, r, err) + return + } + writeData(w, http.StatusOK, report) +} + func (s *Server) uploadOpenAPIContract(w http.ResponseWriter, r *http.Request) { var req struct { ProductID string `json:"product_id"` @@ -1824,11 +1712,11 @@ func (s *Server) uploadOpenAPIContract(w http.ResponseWriter, r *http.Request) { Version string `json:"version"` Spec json.RawMessage `json:"spec"` } - s.create(w, r, func(actor domain.Actor, body []byte) (int, any, error) { + s.create(w, r, func(ctx requestContext, actor domain.Actor, body []byte) (int, any, error) { if err := decodeJSON(body, &req); err != nil { return 0, nil, err } - contract, err := s.ledger.UploadOpenAPIContract(r.Context(), actor, req.ProductID, req.ReleaseID, req.Version, req.Spec) + contract, err := s.ledger.UploadOpenAPIContract(ctx, actor, req.ProductID, req.ReleaseID, req.Version, req.Spec) return http.StatusCreated, contract, err }) } @@ -1852,11 +1740,11 @@ func (s *Server) createOpenAPIDiff(w http.ResponseWriter, r *http.Request) { TargetContractID string `json:"target_contract_id"` ReleaseID string `json:"release_id"` } - s.create(w, r, func(actor domain.Actor, body []byte) (int, any, error) { + s.create(w, r, func(ctx requestContext, actor domain.Actor, body []byte) (int, any, error) { if err := decodeJSON(body, &req); err != nil { return 0, nil, err } - diff, err := s.ledger.CreateContractDiff(r.Context(), actor, app.CreateContractDiffInput{BaseContractID: req.BaseContractID, TargetContractID: req.TargetContractID, ReleaseID: req.ReleaseID}) + diff, err := s.ledger.CreateContractDiff(ctx, actor, app.CreateContractDiffInput{BaseContractID: req.BaseContractID, TargetContractID: req.TargetContractID, ReleaseID: req.ReleaseID}) return http.StatusCreated, diff, err }) } @@ -1865,11 +1753,11 @@ func (s *Server) evaluatePolicy(w http.ResponseWriter, r *http.Request) { var req struct { ReleaseID string `json:"release_id"` } - s.create(w, r, func(actor domain.Actor, body []byte) (int, any, error) { + s.create(w, r, func(ctx requestContext, actor domain.Actor, body []byte) (int, any, error) { if err := decodeJSON(body, &req); err != nil { return 0, nil, err } - eval, err := s.ledger.EvaluateRelease(r.Context(), actor, req.ReleaseID) + eval, err := s.ledger.EvaluateRelease(ctx, actor, req.ReleaseID) return http.StatusCreated, eval, err }) } @@ -1881,11 +1769,11 @@ func (s *Server) createCustomPolicy(w http.ResponseWriter, r *http.Request) { Description string `json:"description"` Rules []domain.PolicyRule `json:"rules"` } - s.create(w, r, func(actor domain.Actor, body []byte) (int, any, error) { + s.create(w, r, func(ctx requestContext, actor domain.Actor, body []byte) (int, any, error) { if err := decodeJSON(body, &req); err != nil { return 0, nil, err } - policy, err := s.ledger.CreateCustomPolicy(r.Context(), actor, app.CreateCustomPolicyInput{Name: req.Name, Version: req.Version, Description: req.Description, Rules: req.Rules}) + policy, err := s.ledger.CreateCustomPolicy(ctx, actor, app.CreateCustomPolicyInput{Name: req.Name, Version: req.Version, Description: req.Description, Rules: req.Rules}) return http.StatusCreated, policy, err }) } @@ -1894,11 +1782,11 @@ func (s *Server) evaluateCustomPolicy(w http.ResponseWriter, r *http.Request) { var req struct { ReleaseID string `json:"release_id"` } - s.create(w, r, func(actor domain.Actor, body []byte) (int, any, error) { + s.create(w, r, func(ctx requestContext, actor domain.Actor, body []byte) (int, any, error) { if err := decodeJSON(body, &req); err != nil { return 0, nil, err } - eval, err := s.ledger.EvaluateCustomPolicy(r.Context(), actor, r.PathValue("id"), req.ReleaseID) + eval, err := s.ledger.EvaluateCustomPolicy(ctx, actor, r.PathValue("id"), req.ReleaseID) return http.StatusCreated, eval, err }) } @@ -1925,11 +1813,11 @@ func (s *Server) createException(w http.ResponseWriter, r *http.Request) { Owner string `json:"owner"` ExpiresAt time.Time `json:"expires_at"` } - s.create(w, r, func(actor domain.Actor, body []byte) (int, any, error) { + s.create(w, r, func(ctx requestContext, actor domain.Actor, body []byte) (int, any, error) { if err := decodeJSON(body, &req); err != nil { return 0, nil, err } - exception, err := s.ledger.CreateException(r.Context(), actor, app.CreateExceptionInput{ + exception, err := s.ledger.CreateException(ctx, actor, app.CreateExceptionInput{ ReleaseID: req.ReleaseID, FindingID: req.FindingID, ControlID: req.ControlID, @@ -1955,8 +1843,8 @@ func (s *Server) listExceptions(w http.ResponseWriter, r *http.Request) { } func (s *Server) approveException(w http.ResponseWriter, r *http.Request) { - s.create(w, r, func(actor domain.Actor, _ []byte) (int, any, error) { - exception, err := s.ledger.ApproveException(r.Context(), actor, r.PathValue("id")) + s.create(w, r, func(ctx requestContext, actor domain.Actor, _ []byte) (int, any, error) { + exception, err := s.ledger.ApproveException(ctx, actor, r.PathValue("id")) return http.StatusOK, exception, err }) } @@ -2007,15 +1895,41 @@ func (s *Server) craReadinessReport(w http.ResponseWriter, r *http.Request) { writeData(w, http.StatusOK, report) } +func (s *Server) craVulnerabilityHandlingReport(w http.ResponseWriter, r *http.Request) { + actor, ok := s.authenticate(w, r) + if !ok { + return + } + report, err := s.ledger.CRAVulnerabilityHandlingReport(r.Context(), actor, r.URL.Query().Get("product_id"), r.URL.Query().Get("release_id")) + if err != nil { + writeProblem(w, r, err) + return + } + writeData(w, http.StatusOK, report) +} + +func (s *Server) securityUpdateEvidenceReport(w http.ResponseWriter, r *http.Request) { + actor, ok := s.authenticate(w, r) + if !ok { + return + } + report, err := s.ledger.SecurityUpdateEvidenceReport(r.Context(), actor, r.URL.Query().Get("product_id"), r.URL.Query().Get("release_id")) + if err != nil { + writeProblem(w, r, err) + return + } + writeData(w, http.StatusOK, report) +} + func (s *Server) createReleaseBundle(w http.ResponseWriter, r *http.Request) { var req struct { ReleaseID string `json:"release_id"` } - s.create(w, r, func(actor domain.Actor, body []byte) (int, any, error) { + s.create(w, r, func(ctx requestContext, actor domain.Actor, body []byte) (int, any, error) { if err := decodeJSON(body, &req); err != nil { return 0, nil, err } - bundle, err := s.ledger.CreateReleaseBundle(r.Context(), actor, req.ReleaseID) + bundle, err := s.ledger.CreateReleaseBundle(ctx, actor, req.ReleaseID) return http.StatusCreated, bundle, err }) } @@ -2113,11 +2027,11 @@ func (s *Server) createMerkleBatch(w http.ResponseWriter, r *http.Request) { FromSequence int64 `json:"from_sequence"` ToSequence int64 `json:"to_sequence"` } - s.create(w, r, func(actor domain.Actor, body []byte) (int, any, error) { + s.create(w, r, func(ctx requestContext, actor domain.Actor, body []byte) (int, any, error) { if err := decodeJSON(body, &req); err != nil { return 0, nil, err } - batch, err := s.ledger.CreateMerkleBatch(r.Context(), actor, app.CreateMerkleBatchInput{FromSequence: req.FromSequence, ToSequence: req.ToSequence}) + batch, err := s.ledger.CreateMerkleBatch(ctx, actor, app.CreateMerkleBatchInput{FromSequence: req.FromSequence, ToSequence: req.ToSequence}) return http.StatusCreated, batch, err }) } @@ -2142,77 +2056,46 @@ func (s *Server) createTransparencyCheckpoint(w http.ResponseWriter, r *http.Req ExternalURL string `json:"external_url"` ExternalID string `json:"external_id"` } - s.create(w, r, func(actor domain.Actor, body []byte) (int, any, error) { + s.create(w, r, func(ctx requestContext, actor domain.Actor, body []byte) (int, any, error) { if err := decodeJSON(body, &req); err != nil { return 0, nil, err } - checkpoint, err := s.ledger.CreateTransparencyCheckpoint(r.Context(), actor, app.CreateTransparencyCheckpointInput{BatchID: req.BatchID, Provider: req.Provider, ExternalURL: req.ExternalURL, ExternalID: req.ExternalID}) + checkpoint, err := s.ledger.CreateTransparencyCheckpoint(ctx, actor, app.CreateTransparencyCheckpointInput{BatchID: req.BatchID, Provider: req.Provider, ExternalURL: req.ExternalURL, ExternalID: req.ExternalID}) return http.StatusCreated, checkpoint, err }) } func (s *Server) createObjectRetentionPolicy(w http.ResponseWriter, r *http.Request) { var req struct { - Name string `json:"name"` - ObjectPrefix string `json:"object_prefix"` - Mode string `json:"mode"` - RetentionDays int `json:"retention_days"` + Name string `json:"name"` + ObjectPrefix string `json:"object_prefix"` + ObjectKey string `json:"object_key"` + RequireLegalHold bool `json:"require_legal_hold"` + Mode string `json:"mode"` + RetentionDays int `json:"retention_days"` } - s.create(w, r, func(actor domain.Actor, body []byte) (int, any, error) { + s.create(w, r, func(ctx requestContext, actor domain.Actor, body []byte) (int, any, error) { if err := decodeJSON(body, &req); err != nil { return 0, nil, err } - policy, err := s.ledger.CreateObjectRetentionPolicy(r.Context(), actor, app.CreateObjectRetentionPolicyInput{Name: req.Name, ObjectPrefix: req.ObjectPrefix, Mode: req.Mode, RetentionDays: req.RetentionDays}) + policy, err := s.ledger.CreateObjectRetentionPolicy(ctx, actor, app.CreateObjectRetentionPolicyInput{Name: req.Name, ObjectPrefix: req.ObjectPrefix, ObjectKey: req.ObjectKey, RequireLegalHold: req.RequireLegalHold, Mode: req.Mode, RetentionDays: req.RetentionDays}) return http.StatusCreated, policy, err }) } func (s *Server) verifyObjectRetentionPolicy(w http.ResponseWriter, r *http.Request) { - s.create(w, r, func(actor domain.Actor, _ []byte) (int, any, error) { - policy, err := s.ledger.VerifyObjectRetentionPolicy(r.Context(), actor, r.PathValue("id")) + s.create(w, r, func(ctx requestContext, actor domain.Actor, _ []byte) (int, any, error) { + policy, err := s.ledger.VerifyObjectRetentionPolicy(ctx, actor, r.PathValue("id")) return http.StatusOK, policy, err }) } -func (s *Server) createLegalHold(w http.ResponseWriter, r *http.Request) { - var req struct { - ScopeType string `json:"scope_type"` - ScopeID string `json:"scope_id"` - Reason string `json:"reason"` - Owner string `json:"owner"` - } - s.create(w, r, func(actor domain.Actor, body []byte) (int, any, error) { - if err := decodeJSON(body, &req); err != nil { - return 0, nil, err - } - hold, err := s.ledger.CreateLegalHold(r.Context(), actor, app.CreateLegalHoldInput{ScopeType: req.ScopeType, ScopeID: req.ScopeID, Reason: req.Reason, Owner: req.Owner}) - return http.StatusCreated, hold, err - }) -} - -func (s *Server) createRetentionOverride(w http.ResponseWriter, r *http.Request) { - var req struct { - ScopeType string `json:"scope_type"` - ScopeID string `json:"scope_id"` - RetentionUntil time.Time `json:"retention_until"` - Reason string `json:"reason"` - Owner string `json:"owner"` - } - s.create(w, r, func(actor domain.Actor, body []byte) (int, any, error) { - if err := decodeJSON(body, &req); err != nil { - return 0, nil, err - } - override, err := s.ledger.CreateRetentionOverride(r.Context(), actor, app.CreateRetentionOverrideInput{ScopeType: req.ScopeType, ScopeID: req.ScopeID, RetentionUntil: req.RetentionUntil, Reason: req.Reason, Owner: req.Owner}) - return http.StatusCreated, override, err - }) -} - -func (s *Server) retentionReport(w http.ResponseWriter, r *http.Request) { +func (s *Server) signingCustodyReviewReport(w http.ResponseWriter, r *http.Request) { actor, ok := s.authenticate(w, r) if !ok { return } - report, err := s.ledger.RetentionReport(r.Context(), actor, r.URL.Query().Get("scope_type"), r.URL.Query().Get("scope_id")) + report, err := s.ledger.SigningCustodyReviewReport(r.Context(), actor) if err != nil { writeProblem(w, r, err) return @@ -2221,8 +2104,8 @@ func (s *Server) retentionReport(w http.ResponseWriter, r *http.Request) { } func (s *Server) generateBackupManifest(w http.ResponseWriter, r *http.Request) { - s.create(w, r, func(actor domain.Actor, _ []byte) (int, any, error) { - manifest, err := s.ledger.GenerateBackupManifest(r.Context(), actor) + s.create(w, r, func(ctx requestContext, actor domain.Actor, _ []byte) (int, any, error) { + manifest, err := s.ledger.GenerateBackupManifest(ctx, actor) return http.StatusCreated, manifest, err }) } @@ -2257,13 +2140,13 @@ func (s *Server) rotateSigningKey(w http.ResponseWriter, r *http.Request) { var req struct { Reason string `json:"reason"` } - s.create(w, r, func(actor domain.Actor, body []byte) (int, any, error) { + s.create(w, r, func(ctx requestContext, actor domain.Actor, body []byte) (int, any, error) { if len(bytes.TrimSpace(body)) > 0 { if err := decodeJSON(body, &req); err != nil { return 0, nil, err } } - key, err := s.ledger.RotateSigningKey(r.Context(), actor, req.Reason) + key, err := s.ledger.RotateSigningKey(ctx, actor, req.Reason) return http.StatusCreated, key, err }) } @@ -2272,13 +2155,13 @@ func (s *Server) revokeSigningKey(w http.ResponseWriter, r *http.Request) { var req struct { Reason string `json:"reason"` } - s.create(w, r, func(actor domain.Actor, body []byte) (int, any, error) { + s.create(w, r, func(ctx requestContext, actor domain.Actor, body []byte) (int, any, error) { if len(bytes.TrimSpace(body)) > 0 { if err := decodeJSON(body, &req); err != nil { return 0, nil, err } } - key, err := s.ledger.RevokeSigningKey(r.Context(), actor, r.PathValue("id"), req.Reason) + key, err := s.ledger.RevokeSigningKey(ctx, actor, r.PathValue("id"), req.Reason) return http.StatusOK, key, err }) } @@ -2290,11 +2173,11 @@ func (s *Server) createSigningProvider(w http.ResponseWriter, r *http.Request) { KeyRef string `json:"key_ref"` Encrypted bool `json:"encrypted"` } - s.create(w, r, func(actor domain.Actor, body []byte) (int, any, error) { + s.create(w, r, func(ctx requestContext, actor domain.Actor, body []byte) (int, any, error) { if err := decodeJSON(body, &req); err != nil { return 0, nil, err } - provider, err := s.ledger.CreateSigningProvider(r.Context(), actor, app.CreateSigningProviderInput{Name: req.Name, Type: req.Type, KeyRef: req.KeyRef, Encrypted: req.Encrypted}) + provider, err := s.ledger.CreateSigningProvider(ctx, actor, app.CreateSigningProviderInput{Name: req.Name, Type: req.Type, KeyRef: req.KeyRef, Encrypted: req.Encrypted}) return http.StatusCreated, provider, err }) } @@ -2307,11 +2190,11 @@ func (s *Server) createCommercialCollector(w http.ResponseWriter, r *http.Reques ManifestHash string `json:"manifest_hash"` AllowedScopes []string `json:"allowed_scopes"` } - s.create(w, r, func(actor domain.Actor, body []byte) (int, any, error) { + s.create(w, r, func(ctx requestContext, actor domain.Actor, body []byte) (int, any, error) { if err := decodeJSON(body, &req); err != nil { return 0, nil, err } - definition, err := s.ledger.CreateCommercialCollectorDefinition(r.Context(), actor, app.CreateCommercialCollectorInput{ + definition, err := s.ledger.CreateCommercialCollectorDefinition(ctx, actor, app.CreateCommercialCollectorInput{ Name: req.Name, Provider: req.Provider, Version: req.Version, @@ -2340,11 +2223,11 @@ func (s *Server) verifySubject(w http.ResponseWriter, r *http.Request) { SubjectType string `json:"subject_type"` SubjectID string `json:"subject_id"` } - s.create(w, r, func(actor domain.Actor, body []byte) (int, any, error) { + s.create(w, r, func(ctx requestContext, actor domain.Actor, body []byte) (int, any, error) { if err := decodeJSON(body, &req); err != nil { return 0, nil, err } - result, err := s.ledger.VerifySubject(r.Context(), actor, req.SubjectType, req.SubjectID) + result, err := s.ledger.VerifySubject(ctx, actor, req.SubjectType, req.SubjectID) return http.StatusOK, result, err }) } @@ -2355,11 +2238,11 @@ func (s *Server) createAPIKey(w http.ResponseWriter, r *http.Request) { Scopes []string `json:"scopes"` ExpiresAt *time.Time `json:"expires_at"` } - s.create(w, r, func(actor domain.Actor, body []byte) (int, any, error) { + s.create(w, r, func(ctx requestContext, actor domain.Actor, body []byte) (int, any, error) { if err := decodeJSON(body, &req); err != nil { return 0, nil, err } - key, secret, err := s.ledger.CreateAPIKey(r.Context(), actor, req.Name, req.Scopes, req.ExpiresAt) + key, secret, err := s.ledger.CreateAPIKey(ctx, actor, req.Name, req.Scopes, req.ExpiresAt) return http.StatusCreated, map[string]any{"api_key": key, "secret": secret}, err }) } @@ -2377,18 +2260,19 @@ func (s *Server) listAPIKeys(w http.ResponseWriter, r *http.Request) { writeData(w, http.StatusOK, keys) } -func (s *Server) create(w http.ResponseWriter, r *http.Request, run func(domain.Actor, []byte) (int, any, error)) { +func (s *Server) create(w http.ResponseWriter, r *http.Request, run func(requestContext, domain.Actor, []byte) (int, any, error)) { actor, ok := s.authenticate(w, r) if !ok { return } + ctx := r.Context() body, err := readBody(r) if err != nil { writeProblem(w, r, err) return } - status, response, err := s.ledger.WithIdempotency(r.Context(), actor, r.Method, r.URL.Path, r.Header.Get("Idempotency-Key"), body, func() (int, any, error) { - return run(actor, body) + status, response, err := s.ledger.WithIdempotency(ctx, actor, r.Method, r.URL.Path, r.Header.Get("Idempotency-Key"), body, func() (int, any, error) { + return run(ctx, actor, body) }) if err != nil { writeProblem(w, r, err) @@ -2401,7 +2285,14 @@ func (s *Server) create(w http.ResponseWriter, r *http.Request, run func(domain. } func (s *Server) authenticate(w http.ResponseWriter, r *http.Request) (domain.Actor, bool) { - actor, err := s.ledger.Authenticate(r.Context(), strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "))) + authHeader := strings.TrimSpace(r.Header.Get("Authorization")) + token := strings.TrimSpace(strings.TrimPrefix(authHeader, "Bearer ")) + if authHeader == "" { + if cookie, err := r.Cookie(ssoSessionCookieName); err == nil { + token = strings.TrimSpace(cookie.Value) + } + } + actor, err := s.ledger.Authenticate(r.Context(), token) if err != nil { writeProblem(w, r, err) return domain.Actor{}, false @@ -2451,14 +2342,25 @@ func writeData(w http.ResponseWriter, status int, data any) { httpx.WriteJSON(w, status, map[string]any{"data": data, "meta": map[string]string{"api_version": "v1"}}) } +func writeArchive(w http.ResponseWriter, archive app.CustomerPackageArchive) { + w.Header().Set("Content-Type", archive.MediaType) + w.Header().Set("Content-Disposition", `attachment; filename="`+archive.Filename+`"`) + w.Header().Set("Content-Length", strconv.FormatInt(archive.Size, 10)) + w.Header().Set("X-Evydence-Archive-Hash", archive.Hash) + w.WriteHeader(http.StatusOK) + _, _ = w.Write(archive.Bytes) +} + func writeProblem(w http.ResponseWriter, r *http.Request, err error) { status := app.StatusCode(err) + requestID := requestIDFromRequest(r) problem := httpx.Problem{ Type: "https://evydence.local/problems/" + strings.ToLower(strings.ReplaceAll(app.ProblemCode(err), "_", "-")), Title: http.StatusText(status), Detail: app.SafeErrorDetail(err), Ext: map[string]any{ - "code": app.ProblemCode(err), + "code": app.ProblemCode(err), + "request_id": requestID, }, } if r != nil { @@ -2467,6 +2369,110 @@ func writeProblem(w http.ResponseWriter, r *http.Request, err error) { httpx.WriteProblem(w, status, problem) } +type requestRateLimiter struct { + mu sync.Mutex + limit int + window time.Duration + buckets map[string]rateLimitBucket +} + +type rateLimitBucket struct { + reset time.Time + used int +} + +func newRequestRateLimiter(limit int) *requestRateLimiter { + if limit <= 0 { + return nil + } + return &requestRateLimiter{limit: limit, window: time.Minute, buckets: map[string]rateLimitBucket{}} +} + +func (s *Server) rateLimitMiddleware(next http.Handler) http.Handler { + if s.limiter == nil { + return next + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !s.limiter.allow(clientRateLimitKey(r), time.Now().UTC()) { + w.Header().Set("Retry-After", "60") + writeProblem(w, r, app.ErrRateLimited) + return + } + next.ServeHTTP(w, r) + }) +} + +func (l *requestRateLimiter) allow(key string, now time.Time) bool { + l.mu.Lock() + defer l.mu.Unlock() + bucket := l.buckets[key] + if bucket.reset.IsZero() || !now.Before(bucket.reset) { + bucket = rateLimitBucket{reset: now.Add(l.window)} + } + if bucket.used >= l.limit { + l.buckets[key] = bucket + return false + } + bucket.used++ + l.buckets[key] = bucket + return true +} + +func clientRateLimitKey(r *http.Request) string { + host, _, err := net.SplitHostPort(strings.TrimSpace(r.RemoteAddr)) + if err == nil && host != "" { + return host + } + if remote := strings.TrimSpace(r.RemoteAddr); remote != "" { + return remote + } + return "unknown" +} + +func requestIDMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestID := strings.TrimSpace(r.Header.Get(requestIDHeader)) + if !safeRequestID(requestID) { + requestID = newRequestID() + } + r.Header.Set(requestIDHeader, requestID) + w.Header().Set(requestIDHeader, requestID) + next.ServeHTTP(w, r) + }) +} + +func requestIDFromRequest(r *http.Request) string { + if r == nil { + return newRequestID() + } + requestID := strings.TrimSpace(r.Header.Get(requestIDHeader)) + if safeRequestID(requestID) { + return requestID + } + return newRequestID() +} + +func newRequestID() string { + var buf [16]byte + if _, err := rand.Read(buf[:]); err != nil { + return "req_unavailable" + } + return "req_" + hex.EncodeToString(buf[:]) +} + +func safeRequestID(value string) bool { + if len(value) < 3 || len(value) > 128 { + return false + } + for _, r := range value { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' || r == '.' || r == ':' { + continue + } + return false + } + return true +} + func secureHeaders(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-Content-Type-Options", "nosniff") @@ -2500,6 +2506,7 @@ func (r *serveMuxRouter) Patch(pattern string, h http.HandlerFunc) { } func op(id, method, path, summary string, scopes []string) specs.Operation { + successStatus := defaultSuccessStatus(id, method) operation := specs.Operation{ OperationID: id, Method: method, @@ -2507,14 +2514,13 @@ func op(id, method, path, summary string, scopes []string) specs.Operation { Summary: summary, Tags: []string{"evydence"}, Responses: map[int]specs.Response{ - 200: {Description: "OK"}, - 201: {Description: "Created"}, - 400: {Description: "Bad request"}, - 401: {Description: "Unauthorized"}, - 403: {Description: "Forbidden"}, - 404: {Description: "Not found"}, - 409: {Description: "Conflict"}, - 422: {Description: "Verification failed"}, + successStatus: {Description: summary + " response"}, + 400: {Description: "Bad request"}, + 401: {Description: "Unauthorized"}, + 403: {Description: "Forbidden"}, + 404: {Description: "Not found"}, + 409: {Description: "Conflict"}, + 422: {Description: "Verification failed"}, }, } if len(scopes) > 0 { @@ -2524,5 +2530,64 @@ func op(id, method, path, summary string, scopes []string) specs.Operation { if method == http.MethodPost { operation.Extensions = idempotent.OperationExtensions(true) } + return withCriticalOperationDetails(operation) +} + +func authenticatedOp(id, method, path, summary string) specs.Operation { + operation := op(id, method, path, summary, nil) + operation.Security = []specs.SecurityRequirement{{Name: "BearerAuth"}} + return operation +} + +func readOnlyPostOp(id, method, path, summary string, scopes []string) specs.Operation { + operation := op(id, method, path, summary, scopes) + operation.Extensions = idempotent.OperationExtensions(false) return operation } + +func publicPostOp(id, method, path, summary string) specs.Operation { + operation := op(id, method, path, summary, nil) + operation.Security = nil + operation.Scopes = nil + operation.Extensions = nil + return operation +} + +func defaultSuccessStatus(operationID, method string) int { + if method == http.MethodGet { + return http.StatusOK + } + if method != http.MethodPost { + return http.StatusOK + } + switch operationID { + case "deactivateUser", + "logoutSSOSession", + "revokeSSOSession", + "refreshSSOProviderOIDCTrustMaterial", + "updateSSOProviderTrustMaterial", + "freezeRelease", + "approveRelease", + "promoteReleaseCandidate", + "rejectReleaseCandidate", + "verifyCosignSignature", + "verifyBuildAttestationSignature", + "previewVEXImport", + "previewCycloneDXVEXImport", + "approveWaiver", + "accessCustomerPortalPackage", + "downloadCustomerPortalPackage", + "customerPortalPackageView", + "downloadCustomerPortalPackageView", + "revokeCustomerPortalAccess", + "approveException", + "verifyObjectRetentionPolicy", + "verifyPublicTransparencyLogEntry", + "fetchPublicTransparencyLogEntryProof", + "revokeSigningKey", + "verify": + return http.StatusOK + default: + return http.StatusCreated + } +} diff --git a/internal/adapters/httpapi/router_test.go b/internal/adapters/httpapi/router_test.go index 042785a..3e09c70 100644 --- a/internal/adapters/httpapi/router_test.go +++ b/internal/adapters/httpapi/router_test.go @@ -2,10 +2,14 @@ package httpapi import ( "bytes" + "context" + "crypto/ed25519" + "crypto/rand" "encoding/base64" "encoding/json" "net/http" "net/http/httptest" + "net/url" "strings" "testing" "time" @@ -31,6 +35,411 @@ func TestRoutesValidateAndOpenAPIRenders(t *testing.T) { } } +func TestRouteFamiliesRegisterCriticalPaths(t *testing.T) { + server, _ := testServer(t) + groups := map[string][]routeDef{ + "system": server.systemRoutes(), + "identity": server.identityRoutes(), + "portal": server.packagePortalRoutes(), + "ops": server.integrityOpsRoutes(), + } + for name, routes := range groups { + if len(routes) == 0 { + t.Fatalf("%s route group is empty", name) + } + } + want := map[string]string{ + "instanceAdminSnapshot": "/v1/admin/instance", + "createSSOSession": "/v1/sso/sessions", + "createCustomerPortalAccess": "/v1/customer-portal/access", + "accessCustomerPortalPackage": "/v1/customer-portal/package", + "downloadCustomerPackage": "/v1/customer-packages/{id}/download", + "downloadCustomerPortalPackage": "/v1/customer-portal/package/download", + "receiveIncidentWebhook": "/v1/incident-webhooks/{receiver_id}", + "createLegalHold": "/v1/legal-holds", + "verifyReleaseBundle": "/v1/release-bundles/{id}/verify", + } + got := map[string]string{} + for _, route := range server.routeDefinitions() { + got[route.op.OperationID] = route.path + } + for opID, path := range want { + if got[opID] != path { + t.Fatalf("operation %s path = %q, want %q", opID, got[opID], path) + } + } +} + +func TestOpenAPICriticalRoutesHavePreciseContracts(t *testing.T) { + server, _ := testServer(t) + docBytes, err := server.OpenAPI() + if err != nil { + t.Fatalf("OpenAPI: %v", err) + } + var doc map[string]any + if err := json.Unmarshal(docBytes, &doc); err != nil { + t.Fatalf("decode OpenAPI: %v", err) + } + schemas := asStringAnyMap(t, asStringAnyMap(t, doc["components"])["schemas"]) + problem := asStringAnyMap(t, schemas["Problem"]) + problemProps := asStringAnyMap(t, problem["properties"]) + if _, ok := problemProps["request_id"]; !ok { + t.Fatalf("Problem schema missing request_id: %#v", problemProps) + } + decisionRequestProps := asStringAnyMap(t, asStringAnyMap(t, schemas["CreateVulnerabilityDecisionRequest"])["properties"]) + if _, ok := decisionRequestProps["vex_document_id"]; !ok { + t.Fatalf("manual decision request schema missing vex_document_id: %#v", decisionRequestProps) + } + if _, ok := decisionRequestProps["review_due_at"]; !ok { + t.Fatalf("manual decision request schema missing review_due_at: %#v", decisionRequestProps) + } + if _, ok := decisionRequestProps["supporting_refs"]; !ok { + t.Fatalf("manual decision request schema missing supporting_refs: %#v", decisionRequestProps) + } + decisionProps := asStringAnyMap(t, asStringAnyMap(t, schemas["VulnerabilityDecision"])["properties"]) + if _, ok := decisionProps["sbom_component_purl"]; !ok { + t.Fatalf("decision schema missing sbom_component_purl: %#v", decisionProps) + } + if _, ok := decisionProps["supporting_refs"]; !ok { + t.Fatalf("decision schema missing supporting_refs: %#v", decisionProps) + } + redactionRequestProps := asStringAnyMap(t, asStringAnyMap(t, schemas["CreateRedactionProfileRequest"])["properties"]) + if _, ok := redactionRequestProps["preset"]; !ok { + t.Fatalf("redaction profile request schema missing preset: %#v", redactionRequestProps) + } + for _, schemaName := range []string{"ReadinessStatusEnvelope", "BackupManifestEnvelope", "VerificationResultEnvelope", "ReadinessReportEnvelope", "CRAVulnerabilityHandlingReportEnvelope", "SecurityUpdateEvidenceReportEnvelope", "SigningCustodyReviewReportEnvelope", "CreateProductRequest", "ProductEnvelope", "ProductListEnvelope", "CreateProjectRequest", "ProjectEnvelope", "CreateReleaseRequest", "ReleaseEnvelope", "ReleaseEvidenceFlowEnvelope", "ReleaseSecuritySummaryEnvelope", "RegisterArtifactRequest", "ArtifactEnvelope", "CreateBuildRequest", "BuildRunEnvelope", "EvidenceUploadRequest", "SBOMEnvelope", "VEXDocumentEnvelope", "VEXImportReportEnvelope", "VEXImportPreviewEnvelope", "UploadVulnerabilityScanRequest", "VulnerabilityScanEnvelope", "VulnerabilityDecisionListEnvelope", "VulnerabilityDecisionSummaryReportEnvelope", "CreateEvidenceRequest", "CreateReleaseBundleRequest", "CreateSSOProviderRequest", "UpdateSSOProviderTrustMaterialRequest", "SSOProviderEnvelope", "VerifyProviderIdentityRequest", "ProviderVerificationEnvelope", "CreateSSOSessionRequest", "SSOSessionCreateEnvelope", "ExchangeSSOCredentialRequest", "SSOCredentialExchangeEnvelope", "CreateCustomerPortalAccessRequest", "CustomerPortalAccessCreateEnvelope", "CustomerPortalAccessEnvelope", "CustomerPortalAccessListEnvelope", "CustomerPortalPackageRequest", "QuestionnaireAnswerLibraryEntryEnvelope", "QuestionnaireAnswerLibraryEntryListEnvelope", "DataEnvelope"} { + if _, ok := schemas[schemaName]; !ok { + t.Fatalf("schema %s missing from OpenAPI components", schemaName) + } + } + + paths := asStringAnyMap(t, doc["paths"]) + ready := operationMap(t, paths, "/v1/ready", "get") + assertResponseRef(t, ready, "200", "#/components/schemas/ReadinessStatusEnvelope") + backupManifest := operationMap(t, paths, "/v1/backup-manifests", "post") + assertRequestRef(t, backupManifest, "#/components/schemas/EmptyObject") + assertResponseRef(t, backupManifest, "201", "#/components/schemas/BackupManifestEnvelope") + verifyBackup := operationMap(t, paths, "/v1/backup-manifests/{id}/verify", "get") + assertResponseRef(t, verifyBackup, "200", "#/components/schemas/VerificationResultEnvelope") + releaseReadiness := operationMap(t, paths, "/v1/reports/release-readiness", "get") + assertQueryParams(t, releaseReadiness, "release_id") + assertResponseRef(t, releaseReadiness, "200", "#/components/schemas/ReadinessReportEnvelope") + assertResponseExampleContains(t, releaseReadiness, "200", "failed-readiness-with-gap", "blocking_findings") + craReadiness := operationMap(t, paths, "/v1/reports/cra-readiness", "get") + assertQueryParams(t, craReadiness, "product_id", "release_id") + assertResponseRef(t, craReadiness, "200", "#/components/schemas/ReadinessReportEnvelope") + craVulnerabilityHandling := operationMap(t, paths, "/v1/reports/cra-vulnerability-handling", "get") + assertQueryParams(t, craVulnerabilityHandling, "product_id", "release_id") + assertResponseRef(t, craVulnerabilityHandling, "200", "#/components/schemas/CRAVulnerabilityHandlingReportEnvelope") + securityUpdateEvidence := operationMap(t, paths, "/v1/reports/security-update-evidence", "get") + assertQueryParams(t, securityUpdateEvidence, "product_id", "release_id") + assertResponseRef(t, securityUpdateEvidence, "200", "#/components/schemas/SecurityUpdateEvidenceReportEnvelope") + custodyReview := operationMap(t, paths, "/v1/reports/custody-review", "get") + assertResponseRef(t, custodyReview, "200", "#/components/schemas/SigningCustodyReviewReportEnvelope") + createProduct := operationMap(t, paths, "/v1/products", "post") + assertRequestRef(t, createProduct, "#/components/schemas/CreateProductRequest") + assertResponseRef(t, createProduct, "201", "#/components/schemas/ProductEnvelope") + listProducts := operationMap(t, paths, "/v1/products", "get") + assertResponseRef(t, listProducts, "200", "#/components/schemas/ProductListEnvelope") + getProduct := operationMap(t, paths, "/v1/products/{id}", "get") + assertResponseRef(t, getProduct, "200", "#/components/schemas/ProductEnvelope") + createProject := operationMap(t, paths, "/v1/projects", "post") + assertRequestRef(t, createProject, "#/components/schemas/CreateProjectRequest") + assertResponseRef(t, createProject, "201", "#/components/schemas/ProjectEnvelope") + getProject := operationMap(t, paths, "/v1/projects/{id}", "get") + assertResponseRef(t, getProject, "200", "#/components/schemas/ProjectEnvelope") + createRelease := operationMap(t, paths, "/v1/releases", "post") + assertRequestRef(t, createRelease, "#/components/schemas/CreateReleaseRequest") + assertRequestExampleContains(t, createRelease, "release-candidate", "1.0.0-rc.1") + assertResponseRef(t, createRelease, "201", "#/components/schemas/ReleaseEnvelope") + assertResponseExampleContains(t, createRelease, "201", "created-release", "rel_20260527120000") + getRelease := operationMap(t, paths, "/v1/releases/{id}", "get") + assertResponseRef(t, getRelease, "200", "#/components/schemas/ReleaseEnvelope") + startFlow := operationMap(t, paths, "/v1/releases/{id}/evidence-flow/start", "post") + assertResponseRef(t, startFlow, "200", "#/components/schemas/ReleaseEvidenceFlowEnvelope") + securitySummary := operationMap(t, paths, "/v1/releases/{id}/security-summary", "get") + assertResponseRef(t, securitySummary, "200", "#/components/schemas/ReleaseSecuritySummaryEnvelope") + freezeRelease := operationMap(t, paths, "/v1/releases/{id}/freeze", "post") + assertRequestRef(t, freezeRelease, "#/components/schemas/EmptyObject") + assertResponseRef(t, freezeRelease, "200", "#/components/schemas/ReleaseEnvelope") + registerArtifact := operationMap(t, paths, "/v1/artifacts", "post") + assertRequestRef(t, registerArtifact, "#/components/schemas/RegisterArtifactRequest") + assertResponseRef(t, registerArtifact, "201", "#/components/schemas/ArtifactEnvelope") + getArtifact := operationMap(t, paths, "/v1/artifacts/{id}", "get") + assertResponseRef(t, getArtifact, "200", "#/components/schemas/ArtifactEnvelope") + createBuild := operationMap(t, paths, "/v1/builds", "post") + assertRequestRef(t, createBuild, "#/components/schemas/CreateBuildRequest") + assertResponseRef(t, createBuild, "201", "#/components/schemas/BuildRunEnvelope") + uploadSBOM := operationMap(t, paths, "/v1/sboms", "post") + assertRequestRef(t, uploadSBOM, "#/components/schemas/EvidenceUploadRequest") + assertRequestExampleContains(t, uploadSBOM, "cyclonedx-release-sbom", "pkg:apk/openssl") + assertResponseRef(t, uploadSBOM, "201", "#/components/schemas/SBOMEnvelope") + uploadVulnerabilityScan := operationMap(t, paths, "/v1/vulnerability-scans", "post") + assertRequestRef(t, uploadVulnerabilityScan, "#/components/schemas/UploadVulnerabilityScanRequest") + assertRequestExampleContains(t, uploadVulnerabilityScan, "generic-critical-finding", "CVE-2026-0099") + assertResponseRef(t, uploadVulnerabilityScan, "201", "#/components/schemas/VulnerabilityScanEnvelope") + listVulnerabilityDecisions := operationMap(t, paths, "/v1/vulnerability-decisions", "get") + assertQueryParams(t, listVulnerabilityDecisions, "product_id", "release_id", "vulnerability", "component", "status", "active") + assertResponseRef(t, listVulnerabilityDecisions, "200", "#/components/schemas/VulnerabilityDecisionListEnvelope") + createVulnerabilityDecision := operationMap(t, paths, "/v1/vulnerability-findings/{id}/decisions", "post") + assertRequestExampleContains(t, createVulnerabilityDecision, "not-affected-decision", "customer_visible") + decisionSummary := operationMap(t, paths, "/v1/reports/vulnerability-decision-summary", "get") + assertQueryParams(t, decisionSummary, "release_id") + assertResponseRef(t, decisionSummary, "200", "#/components/schemas/VulnerabilityDecisionSummaryReportEnvelope") + uploadVEX := operationMap(t, paths, "/v1/vex", "post") + assertRequestRef(t, uploadVEX, "#/components/schemas/EvidenceUploadRequest") + assertRequestExampleContains(t, uploadVEX, "openvex-fixed-decision", "CVE-2026-0002") + assertResponseRef(t, uploadVEX, "201", "#/components/schemas/VEXDocumentEnvelope") + previewVEX := operationMap(t, paths, "/v1/vex/preview", "post") + assertRequestRef(t, previewVEX, "#/components/schemas/EvidenceUploadRequest") + assertResponseRef(t, previewVEX, "200", "#/components/schemas/VEXImportPreviewEnvelope") + previewCycloneDXVEX := operationMap(t, paths, "/v1/vex/cyclonedx/preview", "post") + assertRequestRef(t, previewCycloneDXVEX, "#/components/schemas/EvidenceUploadRequest") + assertResponseRef(t, previewCycloneDXVEX, "200", "#/components/schemas/VEXImportPreviewEnvelope") + getVEXImportReport := operationMap(t, paths, "/v1/vex/{id}/import-report", "get") + assertQueryParams(t, getVEXImportReport, "id") + assertResponseRef(t, getVEXImportReport, "200", "#/components/schemas/VEXImportReportEnvelope") + createEvidence := operationMap(t, paths, "/v1/evidence", "post") + assertRequestRef(t, createEvidence, "#/components/schemas/CreateEvidenceRequest") + assertProblemResponseRef(t, createEvidence, "400") + searchEvidence := operationMap(t, paths, "/v1/evidence/search", "get") + assertQueryParams(t, searchEvidence, "product_id", "project_id", "release_id", "type", "source", "tag", "cursor", "limit") + createPortalAccess := operationMap(t, paths, "/v1/customer-portal/access", "post") + assertRequestRef(t, createPortalAccess, "#/components/schemas/CreateCustomerPortalAccessRequest") + assertResponseRef(t, createPortalAccess, "201", "#/components/schemas/CustomerPortalAccessCreateEnvelope") + listPortalAccess := operationMap(t, paths, "/v1/customer-portal/access", "get") + assertQueryParams(t, listPortalAccess, "package_id") + assertResponseRef(t, listPortalAccess, "200", "#/components/schemas/CustomerPortalAccessListEnvelope") + revokePortalAccess := operationMap(t, paths, "/v1/customer-portal/access/{id}/revoke", "post") + assertRequestRef(t, revokePortalAccess, "#/components/schemas/EmptyObject") + assertResponseRef(t, revokePortalAccess, "200", "#/components/schemas/CustomerPortalAccessEnvelope") + portalPackage := operationMap(t, paths, "/v1/customer-portal/package", "post") + assertRequestRef(t, portalPackage, "#/components/schemas/CustomerPortalPackageRequest") + if _, ok := portalPackage["security"]; ok { + t.Fatalf("public portal token exchange should not advertise bearer security: %#v", portalPackage["security"]) + } + createCustomerPackage := operationMap(t, paths, "/v1/customer-packages", "post") + assertRequestExampleContains(t, createCustomerPackage, "release-evidence-package", "redaction_profile_id") + downloadPackage := operationMap(t, paths, "/v1/customer-packages/{id}/download", "get") + assertMediaResponseType(t, downloadPackage, "200", "application/zip") + portalDownload := operationMap(t, paths, "/v1/customer-portal/package/download", "post") + assertRequestRef(t, portalDownload, "#/components/schemas/CustomerPortalPackageRequest") + assertMediaResponseType(t, portalDownload, "200", "application/zip") + if _, ok := portalDownload["security"]; ok { + t.Fatalf("public portal download should not advertise bearer security: %#v", portalDownload["security"]) + } + portalViewForm := operationMap(t, paths, "/v1/customer-portal/package/view", "get") + assertMediaResponseType(t, portalViewForm, "200", "text/html") + if _, ok := portalViewForm["security"]; ok { + t.Fatalf("public portal view form should not advertise bearer security: %#v", portalViewForm["security"]) + } + portalView := operationMap(t, paths, "/v1/customer-portal/package/view", "post") + assertRequestMediaRef(t, portalView, "application/x-www-form-urlencoded", "#/components/schemas/CustomerPortalPackageRequest") + assertMediaResponseType(t, portalView, "200", "text/html") + if _, ok := portalView["security"]; ok { + t.Fatalf("public portal view should not advertise bearer security: %#v", portalView["security"]) + } + portalViewDownload := operationMap(t, paths, "/v1/customer-portal/package/view/download", "post") + assertRequestMediaRef(t, portalViewDownload, "application/x-www-form-urlencoded", "#/components/schemas/CustomerPortalPackageRequest") + assertMediaResponseType(t, portalViewDownload, "200", "application/zip") + if _, ok := portalViewDownload["security"]; ok { + t.Fatalf("public portal view download should not advertise bearer security: %#v", portalViewDownload["security"]) + } + createAnswerLibrary := operationMap(t, paths, "/v1/questionnaire-answer-library", "post") + assertRequestRef(t, createAnswerLibrary, "#/components/schemas/CreateQuestionnaireAnswerLibraryEntryRequest") + assertResponseRef(t, createAnswerLibrary, "201", "#/components/schemas/QuestionnaireAnswerLibraryEntryEnvelope") + listAnswerLibrary := operationMap(t, paths, "/v1/questionnaire-answer-library", "get") + assertQueryParams(t, listAnswerLibrary, "question_id", "product_id", "release_id") + assertResponseRef(t, listAnswerLibrary, "200", "#/components/schemas/QuestionnaireAnswerLibraryEntryListEnvelope") + incidentWebhook := operationMap(t, paths, "/v1/incident-webhooks/{receiver_id}", "post") + assertQueryParams(t, incidentWebhook, "receiver_id", "X-Evydence-Webhook-Event-ID", "X-Evydence-Webhook-Timestamp", "X-Evydence-Webhook-Signature") + if _, ok := incidentWebhook["security"]; ok { + t.Fatalf("public incident webhook should not advertise bearer security: %#v", incidentWebhook["security"]) + } + createSession := operationMap(t, paths, "/v1/sso/sessions", "post") + assertRequestRef(t, createSession, "#/components/schemas/CreateSSOSessionRequest") + assertResponseRef(t, createSession, "201", "#/components/schemas/SSOSessionCreateEnvelope") + createProvider := operationMap(t, paths, "/v1/sso/providers", "post") + assertRequestRef(t, createProvider, "#/components/schemas/CreateSSOProviderRequest") + assertResponseRef(t, createProvider, "201", "#/components/schemas/SSOProviderEnvelope") + updateProviderTrust := operationMap(t, paths, "/v1/sso/providers/{id}/trust-material", "post") + assertRequestRef(t, updateProviderTrust, "#/components/schemas/UpdateSSOProviderTrustMaterialRequest") + assertResponseRef(t, updateProviderTrust, "200", "#/components/schemas/SSOProviderEnvelope") + refreshProviderTrust := operationMap(t, paths, "/v1/sso/providers/{id}/discover-oidc", "post") + assertRequestRef(t, refreshProviderTrust, "#/components/schemas/EmptyObject") + assertResponseRef(t, refreshProviderTrust, "200", "#/components/schemas/SSOProviderEnvelope") + verifyProvider := operationMap(t, paths, "/v1/provider-verifications", "post") + assertRequestRef(t, verifyProvider, "#/components/schemas/VerifyProviderIdentityRequest") + assertResponseRef(t, verifyProvider, "201", "#/components/schemas/ProviderVerificationEnvelope") + exchangeSSO := operationMap(t, paths, "/v1/sso/session-exchanges", "post") + assertRequestRef(t, exchangeSSO, "#/components/schemas/ExchangeSSOCredentialRequest") + assertResponseRef(t, exchangeSSO, "201", "#/components/schemas/SSOCredentialExchangeEnvelope") + if _, ok := exchangeSSO["security"]; ok { + t.Fatalf("public SSO credential exchange should not require bearer auth in OpenAPI: %#v", exchangeSSO) + } + fetchTransparencyProof := operationMap(t, paths, "/v1/public-transparency-log-entries/{id}/fetch-proof", "post") + assertRequestRef(t, fetchTransparencyProof, "#/components/schemas/EmptyObject") + assertResponseRef(t, fetchTransparencyProof, "200", "#/components/schemas/PublicTransparencyLogEntryEnvelope") + verifyBundle := operationMap(t, paths, "/v1/release-bundles/{id}/verify", "get") + assertQueryParams(t, verifyBundle, "id") + assertResponseRef(t, verifyBundle, "200", "#/components/schemas/VerificationResultEnvelope") + instanceAdmin := operationMap(t, paths, "/v1/admin/instance", "get") + if !strings.Contains(asString(t, instanceAdmin["description"]), "instance:admin") { + t.Fatalf("instance admin description should document exact scope: %#v", instanceAdmin["description"]) + } +} + +func TestSSOProviderOIDCDiscoveryRefreshRoute(t *testing.T) { + pub, _, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("keygen: %v", err) + } + discovery := &fakeOIDCDiscoveryHTTP{result: app.OIDCDiscoveryResult{ + Issuer: "https://idp.example.test", + JWKS: map[string]any{"keys": []any{map[string]any{"kty": "OKP", "crv": "Ed25519", "kid": "kid-route", "x": base64.RawURLEncoding.EncodeToString(pub)}}}, + }} + ledger := app.NewLedger(app.Config{APIKeyPepper: "test", OIDC: discovery}) + _, _, secret, err := ledger.BootstrapTenant(t.Context(), "Tenant", "admin", []string{"*"}) + if err != nil { + t.Fatalf("bootstrap: %v", err) + } + server, err := NewServer(ledger) + if err != nil { + t.Fatalf("server: %v", err) + } + body := postJSON(t, server, secret, "/v1/sso/providers", "sso-discovery-provider", map[string]any{"name": "OIDC", "type": "oidc", "issuer": "https://idp.example.test", "client_id": "client"}, http.StatusCreated) + providerID := dataField(t, body, "id") + refreshed := postJSON(t, server, secret, "/v1/sso/providers/"+providerID+"/discover-oidc", "sso-discovery-refresh", map[string]any{}, http.StatusOK) + if !strings.Contains(refreshed, `"trust_material_updated_at"`) || !strings.Contains(refreshed, `"kid-route"`) { + t.Fatalf("refreshed response missing public trust material: %s", refreshed) + } + if discovery.request.ProviderID != providerID || discovery.request.Issuer != "https://idp.example.test" { + t.Fatalf("discovery request = %#v", discovery.request) + } +} + +func TestSSOCredentialExchangeRouteSetsSessionCookie(t *testing.T) { + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("keygen: %v", err) + } + ledger := app.NewLedger(app.Config{APIKeyPepper: "test"}) + _, _, secret, err := ledger.BootstrapTenant(t.Context(), "Tenant", "admin", []string{"*"}) + if err != nil { + t.Fatalf("bootstrap: %v", err) + } + admin, err := ledger.Authenticate(t.Context(), secret) + if err != nil { + t.Fatalf("auth: %v", err) + } + jwks := map[string]any{"keys": []any{map[string]any{"kty": "OKP", "crv": "Ed25519", "kid": "kid-login", "x": base64.RawURLEncoding.EncodeToString(pub)}}} + provider, err := ledger.CreateSSOProvider(t.Context(), admin, app.CreateSSOProviderInput{Name: "OIDC", Type: "oidc", Issuer: "https://idp.example.test", ClientID: "client", GroupsClaim: "groups", RoleMapping: map[string]string{"security": "security_engineer"}, JWKS: jwks}) + if err != nil { + t.Fatalf("provider: %v", err) + } + org, err := ledger.CreateOrganization(t.Context(), admin, app.CreateOrganizationInput{Name: "Example", Slug: "example"}) + if err != nil { + t.Fatalf("org: %v", err) + } + user, err := ledger.CreateUser(t.Context(), admin, app.CreateUserInput{OrganizationID: org.ID, Email: "user@example.test", DisplayName: "User"}) + if err != nil { + t.Fatalf("user: %v", err) + } + if _, err := ledger.LinkSSOIdentity(t.Context(), admin, app.LinkSSOIdentityInput{UserID: user.ID, ProviderID: provider.ID, Subject: "sub-1", Email: user.Email, Verified: true}); err != nil { + t.Fatalf("link: %v", err) + } + server, err := NewServer(ledger) + if err != nil { + t.Fatalf("server: %v", err) + } + idToken := signedRouterIDToken(t, priv, "kid-login", map[string]any{"iss": provider.Issuer, "aud": provider.ClientID, "sub": "sub-1", "email": user.Email, "email_verified": true, "groups": []string{"security"}, "exp": time.Now().Add(time.Hour).Unix()}) + body, err := json.Marshal(map[string]any{"provider_id": provider.ID, "subject": "sub-1", "id_token": idToken}) + if err != nil { + t.Fatalf("marshal body: %v", err) + } + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/v1/sso/session-exchanges", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + server.Handler().ServeHTTP(rec, req) + if rec.Code != http.StatusCreated { + t.Fatalf("exchange status=%d body=%s", rec.Code, rec.Body.String()) + } + cookie := rec.Result().Cookies()[0] + if cookie.Name != ssoSessionCookieName || !cookie.HttpOnly || cookie.Value == "" { + t.Fatalf("session cookie = %#v", cookie) + } + if strings.Contains(rec.Body.String(), idToken) { + t.Fatalf("exchange response leaked id token: %s", rec.Body.String()) + } + rec = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodPost, "/v1/sso/logout", strings.NewReader(`{}`)) + req.AddCookie(cookie) + req.Header.Set("Idempotency-Key", "logout-cookie-session") + server.Handler().ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("cookie logout status=%d body=%s", rec.Code, rec.Body.String()) + } + cleared := rec.Result().Cookies()[0] + if cleared.Name != ssoSessionCookieName || cleared.MaxAge != -1 { + t.Fatalf("cleared cookie = %#v", cleared) + } +} + +func TestPublicTransparencyProofFetchRoute(t *testing.T) { + fetcher := &fakeTransparencyProofHTTP{} + ledger := app.NewLedger(app.Config{APIKeyPepper: "test", Transparency: fetcher}) + _, _, secret, err := ledger.BootstrapTenant(t.Context(), "Tenant", "admin", []string{"*"}) + if err != nil { + t.Fatalf("bootstrap: %v", err) + } + server, err := NewServer(ledger) + if err != nil { + t.Fatalf("server: %v", err) + } + logBody := postJSON(t, server, secret, "/v1/public-transparency-logs", "fetch-log", map[string]any{"name": "public", "endpoint": "https://transparency.example.test", "public_key": "pub"}, http.StatusCreated) + batchBody := postJSON(t, server, secret, "/v1/merkle-batches", "fetch-batch", map[string]any{}, http.StatusCreated) + checkpointBody := postJSON(t, server, secret, "/v1/transparency-checkpoints", "fetch-checkpoint", map[string]any{"batch_id": dataField(t, batchBody, "id"), "provider": "internal", "external_id": "ts"}, http.StatusCreated) + entryBody := postJSON(t, server, secret, "/v1/public-transparency-log-entries", "fetch-entry", map[string]any{"log_id": dataField(t, logBody, "id"), "checkpoint_id": dataField(t, checkpointBody, "id"), "external_id": "entry"}, http.StatusCreated) + entryID := dataField(t, entryBody, "id") + entryHash := dataField(t, entryBody, "entry_hash") + fetcher.result = app.TransparencyProofResult{ExternalID: "entry", RootHash: entryHash, LeafIndex: 0, TreeSize: 1, InclusionProof: []string{}} + fetched := postJSON(t, server, secret, "/v1/public-transparency-log-entries/"+entryID+"/fetch-proof", "fetch-proof", map[string]any{}, http.StatusOK) + if !strings.Contains(fetched, `"state":"inclusion_verified"`) || !strings.Contains(fetched, `"public_log_proof_source"`) { + t.Fatalf("fetched transparency proof response: %s", fetched) + } + if fetcher.request.ExternalID != "entry" || fetcher.request.EntryHash != entryHash { + t.Fatalf("fetch request = %#v", fetcher.request) + } +} + +func TestOpenAPIDoesNotAdvertiseImpossibleSuccessStatusCodes(t *testing.T) { + server, _ := testServer(t) + docBytes, err := server.OpenAPI() + if err != nil { + t.Fatalf("OpenAPI: %v", err) + } + var doc map[string]any + if err := json.Unmarshal(docBytes, &doc); err != nil { + t.Fatalf("decode OpenAPI: %v", err) + } + for path, rawPath := range asStringAnyMap(t, doc["paths"]) { + for method, rawOperation := range asStringAnyMap(t, rawPath) { + operation := asStringAnyMap(t, rawOperation) + responses := asStringAnyMap(t, operation["responses"]) + if method == "get" { + if _, ok := responses["201"]; ok { + t.Fatalf("GET %s advertises 201 response", path) + } + } + if _, has200 := responses["200"]; method == "post" && has200 { + if _, has201 := responses["201"]; has201 { + t.Fatalf("POST %s advertises both 200 and 201 success responses", path) + } + } + } + } +} + func TestCreateProductRequiresAuthAndIdempotency(t *testing.T) { server, secret := testServer(t) rec := httptest.NewRecorder() @@ -85,6 +494,143 @@ func TestCreateProductRequiresAuthAndIdempotency(t *testing.T) { } } +func TestProductProjectArtifactReadEndpoints(t *testing.T) { + server, secret := testServer(t) + productBody := postJSON(t, server, secret, "/v1/products", "ci-read-product", map[string]any{"name": "Payments", "slug": "ci-read-payments"}, http.StatusCreated) + productID := dataField(t, productBody, "id") + projectBody := postJSON(t, server, secret, "/v1/projects", "ci-read-project", map[string]any{"product_id": productID, "name": "API"}, http.StatusCreated) + projectID := dataField(t, projectBody, "id") + artifactBody := postJSON(t, server, secret, "/v1/artifacts", "ci-read-artifact", map[string]any{ + "name": "api.tar.gz", + "media_type": "application/gzip", + "digest": "sha256:ca978112ca1bbdcafac231b39a23dc4da786eff8147c4e72b9807785afee48bb", + "size": 42, + }, http.StatusCreated) + artifactID := dataField(t, artifactBody, "id") + + if !strings.Contains(getJSON(t, server, secret, "/v1/products/"+productID, http.StatusOK), `"id":"`+productID+`"`) { + t.Fatalf("product read did not include product id") + } + if !strings.Contains(getJSON(t, server, secret, "/v1/projects/"+projectID, http.StatusOK), `"product_id":"`+productID+`"`) { + t.Fatalf("project read did not include product link") + } + if !strings.Contains(getJSON(t, server, secret, "/v1/artifacts/"+artifactID, http.StatusOK), `"id":"`+artifactID+`"`) { + t.Fatalf("artifact read did not include artifact id") + } + + getJSON(t, server, secret, "/v1/products/prod_missing", http.StatusNotFound) + getJSON(t, server, secret, "/v1/projects/proj_missing", http.StatusNotFound) + getJSON(t, server, secret, "/v1/artifacts/art_missing", http.StatusNotFound) +} + +func TestServerRateLimitReturnsSafeProblem(t *testing.T) { + ledger := app.NewLedger(app.Config{APIKeyPepper: "test"}) + server, err := NewServerWithOptions(ledger, ServerOptions{RateLimitRequestsPerMinute: 2}) + if err != nil { + t.Fatalf("NewServerWithOptions: %v", err) + } + handler := server.Handler() + for i := 0; i < 2; i++ { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/v1/version", nil) + req.RemoteAddr = "203.0.113.10:12345" + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("request %d status=%d body=%s", i, rec.Code, rec.Body.String()) + } + } + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/v1/version", nil) + req.RemoteAddr = "203.0.113.10:23456" + req.Header.Set("Authorization", "Bearer should-not-leak") + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusTooManyRequests { + t.Fatalf("rate limited status=%d body=%s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "RATE_LIMITED") || strings.Contains(rec.Body.String(), "should-not-leak") { + t.Fatalf("unsafe rate limit problem body: %s", rec.Body.String()) + } + if rec.Header().Get("Retry-After") == "" || rec.Header().Get(requestIDHeader) == "" { + t.Fatalf("missing retry/request headers: %#v", rec.Header()) + } +} + +func TestAuthenticatedReadRoutesRejectMissingBearerToken(t *testing.T) { + server, _ := testServer(t) + paths := []string{ + "/v1/admin/instance", + "/v1/api-keys", + "/v1/artifact-signatures/sig_missing", + "/v1/audit-chain/verify", + "/v1/audit-log", + "/v1/backup-manifests/backup_missing/verify", + "/v1/builds/build_missing", + "/v1/collectors", + "/v1/collectors/collector_missing/health", + "/v1/commercial-collectors", + "/v1/control-evidence", + "/v1/control-framework-template-packs", + "/v1/control-frameworks", + "/v1/controls/control_missing", + "/v1/customer-portal/access", + "/v1/customer-packages/package_missing", + "/v1/customer-packages/package_missing/download", + "/v1/deployments", + "/v1/deployments/deployment_missing", + "/v1/environments", + "/v1/evidence", + "/v1/evidence/search", + "/v1/evidence/ev_missing", + "/v1/evidence/ev_missing/lifecycle-events", + "/v1/exceptions", + "/v1/marketplace-collectors", + "/v1/marketplace-collectors/collector_missing/health", + "/v1/openapi-contracts/contract_missing", + "/v1/products", + "/v1/questionnaire-answer-library", + "/v1/release-bundles/bundle_missing", + "/v1/release-bundles/bundle_missing/manifest", + "/v1/release-bundles/bundle_missing/verify", + "/v1/release-candidates", + "/v1/release-candidates/rc_missing", + "/v1/releases/release_missing", + "/v1/reports/control-coverage", + "/v1/reports/cra-readiness", + "/v1/reports/cra-readiness-html", + "/v1/reports/cra-vulnerability-handling", + "/v1/reports/custody-review", + "/v1/reports/incident-package", + "/v1/reports/missing-evidence", + "/v1/reports/retention", + "/v1/reports/security-review-package", + "/v1/reports/security-update-evidence", + "/v1/reports/vulnerability-posture", + "/v1/role-bindings", + "/v1/sboms/sbom_missing", + "/v1/signing-keys", + "/v1/source/repositories", + "/v1/vex/vex_missing", + "/v1/vulnerability-scans/scan_missing", + } + for _, path := range paths { + t.Run(path, func(t *testing.T) { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, path, nil) + server.Handler().ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("GET %s status=%d want 401 body=%s", path, rec.Code, rec.Body.String()) + } + var problem map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &problem); err != nil { + t.Fatalf("decode problem response: %v body=%s", err, rec.Body.String()) + } + if problem["detail"] != "unauthorized" || strings.Contains(asString(t, problem["detail"]), "missing") { + t.Fatalf("unauthorized detail should stay generic: %#v", problem) + } + }) + } +} + func TestUnknownJSONFieldReturnsProblem(t *testing.T) { server, secret := testServer(t) rec := httptest.NewRecorder() @@ -92,6 +638,7 @@ func TestUnknownJSONFieldReturnsProblem(t *testing.T) { req.Header.Set("Authorization", "Bearer "+secret) req.Header.Set("Idempotency-Key", "unknown-field") req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Request-ID", "req-test-validation") server.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusBadRequest { t.Fatalf("status = %d, want 400 body=%s", rec.Code, rec.Body.String()) @@ -99,6 +646,9 @@ func TestUnknownJSONFieldReturnsProblem(t *testing.T) { if !strings.Contains(rec.Body.String(), `"code":"VALIDATION_FAILED"`) { t.Fatalf("problem code missing: %s", rec.Body.String()) } + if rec.Header().Get("X-Request-ID") != "req-test-validation" || !strings.Contains(rec.Body.String(), `"request_id":"req-test-validation"`) { + t.Fatalf("request id missing from problem/header: header=%q body=%s", rec.Header().Get("X-Request-ID"), rec.Body.String()) + } } func TestCrossTenantEvidenceReadDenied(t *testing.T) { @@ -129,6 +679,24 @@ func TestCrossTenantEvidenceReadDenied(t *testing.T) { } } +func TestInstanceAdminHTTPRequiresExplicitScope(t *testing.T) { + ledger := app.NewLedger(app.Config{APIKeyPepper: "test"}) + _, _, tenantSecret, err := ledger.BootstrapTenant(t.Context(), "Tenant", "tenant-admin", []string{"*"}) + if err != nil { + t.Fatalf("bootstrap tenant: %v", err) + } + _, _, instanceSecret, err := ledger.BootstrapTenant(t.Context(), "Instance Tenant", "instance-admin", []string{"*", app.ScopeInstanceAdmin}) + if err != nil { + t.Fatalf("bootstrap instance: %v", err) + } + server, err := NewServer(ledger) + if err != nil { + t.Fatalf("server: %v", err) + } + getJSON(t, server, tenantSecret, "/v1/admin/instance", http.StatusForbidden) + getJSON(t, server, instanceSecret, "/v1/admin/instance", http.StatusOK) +} + func TestReleaseBundleVerifyFlow(t *testing.T) { server, secret := testServer(t) productBody := postJSON(t, server, secret, "/v1/products", "prod", map[string]any{"name": "Payments", "slug": "payments"}, http.StatusCreated) @@ -153,6 +721,80 @@ func TestReleaseBundleVerifyFlow(t *testing.T) { } } +func TestReleaseEvidenceFlowStartHTTPFlow(t *testing.T) { + server, secret := testServer(t) + productBody := postJSON(t, server, secret, "/v1/products", "flow-prod", map[string]any{"name": "Flow Product", "slug": "flow-product"}, http.StatusCreated) + productID := dataField(t, productBody, "id") + releaseBody := postJSON(t, server, secret, "/v1/releases", "flow-rel", map[string]any{"product_id": productID, "version": "1.0.0"}, http.StatusCreated) + releaseID := dataField(t, releaseBody, "id") + artifactDigest := "sha256:ca978112ca1bbdcafac231b39a23dc4da786eff8147c4e72b9807785afee48bb" + artifactBody := postJSON(t, server, secret, "/v1/artifacts", "flow-artifact", map[string]any{"name": "api.tar.gz", "media_type": "application/gzip", "digest": artifactDigest, "size": 42}, http.StatusCreated) + artifactID := dataField(t, artifactBody, "id") + postJSON(t, server, secret, "/v1/sboms", "flow-sbom", map[string]any{ + "release_id": releaseID, + "artifact_id": artifactID, + "payload": map[string]any{ + "bomFormat": "CycloneDX", "specVersion": "1.6", + "components": []map[string]any{{"name": "api", "purl": "pkg:github/acme/api@abc"}}, + }, + }, http.StatusCreated) + postJSON(t, server, secret, "/v1/vulnerability-scans", "flow-scan", map[string]any{"scanner": "generic", "target_ref": "pkg:github/acme/api@abc", "release_id": releaseID, "findings": []map[string]any{}}, http.StatusCreated) + addHTTPBuildProvenance(t, server, secret, productID, releaseID, artifactID, artifactDigest) + postJSON(t, server, secret, "/v1/release-bundles", "flow-bundle", map[string]any{"release_id": releaseID}, http.StatusCreated) + flow := postJSON(t, server, secret, "/v1/releases/"+releaseID+"/evidence-flow/start", "flow-start", map[string]any{}, http.StatusOK) + for _, expected := range []string{`"schema_version":"release-evidence-flow.v1.0.0"`, `"status":"ready_for_review"`, `"artifact_refs":1`, `"sboms":1`, `"vulnerability_scans":1`, `"release_bundles":1`, `"path":"/v1/sboms"`} { + if !strings.Contains(flow, expected) { + t.Fatalf("flow response missing %s: %s", expected, flow) + } + } + if strings.Contains(flow, "certified secure") || strings.Contains(flow, "legally compliant") { + t.Fatalf("flow response uses prohibited claim wording: %s", flow) + } +} + +func TestReleaseSecuritySummaryHTTPFlow(t *testing.T) { + server, secret := testServer(t) + productBody := postJSON(t, server, secret, "/v1/products", "security-summary-prod", map[string]any{"name": "Summary Product", "slug": "summary-product"}, http.StatusCreated) + productID := dataField(t, productBody, "id") + releaseBody := postJSON(t, server, secret, "/v1/releases", "security-summary-rel", map[string]any{"product_id": productID, "version": "1.0.0"}, http.StatusCreated) + releaseID := dataField(t, releaseBody, "id") + artifactBody := postJSON(t, server, secret, "/v1/artifacts", "security-summary-artifact", map[string]any{"name": "api.tar.gz", "media_type": "application/gzip", "digest": "sha256:ca978112ca1bbdcafac231b39a23dc4da786eff8147c4e72b9807785afee48bb", "size": 42}, http.StatusCreated) + artifactID := dataField(t, artifactBody, "id") + postJSON(t, server, secret, "/v1/sboms", "security-summary-sbom", map[string]any{ + "release_id": releaseID, + "artifact_id": artifactID, + "payload": map[string]any{ + "bomFormat": "CycloneDX", "specVersion": "1.6", + "components": []map[string]any{{"name": "openssl", "purl": "pkg:apk/openssl@3.1.0"}}, + }, + }, http.StatusCreated) + scanBody := postJSON(t, server, secret, "/v1/vulnerability-scans", "security-summary-scan", map[string]any{ + "scanner": "grype", "target_ref": "pkg:oci/summary-api", "release_id": releaseID, + "findings": []map[string]any{{"vulnerability": "CVE-2026-0099", "component": "pkg:apk/openssl@3.1.0", "severity": "critical", "state": "open"}}, + }, http.StatusCreated) + findingID := firstFindingID(t, scanBody) + + summary := getJSON(t, server, secret, "/v1/releases/"+releaseID+"/security-summary", http.StatusOK) + for _, expected := range []string{`"schema_version":"release-security-summary.v1.0.0"`, `"sbom_status":"present"`, `"vulnerability_scan_status":"present"`, `"critical":1`, `"missing_required_decisions"`, `"readiness_status":"failed"`, `"package_status":"not_generated"`} { + if !strings.Contains(summary, expected) { + t.Fatalf("security summary missing %s: %s", expected, summary) + } + } + for _, forbidden := range []string{"payload_ref", "payload_hash", "internal_notes", "certified secure", "legally compliant"} { + if strings.Contains(summary, forbidden) { + t.Fatalf("security summary leaked or overclaimed %q: %s", forbidden, summary) + } + } + keyBody := postJSON(t, server, secret, "/v1/api-keys", "security-summary-release-reader", map[string]any{"name": "release reader", "scopes": []string{"release:read"}}, http.StatusCreated) + getJSON(t, server, nestedDataField(t, keyBody, "secret"), "/v1/releases/"+releaseID+"/security-summary", http.StatusForbidden) + + postJSON(t, server, secret, "/v1/vulnerability-findings/"+findingID+"/decisions", "security-summary-decision", map[string]any{"status": "not_affected", "justification": "vulnerable code is not present"}, http.StatusCreated) + summary = getJSON(t, server, secret, "/v1/releases/"+releaseID+"/security-summary", http.StatusOK) + if !strings.Contains(summary, `"not_affected":1`) || strings.Contains(summary, `"missing_required_decisions":[{`) { + t.Fatalf("security summary did not reflect decision: %s", summary) + } +} + func TestReleaseRiskDecisionHTTPFlow(t *testing.T) { server, secret := testServer(t) productBody := postJSON(t, server, secret, "/v1/products", "risk-prod", map[string]any{"name": "Payments", "slug": "risk-payments"}, http.StatusCreated) @@ -174,18 +816,42 @@ func TestReleaseRiskDecisionHTTPFlow(t *testing.T) { "findings": []map[string]any{{"vulnerability": "CVE-2026-0099", "component": "pkg:apk/openssl@3.1.0", "severity": "critical", "state": "open"}}, }, http.StatusCreated) findingID := firstFindingID(t, scanBody) + evidenceBody := postJSON(t, server, secret, "/v1/evidence", "risk-supporting-evidence", map[string]any{ + "product_id": productID, "release_id": releaseID, "type": "security_review", "title": "Runtime review", + "payload_hash": "sha256:44575cf5b2853284ce5d55751bc9e87d165bd64d5ef12c55fa291e9d40afae86", + }, http.StatusCreated) + evidenceID := dataField(t, evidenceBody, "id") postJSON(t, server, secret, "/v1/release-bundles", "risk-bundle", map[string]any{"release_id": releaseID}, http.StatusCreated) addHTTPBuildProvenance(t, server, secret, productID, releaseID, artifactID, "sha256:ca978112ca1bbdcafac231b39a23dc4da786eff8147c4e72b9807785afee48bb") report := getJSON(t, server, secret, "/v1/reports/release-readiness?release_id="+releaseID, http.StatusOK) - if !strings.Contains(report, `"result":"failed"`) || !strings.Contains(report, `"blocking_findings"`) { + if !strings.Contains(report, `"result":"failed"`) || !strings.Contains(report, `"blocking_findings"`) || !strings.Contains(report, `"sections"`) || !strings.Contains(report, `"missing_evidence"`) { t.Fatalf("expected failed readiness report with blocking findings: %s", report) } - decisionBody := postJSON(t, server, secret, "/v1/vulnerability-findings/"+findingID+"/decisions", "risk-decision", map[string]any{"status": "not_affected", "justification": "vulnerable code is not present"}, http.StatusCreated) - replayed := postJSON(t, server, secret, "/v1/vulnerability-findings/"+findingID+"/decisions", "risk-decision", map[string]any{"status": "not_affected", "justification": "vulnerable code is not present"}, http.StatusCreated) + postJSON(t, server, secret, "/v1/vulnerability-findings/"+findingID+"/decisions", "risk-decision-bad", map[string]any{"status": "not_affected", "justification": "vulnerable code is not present", "customer_visible": true}, http.StatusBadRequest) + decisionPayload := map[string]any{"status": "not_affected", "justification": "vulnerable code is not present", "impact_statement": "The vulnerable code path is not present in this release.", "customer_visible": true, "internal_notes": "private note", "evidence_ids": []string{evidenceID}, "reviewed_at": "2026-05-27T12:00:00Z", "review_due_at": "2026-08-25T12:00:00Z"} + decisionBody := postJSON(t, server, secret, "/v1/vulnerability-findings/"+findingID+"/decisions", "risk-decision", decisionPayload, http.StatusCreated) + if !strings.Contains(decisionBody, `"customer_visible":true`) || !strings.Contains(decisionBody, `"internal_notes":"private note"`) || !strings.Contains(decisionBody, evidenceID) || !strings.Contains(decisionBody, `"review_due_at":"2026-08-25T12:00:00Z"`) || !strings.Contains(decisionBody, `"sbom_component_purl":"pkg:apk/openssl@3.1.0"`) { + t.Fatalf("decision response missing customer visibility/internal note fields: %s", decisionBody) + } + replayed := postJSON(t, server, secret, "/v1/vulnerability-findings/"+findingID+"/decisions", "risk-decision", decisionPayload, http.StatusCreated) if replayed != decisionBody { t.Fatalf("decision idempotency replay changed response\nfirst=%s\nsecond=%s", decisionBody, replayed) } + historyPath := "/v1/vulnerability-decisions?release_id=" + releaseID + "&product_id=" + productID + "&vulnerability=CVE-2026-0099&component=" + url.QueryEscape("pkg:apk/openssl@3.1.0") + "&status=not_affected&active=true" + history := getJSON(t, server, secret, historyPath, http.StatusOK) + if !strings.Contains(history, `"vulnerability":"CVE-2026-0099"`) || !strings.Contains(history, `"customer_visible":true`) { + t.Fatalf("decision history response missing decision fields: %s", history) + } + getJSON(t, server, secret, "/v1/vulnerability-decisions?active=maybe", http.StatusBadRequest) + getJSON(t, server, secret, "/v1/vulnerability-decisions?status=not_a_status", http.StatusBadRequest) + summary := getJSON(t, server, secret, "/v1/reports/vulnerability-decision-summary?release_id="+releaseID, http.StatusOK) + if !strings.Contains(summary, `"report_type":"vulnerability_decision_summary"`) || !strings.Contains(summary, `"The vulnerable code path is not present in this release."`) || !strings.Contains(summary, `"reviewed_at":"2026-05-27T12:00:00Z"`) || !strings.Contains(summary, `"sbom_component_purl":"pkg:apk/openssl@3.1.0"`) { + t.Fatalf("decision summary missing customer-safe content: %s", summary) + } + if strings.Contains(summary, "private note") || strings.Contains(summary, "payload_ref") { + t.Fatalf("decision summary leaked internal notes or raw payload metadata: %s", summary) + } report = getJSON(t, server, secret, "/v1/reports/release-readiness?release_id="+releaseID, http.StatusOK) if !strings.Contains(report, `"result":"passed"`) { t.Fatalf("expected passed readiness report after decision: %s", report) @@ -219,6 +885,10 @@ func TestIntegrityRuntimeHTTPFlow(t *testing.T) { retentionBody := postJSON(t, server, secret, "/v1/object-retention-policies", "int-retention", map[string]any{"name": "lock", "mode": "governance", "retention_days": 30}, http.StatusCreated) retentionID := dataField(t, retentionBody, "id") postJSON(t, server, secret, "/v1/object-retention-policies/"+retentionID+"/verify", "int-retention-verify", map[string]any{}, http.StatusOK) + custodyReview := getJSON(t, server, secret, "/v1/reports/custody-review", http.StatusOK) + if !strings.Contains(custodyReview, `"report_type":"signing_custody_review"`) || !strings.Contains(custodyReview, `"object_lock_proof_recorded"`) || strings.Contains(custodyReview, "private_key") { + t.Fatalf("custody review response missing checks or leaked sensitive wording: %s", custodyReview) + } backupBody := postJSON(t, server, secret, "/v1/backup-manifests", "int-backup", map[string]any{}, http.StatusCreated) backupID := dataField(t, backupBody, "id") backupVerify := getJSON(t, server, secret, "/v1/backup-manifests/"+backupID+"/verify", http.StatusOK) @@ -233,6 +903,13 @@ func TestIntegrityRuntimeHTTPFlow(t *testing.T) { if !strings.Contains(metrics, `"resource_counts"`) { t.Fatalf("metrics response: %s", metrics) } + metricsText := getRawWithAccept(t, server, secret, "/v1/metrics", "text/plain", http.StatusOK) + if contentType := metricsText.Header().Get("Content-Type"); !strings.Contains(contentType, "text/plain") { + t.Fatalf("metrics content type = %q", contentType) + } + if body := metricsText.Body.String(); !strings.Contains(body, "evydence_resource_count{resource=\"evidence\"}") || strings.Contains(body, secret) { + t.Fatalf("unsafe or incomplete text metrics: %s", body) + } rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/v1/ready", nil) server.Handler().ServeHTTP(rec, req) @@ -249,10 +926,46 @@ func TestVEXAndExceptionHTTPValidation(t *testing.T) { releaseID := dataField(t, releaseBody, "id") artifactBody := postJSON(t, server, secret, "/v1/artifacts", "vex-artifact", map[string]any{"name": "api.tar.gz", "media_type": "application/gzip", "digest": "sha256:ca978112ca1bbdcafac231b39a23dc4da786eff8147c4e72b9807785afee48bb", "size": 42}, http.StatusCreated) artifactID := dataField(t, artifactBody, "id") - postJSON(t, server, secret, "/v1/vulnerability-scans", "vex-scan", map[string]any{ + scanBody := postJSON(t, server, secret, "/v1/vulnerability-scans", "vex-scan", map[string]any{ "scanner": "grype", "target_ref": "pkg:oci/payments-api", "release_id": releaseID, "findings": []map[string]any{{"vulnerability": "CVE-2026-0100", "component": "pkg:apk/openssl@3.1.0", "severity": "critical", "state": "open"}}, }, http.StatusCreated) + findingID := firstFindingID(t, scanBody) + previewPayload := map[string]any{ + "release_id": releaseID, + "artifact_id": artifactID, + "payload": map[string]any{ + "@context": "https://openvex.dev/ns/v0.2.0", + "@id": "https://example.test/vex/preview", + "author": "security@example.test", + "timestamp": "2026-05-27T12:00:00Z", + "version": 1, + "statements": []map[string]any{{ + "vulnerability": map[string]any{"name": "CVE-2026-0100"}, + "products": []map[string]any{{"@id": "pkg:apk/openssl@3.1.0"}}, + "status": "fixed", + "justification": "fixed in release candidate", + "impact_statement": "patched before release", + "action_statement": "ship fixed artifact", + }}, + }, + } + previewBodyBytes, err := json.Marshal(previewPayload) + if err != nil { + t.Fatalf("marshal preview: %v", err) + } + previewRec := httptest.NewRecorder() + previewReq := httptest.NewRequest(http.MethodPost, "/v1/vex/preview", bytes.NewReader(previewBodyBytes)) + previewReq.Header.Set("Authorization", "Bearer "+secret) + previewReq.Header.Set("Content-Type", "application/json") + server.Handler().ServeHTTP(previewRec, previewReq) + if previewRec.Code != http.StatusOK || !strings.Contains(previewRec.Body.String(), `"advisory":true`) || !strings.Contains(previewRec.Body.String(), `"decisions_would_create":1`) || strings.Contains(previewRec.Body.String(), "payload_ref") { + t.Fatalf("preview status=%d body=%s", previewRec.Code, previewRec.Body.String()) + } + badPreview := postRaw(t, server, secret, "/v1/vex/preview", "", []byte(`{"release_id":"`+releaseID+`","payload":{"author":"a"},"unexpected":true}`), http.StatusBadRequest) + if strings.Contains(badPreview, "author") { + t.Fatalf("bad preview leaked payload details: %s", badPreview) + } vexBody := postJSON(t, server, secret, "/v1/vex", "vex-upload", map[string]any{ "release_id": releaseID, "artifact_id": artifactID, @@ -274,6 +987,23 @@ func TestVEXAndExceptionHTTPValidation(t *testing.T) { }, http.StatusCreated) vexID := dataField(t, vexBody, "id") getJSON(t, server, secret, "/v1/vex/"+vexID, http.StatusOK) + importReport := getJSON(t, server, secret, "/v1/vex/"+vexID+"/import-report", http.StatusOK) + if !strings.Contains(importReport, `"status":"parsed"`) || !strings.Contains(importReport, `"decisions_created":1`) || strings.Contains(importReport, "payload_ref") { + t.Fatalf("unsafe or incomplete VEX import report: %s", importReport) + } + postJSON(t, server, secret, "/v1/vulnerability-findings/"+findingID+"/decisions", "manual-vex-link-bad", map[string]any{ + "status": "not_affected", "justification": "manual review", "customer_visible": true, "vex_document_id": vexID, + }, http.StatusBadRequest) + manualDecision := postJSON(t, server, secret, "/v1/vulnerability-findings/"+findingID+"/decisions", "manual-vex-link", map[string]any{ + "status": "not_affected", + "justification": "manual review linked to imported VEX", + "impact_statement": "This release is not affected based on a manual review linked to the imported VEX document.", + "customer_visible": true, + "vex_document_id": vexID, + }, http.StatusCreated) + if !strings.Contains(manualDecision, `"vex_document_id":"`+vexID+`"`) || strings.Contains(manualDecision, "payload_ref") { + t.Fatalf("manual linked VEX decision response unsafe or incomplete: %s", manualDecision) + } postJSON(t, server, secret, "/v1/vex", "vex-bad", map[string]any{"release_id": releaseID, "payload": map[string]any{"author": "a", "timestamp": "2026-05-27T12:00:00Z", "statements": []any{}, "extra": true}}, http.StatusBadRequest) exceptionBody := postJSON(t, server, secret, "/v1/exceptions", "exception-create", map[string]any{"release_id": releaseID, "reason": "temporary acceptance", "owner": "security", "expires_at": time.Now().UTC().Add(time.Hour).Format(time.RFC3339)}, http.StatusCreated) @@ -326,6 +1056,7 @@ func TestCollectorBuildAttestationHTTPFlow(t *testing.T) { if attestationReplay != attestationBody { t.Fatalf("attestation idempotency replay changed response\nfirst=%s\nsecond=%s", attestationBody, attestationReplay) } + postJSON(t, server, secret, "/v1/build-attestations/"+dataField(t, attestationBody, "id")+"/verify-signature", "prov-attestation-verify", map[string]any{}, http.StatusBadRequest) postRaw(t, server, collectorSecret, "/v1/builds/"+buildID+"/attestations", "prov-bad-attestation", []byte(`{"payloadType":"application/vnd.in-toto+json","payload":"@@@","signatures":[{"sig":"abc"}]}`), http.StatusBadRequest) } @@ -376,6 +1107,10 @@ func TestControlsAndReportsHTTPFlow(t *testing.T) { if replayed != frameworkBody { t.Fatalf("framework idempotency replay changed response\nfirst=%s\nsecond=%s", frameworkBody, replayed) } + frameworks := getJSON(t, server, secret, "/v1/control-frameworks", http.StatusOK) + if !strings.Contains(frameworks, frameworkID) { + t.Fatalf("framework list missing id %s: %s", frameworkID, frameworks) + } postJSON(t, server, secret, "/v1/control-frameworks", "ctrl-framework-conflict", map[string]any{"name": "Changed", "slug": "evydence-cra-readiness", "version": "2026.05"}, http.StatusConflict) controlBody := postJSON(t, server, secret, "/v1/controls", "ctrl-control", map[string]any{ "framework_id": frameworkID, @@ -424,6 +1159,14 @@ func TestControlsAndReportsHTTPFlow(t *testing.T) { if strings.Contains(strings.ToLower(cra), "automatically compliant") || strings.Contains(strings.ToLower(cra), "certified secure") { t.Fatalf("CRA report contains forbidden claim: %s", cra) } + handling := getJSON(t, server, secret, "/v1/reports/cra-vulnerability-handling?product_id="+productID+"&release_id="+releaseID, http.StatusOK) + if !strings.Contains(handling, `"report_type":"cra_vulnerability_handling"`) || strings.Contains(strings.ToLower(handling), "certified secure") { + t.Fatalf("CRA vulnerability handling report response: %s", handling) + } + update := getJSON(t, server, secret, "/v1/reports/security-update-evidence?product_id="+productID+"&release_id="+releaseID, http.StatusOK) + if !strings.Contains(update, `"report_type":"security_update_evidence"`) || strings.Contains(strings.ToLower(update), "automatically compliant") { + t.Fatalf("security update evidence report response: %s", update) + } } func TestEvidenceLifecycleSourceDeploymentHTTPFlow(t *testing.T) { @@ -452,8 +1195,11 @@ func TestEvidenceLifecycleSourceDeploymentHTTPFlow(t *testing.T) { } rcBody := postJSON(t, server, secret, "/v1/release-candidates", "inc-rc", map[string]any{"release_id": releaseID, "name": "rc.1", "artifact_ids": []string{artifactID}}, http.StatusCreated) rcID := dataField(t, rcBody, "id") + getJSON(t, server, secret, "/v1/release-candidates/"+rcID, http.StatusOK) getJSON(t, server, secret, "/v1/release-candidates?release_id="+releaseID, http.StatusOK) postJSON(t, server, secret, "/v1/release-candidates/"+rcID+"/promote", "inc-rc-promote", map[string]any{"reason": "accepted"}, http.StatusOK) + rejectedRCBody := postJSON(t, server, secret, "/v1/release-candidates", "inc-rc-reject", map[string]any{"release_id": releaseID, "name": "rc.reject", "artifact_ids": []string{artifactID}}, http.StatusCreated) + postJSON(t, server, secret, "/v1/release-candidates/"+dataField(t, rejectedRCBody, "id")+"/reject", "inc-rc-reject-transition", map[string]any{"reason": "superseded"}, http.StatusOK) postJSON(t, server, secret, "/v1/container-images", "inc-image", map[string]any{"artifact_id": artifactID, "repository": "ghcr.io/example/api", "tag": "3.0.0", "digest": digest, "platform": "linux/amd64"}, http.StatusCreated) sigBody := postJSON(t, server, secret, "/v1/artifact-signatures", "inc-sig", map[string]any{"artifact_id": artifactID, "algorithm": "cosign", "key_id": "test", "signature": "c2ln", "payload": map[string]any{"sig": "c2ln"}}, http.StatusCreated) sigID := dataField(t, sigBody, "id") @@ -469,6 +1215,10 @@ func TestEvidenceLifecycleSourceDeploymentHTTPFlow(t *testing.T) { envBody := postJSON(t, server, secret, "/v1/environments", "inc-env", map[string]any{"product_id": productID, "name": "production", "kind": "production"}, http.StatusCreated) envID := dataField(t, envBody, "id") + envs := getJSON(t, server, secret, "/v1/environments?product_id="+productID, http.StatusOK) + if !strings.Contains(envs, envID) { + t.Fatalf("environment list missing id %s: %s", envID, envs) + } deploymentBody := postJSON(t, server, secret, "/v1/deployments", "inc-deploy", map[string]any{"environment_id": envID, "release_id": releaseID, "artifact_ids": []string{artifactID}, "status": "succeeded", "started_at": "2026-05-28T12:00:00Z"}, http.StatusCreated) deploymentID := dataField(t, deploymentBody, "id") getJSON(t, server, secret, "/v1/deployments/"+deploymentID, http.StatusOK) @@ -488,6 +1238,15 @@ func TestRiskWorkflowHTTPFlow(t *testing.T) { incidentBody := postJSON(t, server, secret, "/v1/incidents", "risk2-incident", map[string]any{"product_id": productID, "release_id": releaseID, "title": "API outage", "severity": "high"}, http.StatusCreated) incidentID := dataField(t, incidentBody, "id") postJSON(t, server, secret, "/v1/incidents/"+incidentID+"/timeline", "risk2-timeline", map[string]any{"event_type": "detected", "summary": "monitor alert"}, http.StatusCreated) + webhookPublic, webhookPrivate, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("webhook key: %v", err) + } + receiverBody := postJSON(t, server, secret, "/v1/incidents/"+incidentID+"/webhook-receivers", "risk2-webhook-receiver", map[string]any{"name": "pager", "provider": "incident_tool", "public_key": base64.RawStdEncoding.EncodeToString(webhookPublic)}, http.StatusCreated) + webhookBody := signedIncidentWebhook(t, server, dataField(t, receiverBody, "id"), webhookPrivate, "evt-http-1", []byte(`{"event_type":"mitigation_applied","summary":"patched service"}`), http.StatusCreated) + if !strings.Contains(webhookBody, `"event_type":"mitigation_applied"`) || strings.Contains(webhookBody, base64.RawStdEncoding.EncodeToString(webhookPrivate)) { + t.Fatalf("webhook response invalid: %s", webhookBody) + } postJSON(t, server, secret, "/v1/remediation-tasks", "risk2-task", map[string]any{"incident_id": incidentID, "title": "patch", "owner": "security"}, http.StatusCreated) incidentReport := getJSON(t, server, secret, "/v1/reports/incident-package?incident_id="+incidentID, http.StatusOK) if !strings.Contains(incidentReport, `"report_type":"incident_package"`) { @@ -513,6 +1272,10 @@ func TestRiskWorkflowHTTPFlow(t *testing.T) { if !strings.Contains(diffBody, `"added_components"`) { t.Fatalf("sbom diff missing added components: %s", diffBody) } + componentSearch := getJSON(t, server, secret, "/v1/sbom-components?release_id="+releaseID+"&query=curl&limit=10", http.StatusOK) + if !strings.Contains(componentSearch, `"name":"curl"`) { + t.Fatalf("sbom component search missing curl: %s", componentSearch) + } vulnScan := postJSON(t, server, secret, "/v1/vulnerability-scans", "risk2-vuln-scan", map[string]any{"scanner": "grype", "target_ref": "pkg:oci/api", "release_id": releaseID, "findings": []map[string]any{{"vulnerability": "CVE-2026-4242", "component": "openssl", "severity": "critical", "state": "open"}}}, http.StatusCreated) findingID := firstFindingID(t, vulnScan) @@ -555,9 +1318,18 @@ func TestGovernancePackageAndBundleHTTPFlow(t *testing.T) { profileBody := postJSON(t, server, secret, "/v1/redaction-profiles", "gov-profile", map[string]any{"name": "customer", "allowed_types": []string{"security_review"}, "excluded_fields": []string{"payload_ref"}}, http.StatusCreated) profileID := dataField(t, profileBody, "id") + presetProfile := postJSON(t, server, secret, "/v1/redaction-profiles", "gov-profile-preset", map[string]any{"preset": "security_review"}, http.StatusCreated) + if !strings.Contains(presetProfile, `"name":"security_review"`) || !strings.Contains(presetProfile, "build_attestation") { + t.Fatalf("preset redaction profile response incomplete: %s", presetProfile) + } + postJSON(t, server, secret, "/v1/redaction-profiles", "gov-profile-preset-override", map[string]any{"preset": "customer_safe", "allowed_types": []string{"build"}}, http.StatusBadRequest) packageBody := postJSON(t, server, secret, "/v1/customer-packages", "gov-package", map[string]any{"product_id": productID, "release_id": releaseID, "redaction_profile_id": profileID, "title": "Customer package", "expires_at": time.Now().UTC().Add(time.Hour).Format(time.RFC3339)}, http.StatusCreated) packageID := dataField(t, packageBody, "id") getJSON(t, server, secret, "/v1/customer-packages/"+packageID, http.StatusOK) + archiveRec := getRaw(t, server, secret, "/v1/customer-packages/"+packageID+"/download", http.StatusOK) + if archiveRec.Header().Get("Content-Type") != "application/zip" || !bytes.HasPrefix(archiveRec.Body.Bytes(), []byte("PK")) || archiveRec.Header().Get("X-Evydence-Archive-Hash") == "" { + t.Fatalf("customer package archive response invalid headers=%v len=%d", archiveRec.Header(), archiveRec.Body.Len()) + } packageReport := getJSON(t, server, secret, "/v1/reports/security-review-package?package_id="+packageID, http.StatusOK) if !strings.Contains(packageReport, `"report_type":"security_review_package"`) { t.Fatalf("security review report missing type: %s", packageReport) @@ -590,7 +1362,8 @@ func TestEnterprisePortalRetentionAndCommercialCollectorHTTPFlow(t *testing.T) { if strings.Contains(getJSON(t, server, secret, "/v1/role-bindings", http.StatusOK), sessionSecret) { t.Fatalf("session secret leaked in role binding response") } - getJSON(t, server, sessionSecret, "/v1/admin/instance", http.StatusOK) + getJSON(t, server, secret, "/v1/admin/instance", http.StatusForbidden) + getJSON(t, server, sessionSecret, "/v1/admin/instance", http.StatusForbidden) productBody := postJSON(t, server, sessionSecret, "/v1/products", "ent-product", map[string]any{"name": "Enterprise Product", "slug": "enterprise-product"}, http.StatusCreated) productID := dataField(t, productBody, "id") @@ -602,12 +1375,30 @@ func TestEnterprisePortalRetentionAndCommercialCollectorHTTPFlow(t *testing.T) { profileID := dataField(t, profileBody, "id") packageBody := postJSON(t, server, sessionSecret, "/v1/customer-packages", "ent-package", map[string]any{"product_id": productID, "release_id": releaseID, "redaction_profile_id": profileID, "title": "Customer package", "expires_at": time.Now().UTC().Add(time.Hour).Format(time.RFC3339)}, http.StatusCreated) packageID := dataField(t, packageBody, "id") - accessBody := postJSON(t, server, sessionSecret, "/v1/customer-portal/access", "ent-access", map[string]any{"package_id": packageID, "customer_name": "ACME", "expires_at": time.Now().UTC().Add(time.Hour).Format(time.RFC3339)}, http.StatusCreated) + accessBody := postJSON(t, server, sessionSecret, "/v1/customer-portal/access", "ent-access", map[string]any{"package_id": packageID, "customer_name": "ACME", "reviewer_name": "Alice Reviewer", "reviewer_email": "alice.reviewer@example.test", "require_nda": true, "watermark": "ACME confidential review copy", "expires_at": time.Now().UTC().Add(time.Hour).Format(time.RFC3339)}, http.StatusCreated) + accessID := dataFieldFromNestedObject(t, accessBody, "access", "id") portalSecret := nestedDataField(t, accessBody, "secret") - portalBody := postJSONNoAuth(t, server, "/v1/customer-portal/package", map[string]any{"token": portalSecret}, http.StatusOK) - if !strings.Contains(portalBody, packageID) || strings.Contains(portalBody, portalSecret) { + if !strings.Contains(accessBody, `"reviewer_email":"alice.reviewer@example.test"`) || strings.Contains(accessBody, "hash") { + t.Fatalf("portal access response missing reviewer metadata or exposed hash: %s", accessBody) + } + listedAccess := getJSON(t, server, sessionSecret, "/v1/customer-portal/access?package_id="+packageID, http.StatusOK) + if !strings.Contains(listedAccess, accessID) || !strings.Contains(listedAccess, `"reviewer_name":"Alice Reviewer"`) || strings.Contains(listedAccess, portalSecret) { + t.Fatalf("portal access list invalid: %s", listedAccess) + } + postJSONNoAuth(t, server, "/v1/customer-portal/package", map[string]any{"token": portalSecret}, http.StatusForbidden) + portalBody := postJSONNoAuth(t, server, "/v1/customer-portal/package", map[string]any{"token": portalSecret, "nda_accepted": true, "nda_accepted_by": "reviewer@example.test"}, http.StatusOK) + if !strings.Contains(portalBody, packageID) || !strings.Contains(portalBody, "ACME confidential review copy") || strings.Contains(portalBody, portalSecret) { t.Fatalf("portal package response invalid: %s", portalBody) } + portalArchive := postJSONNoAuthRaw(t, server, "/v1/customer-portal/package/download", map[string]any{"token": portalSecret}, http.StatusOK) + if portalArchive.Header().Get("Content-Type") != "application/zip" || !bytes.HasPrefix(portalArchive.Body.Bytes(), []byte("PK")) || bytes.Contains(portalArchive.Body.Bytes(), []byte(portalSecret)) || !bytes.Contains(portalArchive.Body.Bytes(), []byte("WATERMARK.txt")) { + t.Fatalf("portal archive response invalid headers=%v len=%d", portalArchive.Header(), portalArchive.Body.Len()) + } + revokedAccess := postJSON(t, server, sessionSecret, "/v1/customer-portal/access/"+accessID+"/revoke", "ent-access-revoke", map[string]any{}, http.StatusOK) + if !strings.Contains(revokedAccess, `"revoked_at"`) || strings.Contains(revokedAccess, portalSecret) { + t.Fatalf("portal revoke response invalid: %s", revokedAccess) + } + postJSONNoAuth(t, server, "/v1/customer-portal/package", map[string]any{"token": portalSecret}, http.StatusUnauthorized) postJSON(t, server, sessionSecret, "/v1/legal-holds", "ent-hold", map[string]any{"scope_type": "release", "scope_id": releaseID, "reason": "extended review", "owner": "legal"}, http.StatusCreated) postJSON(t, server, sessionSecret, "/v1/retention-overrides", "ent-retention", map[string]any{"scope_type": "evidence", "scope_id": evidenceID, "retention_until": time.Now().UTC().Add(24 * time.Hour).Format(time.RFC3339), "reason": "review", "owner": "security"}, http.StatusCreated) retention := getJSON(t, server, sessionSecret, "/v1/reports/retention?scope_type=release&scope_id="+releaseID, http.StatusOK) @@ -616,8 +1407,13 @@ func TestEnterprisePortalRetentionAndCommercialCollectorHTTPFlow(t *testing.T) { } templateBody := postJSON(t, server, sessionSecret, "/v1/questionnaire-templates", "ent-question-template", map[string]any{"name": "customer", "version": "1", "questions": []map[string]any{{"id": "q1", "prompt": "Is review evidence available?", "evidence_type": "security_review"}}}, http.StatusCreated) templateID := dataField(t, templateBody, "id") + postJSON(t, server, sessionSecret, "/v1/questionnaire-answer-library", "ent-answer-library", map[string]any{"question_id": "q1", "evidence_type": "security_review", "product_id": productID, "release_id": releaseID, "answer": "A scoped security review evidence record is available in the package evidence.", "evidence_ids": []string{evidenceID}}, http.StatusCreated) + answerLibrary := getJSON(t, server, sessionSecret, "/v1/questionnaire-answer-library?product_id="+productID+"&release_id="+releaseID, http.StatusOK) + if !strings.Contains(answerLibrary, evidenceID) { + t.Fatalf("answer library missing evidence: %s", answerLibrary) + } questionnaire := postJSON(t, server, sessionSecret, "/v1/questionnaire-packages", "ent-question-package", map[string]any{"template_id": templateID, "package_id": packageID, "product_id": productID, "release_id": releaseID}, http.StatusCreated) - if !strings.Contains(questionnaire, evidenceID) { + if !strings.Contains(questionnaire, evidenceID) || !strings.Contains(questionnaire, "scoped security review evidence") { t.Fatalf("questionnaire package missing evidence id: %s", questionnaire) } collectorBody := postJSON(t, server, sessionSecret, "/v1/commercial-collectors", "ent-commercial-collector", map[string]any{"name": "jira", "provider": "jira", "version": "1.0.0", "manifest_hash": "sha256:2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae", "allowed_scopes": []string{"evidence:write"}}, http.StatusCreated) @@ -627,8 +1423,305 @@ func TestEnterprisePortalRetentionAndCommercialCollectorHTTPFlow(t *testing.T) { t.Fatalf("commercial collector list missing id: %s", collectors) } sessionID := dataFieldFromNestedObject(t, sessionBody, "session", "id") - postJSON(t, server, secret, "/v1/sso/sessions/"+sessionID+"/revoke", "ent-session-revoke", map[string]any{}, http.StatusOK) + postJSON(t, server, secret, "/v1/sso/logout", "ent-api-logout", map[string]any{}, http.StatusForbidden) + logoutBody := postJSON(t, server, sessionSecret, "/v1/sso/logout", "ent-session-logout", map[string]any{}, http.StatusOK) + if !strings.Contains(logoutBody, sessionID) || !strings.Contains(logoutBody, `"revoked_at"`) { + t.Fatalf("logout response did not revoke current session: %s", logoutBody) + } getJSON(t, server, sessionSecret, "/v1/admin/instance", http.StatusUnauthorized) + postJSON(t, server, secret, "/v1/users/"+userID+"/deactivate", "ent-user-deactivate", map[string]any{}, http.StatusOK) +} + +func TestCustomerPortalPackageViewHTMLSafety(t *testing.T) { + server, secret := testServer(t) + productBody := postJSON(t, server, secret, "/v1/products", "portal-view-product", map[string]any{"name": "Portal Product", "slug": "portal-product"}, http.StatusCreated) + productID := dataField(t, productBody, "id") + releaseBody := postJSON(t, server, secret, "/v1/releases", "portal-view-release", map[string]any{"product_id": productID, "version": "1.0.0"}, http.StatusCreated) + releaseID := dataField(t, releaseBody, "id") + digest := "sha256:ca978112ca1bbdcafac231b39a23dc4da786eff8147c4e72b9807785afee48bb" + postJSON(t, server, secret, "/v1/evidence", "portal-view-evidence", map[string]any{"product_id": productID, "release_id": releaseID, "type": "security_review", "title": "Review", "payload_hash": digest}, http.StatusCreated) + profileBody := postJSON(t, server, secret, "/v1/redaction-profiles", "portal-view-profile", map[string]any{"name": "customer", "allowed_types": []string{"security_review"}}, http.StatusCreated) + profileID := dataField(t, profileBody, "id") + packageBody := postJSON(t, server, secret, "/v1/customer-packages", "portal-view-package", map[string]any{"product_id": productID, "release_id": releaseID, "redaction_profile_id": profileID, "title": "Customer ", "expires_at": time.Now().UTC().Add(time.Hour).Format(time.RFC3339)}, http.StatusCreated) + packageID := dataField(t, packageBody, "id") + accessBody := postJSON(t, server, secret, "/v1/customer-portal/access", "portal-view-access", map[string]any{"package_id": packageID, "customer_name": "ACME ", "reviewer_name": "Rita Reviewer", "reviewer_email": "rita@example.test", "require_nda": true, "watermark": "ACME review", "expires_at": time.Now().UTC().Add(time.Hour).Format(time.RFC3339)}, http.StatusCreated) + portalSecret := nestedDataField(t, accessBody, "secret") + + form := getRawNoAuth(t, server, "/v1/customer-portal/package/view", http.StatusOK) + if ct := form.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/html") { + t.Fatalf("portal view form content type = %q", ct) + } + assertNoStoreHTMLHeaders(t, form) + if strings.Contains(form.Body.String(), portalSecret) { + t.Fatalf("portal view form leaked token") + } + + values := url.Values{} + values.Set("token", portalSecret) + values.Set("nda_accepted", "true") + values.Set("nda_accepted_by", "rita@example.test") + view := postFormNoAuthRaw(t, server, "/v1/customer-portal/package/view", values, http.StatusOK) + assertNoStoreHTMLHeaders(t, view) + htmlBody := view.Body.String() + for _, want := range []string{packageID, "Customer <script>alert(1)</script>", "ACME <b>review</b>", "manifest_hash", "limitations"} { + if !strings.Contains(htmlBody, want) { + t.Fatalf("portal view missing %q: %s", want, htmlBody) + } + } + for _, forbidden := range []string{portalSecret, " 16*1024 { + return app.ProviderIdentityValidationResult{}, app.ErrValidation + } + body, err := json.Marshal(validationRequest{ + TenantID: strings.TrimSpace(req.TenantID), + ProviderID: strings.TrimSpace(req.ProviderID), + ProviderType: strings.TrimSpace(req.ProviderType), + Issuer: strings.TrimSpace(req.Issuer), + Subject: subject, + GroupsClaim: strings.TrimSpace(req.GroupsClaim), + AccessTokenPresent: accessToken != "", + }) + if err != nil { + return app.ProviderIdentityValidationResult{}, app.ErrValidation + } + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, v.endpoint, bytes.NewReader(body)) + if err != nil { + return app.ProviderIdentityValidationResult{}, app.ErrValidation + } + httpReq.Header.Set("Accept", "application/json") + httpReq.Header.Set("Content-Type", "application/json") + if v.bearerToken != "" { + httpReq.Header.Set("Authorization", "Bearer "+v.bearerToken) + } + resp, err := v.client.Do(httpReq) + if err != nil { + return app.ProviderIdentityValidationResult{}, errors.New("provider validation gateway request failed") + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096)) + return failed("provider_validation_gateway_status"), app.ErrVerificationFailed + } + var decoded validationResponse + decoder := json.NewDecoder(io.LimitReader(resp.Body, maxBodyBytes+1)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&decoded); err != nil { + return failed("provider_validation_gateway_response"), app.ErrVerificationFailed + } + if decoder.InputOffset() > maxBodyBytes { + return failed("provider_validation_gateway_response_size"), app.ErrVerificationFailed + } + decoded.Subject = strings.TrimSpace(decoded.Subject) + if decoded.Subject != "" && decoded.Subject != subject { + return app.ProviderIdentityValidationResult{ + Checks: append(safeChecks(decoded.Checks), domain.VerifyCheck{Name: "provider_validation_gateway_subject", Result: "failed"}), + Groups: safeGroups(decoded.Groups), + Limitations: safeLimitations(decoded.Limitations), + }, app.ErrVerificationFailed + } + checks := safeChecks(decoded.Checks) + checks = append([]domain.VerifyCheck{{Name: "provider_validation_gateway", Result: "passed"}}, checks...) + if decoded.Subject != "" { + checks = append(checks, domain.VerifyCheck{Name: "provider_validation_gateway_subject", Result: "passed"}) + } + limitations := safeLimitations(decoded.Limitations) + if len(limitations) == 0 { + limitations = []string{"Provider validation gateway returned non-secret identity checks; Evydence does not store supplied access tokens or synchronize provider groups permanently."} + } + return app.ProviderIdentityValidationResult{Checks: checks, Groups: safeGroups(decoded.Groups), Limitations: limitations}, nil +} + +func failed(check string) app.ProviderIdentityValidationResult { + return app.ProviderIdentityValidationResult{ + Checks: []domain.VerifyCheck{{Name: check, Result: "failed"}}, + Limitations: []string{"Provider validation gateway failed without storing supplied access tokens or raw provider responses."}, + } +} + +func safeChecks(in []domain.VerifyCheck) []domain.VerifyCheck { + out := make([]domain.VerifyCheck, 0, len(in)) + for _, check := range in { + name := strings.TrimSpace(check.Name) + result := strings.TrimSpace(check.Result) + detail := strings.TrimSpace(check.Detail) + if name == "" || result == "" || len(name) > 128 || len(result) > 32 || len(detail) > 1024 { + continue + } + out = append(out, domain.VerifyCheck{Name: name, Result: result, Detail: detail}) + if len(out) >= 32 { + break + } + } + return out +} + +func safeGroups(in []string) []string { + out := make([]string, 0, len(in)) + seen := map[string]struct{}{} + for _, group := range in { + group = strings.TrimSpace(group) + if group == "" || len(group) > 256 { + continue + } + if _, ok := seen[group]; ok { + continue + } + seen[group] = struct{}{} + out = append(out, group) + if len(out) >= 128 { + break + } + } + return out +} + +func safeLimitations(in []string) []string { + out := make([]string, 0, len(in)) + for _, limitation := range in { + limitation = strings.TrimSpace(limitation) + if limitation == "" || len(limitation) > 1024 { + continue + } + out = append(out, limitation) + if len(out) >= 16 { + break + } + } + return out +} + +func localhostHost(host string) bool { + if strings.EqualFold(host, "localhost") { + return true + } + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() +} diff --git a/internal/adapters/identity/httpvalidator/httpvalidator_test.go b/internal/adapters/identity/httpvalidator/httpvalidator_test.go new file mode 100644 index 0000000..bc0d3a2 --- /dev/null +++ b/internal/adapters/identity/httpvalidator/httpvalidator_test.go @@ -0,0 +1,136 @@ +package httpvalidator + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/aatuh/evydence/internal/app" + "github.com/aatuh/evydence/internal/domain" +) + +func TestValidateProviderIdentityPostsSafeRequestAndReturnsChecks(t *testing.T) { + var gotAuth string + var gotRequest validationRequest + var gotBody []byte + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Fatalf("method = %s", r.Method) + } + gotAuth = r.Header.Get("Authorization") + var err error + gotBody, err = io.ReadAll(r.Body) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(gotBody, &gotRequest); err != nil { + t.Fatal(err) + } + _ = json.NewEncoder(w).Encode(validationResponse{ + Subject: "sub-1", + Groups: []string{"security", "security", "engineering"}, + Checks: []domain.VerifyCheck{{Name: "github_team_membership", Result: "passed"}}, + Limitations: []string{ + "Gateway checked provider membership with operator-managed credentials.", + }, + }) + })) + defer server.Close() + + validator, err := New(Config{Endpoint: server.URL, BearerToken: "gateway-token", AllowInsecureForLocalhost: true}) + if err != nil { + t.Fatal(err) + } + result, err := validator.ValidateProviderIdentity(t.Context(), app.ProviderIdentityValidationRequest{ + TenantID: "ten_1", + ProviderID: "sso_1", + ProviderType: "oidc", + Issuer: "https://idp.example.test", + Subject: "sub-1", + GroupsClaim: "groups", + AccessToken: "access-token-secret", + }) + if err != nil { + t.Fatal(err) + } + if gotAuth != "Bearer gateway-token" { + t.Fatalf("auth header = %q", gotAuth) + } + if !gotRequest.AccessTokenPresent || gotRequest.Subject != "sub-1" { + t.Fatalf("request = %#v", gotRequest) + } + if strings.Contains(string(gotBody), "access-token-secret") { + t.Fatalf("gateway request leaked supplied access token: %s", gotBody) + } + if len(result.Groups) != 2 || result.Groups[0] != "security" || result.Groups[1] != "engineering" { + t.Fatalf("groups = %#v", result.Groups) + } + if len(result.Checks) < 3 || result.Checks[0].Name != "provider_validation_gateway" || result.Checks[0].Result != "passed" { + t.Fatalf("checks = %#v", result.Checks) + } +} + +func TestValidateProviderIdentityRejectsSubjectMismatch(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(validationResponse{Subject: "other"}) + })) + defer server.Close() + + validator, err := New(Config{Endpoint: server.URL, AllowInsecureForLocalhost: true}) + if err != nil { + t.Fatal(err) + } + result, err := validator.ValidateProviderIdentity(t.Context(), app.ProviderIdentityValidationRequest{Subject: "sub-1"}) + if err == nil { + t.Fatal("expected subject mismatch to fail") + } + if len(result.Checks) != 1 || result.Checks[0].Name != "provider_validation_gateway_subject" || result.Checks[0].Result != "failed" { + t.Fatalf("checks = %#v", result.Checks) + } +} + +func TestValidateProviderIdentityRejectsUnknownResponseFields(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"subject":"sub-1","unexpected":"nope"}`)) + })) + defer server.Close() + + validator, err := New(Config{Endpoint: server.URL, AllowInsecureForLocalhost: true}) + if err != nil { + t.Fatal(err) + } + if _, err := validator.ValidateProviderIdentity(t.Context(), app.ProviderIdentityValidationRequest{Subject: "sub-1"}); err == nil { + t.Fatal("expected strict response decoding to reject unknown fields") + } +} + +func TestValidateProviderIdentityHidesGatewayBodyAndTokensFromErrors(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "access-token-secret rejected", http.StatusUnauthorized) + })) + defer server.Close() + + validator, err := New(Config{Endpoint: server.URL, AllowInsecureForLocalhost: true}) + if err != nil { + t.Fatal(err) + } + _, err = validator.ValidateProviderIdentity(t.Context(), app.ProviderIdentityValidationRequest{Subject: "sub-1", AccessToken: "access-token-secret"}) + if err == nil { + t.Fatal("expected provider validation failure") + } + if strings.Contains(err.Error(), "access-token-secret") { + t.Fatalf("error leaked access token: %v", err) + } +} + +func TestNewRequiresHTTPSEndpointExceptLocalhostOverride(t *testing.T) { + if _, err := New(Config{Endpoint: "http://example.com/provider"}); err == nil { + t.Fatal("expected remote HTTP endpoint to be rejected") + } + if _, err := New(Config{Endpoint: "http://127.0.0.1/provider", AllowInsecureForLocalhost: true}); err != nil { + t.Fatalf("localhost override should be accepted: %v", err) + } +} diff --git a/internal/adapters/identity/oidcdiscovery/oidcdiscovery.go b/internal/adapters/identity/oidcdiscovery/oidcdiscovery.go new file mode 100644 index 0000000..8b4f6ab --- /dev/null +++ b/internal/adapters/identity/oidcdiscovery/oidcdiscovery.go @@ -0,0 +1,144 @@ +package oidcdiscovery + +import ( + "context" + "encoding/json" + "errors" + "io" + "net" + "net/http" + "net/url" + "strings" + "time" + + "github.com/aatuh/evydence/internal/app" + "github.com/aatuh/evydence/internal/domain" +) + +const ( + defaultTimeout = 10 * time.Second + maxDiscoveryBodySize = 256 * 1024 + maxJWKSBodySize = 256 * 1024 +) + +type Config struct { + Timeout time.Duration + AllowInsecureForLocalhost bool + Client *http.Client +} + +type Client struct { + httpClient *http.Client + allowInsecureForLocalhost bool +} + +type discoveryDocument struct { + Issuer string `json:"issuer"` + JWKSURI string `json:"jwks_uri"` +} + +func New(cfg Config) *Client { + timeout := cfg.Timeout + if timeout <= 0 { + timeout = defaultTimeout + } + httpClient := cfg.Client + if httpClient == nil { + httpClient = &http.Client{ + Timeout: timeout, + CheckRedirect: func(_ *http.Request, _ []*http.Request) error { + return http.ErrUseLastResponse + }, + } + } + return &Client{httpClient: httpClient, allowInsecureForLocalhost: cfg.AllowInsecureForLocalhost} +} + +func (c *Client) FetchOIDCTrustMaterial(ctx context.Context, req app.OIDCDiscoveryRequest) (app.OIDCDiscoveryResult, error) { + if c == nil || c.httpClient == nil { + return app.OIDCDiscoveryResult{}, app.ErrValidation + } + issuer := strings.TrimRight(strings.TrimSpace(req.Issuer), "/") + issuerURL, err := parseProviderURL(issuer, c.allowInsecureForLocalhost) + if err != nil { + return app.OIDCDiscoveryResult{}, err + } + discoveryURL := *issuerURL + discoveryURL.Path = strings.TrimRight(discoveryURL.Path, "/") + "/.well-known/openid-configuration" + discoveryURL.RawQuery = "" + discoveryURL.Fragment = "" + + var doc discoveryDocument + if err := c.getJSON(ctx, discoveryURL.String(), maxDiscoveryBodySize, &doc); err != nil { + return app.OIDCDiscoveryResult{}, err + } + doc.Issuer = strings.TrimRight(strings.TrimSpace(doc.Issuer), "/") + doc.JWKSURI = strings.TrimSpace(doc.JWKSURI) + if doc.Issuer != issuer || doc.JWKSURI == "" { + return app.OIDCDiscoveryResult{}, app.ErrVerificationFailed + } + jwksURL, err := parseProviderURL(doc.JWKSURI, c.allowInsecureForLocalhost) + if err != nil { + return app.OIDCDiscoveryResult{}, err + } + var jwks map[string]any + if err := c.getJSON(ctx, jwksURL.String(), maxJWKSBodySize, &jwks); err != nil { + return app.OIDCDiscoveryResult{}, err + } + return app.OIDCDiscoveryResult{ + Issuer: doc.Issuer, + JWKS: jwks, + Checks: []domain.VerifyCheck{ + {Name: "oidc_discovery_document", Result: "passed"}, + {Name: "oidc_jwks_fetch", Result: "passed"}, + }, + Limitations: []string{"OIDC discovery refreshes public JWKS trust material only; it does not authenticate users or synchronize provider groups."}, + }, nil +} + +func (c *Client) getJSON(ctx context.Context, endpoint string, maxBytes int64, out any) error { + httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return app.ErrValidation + } + httpReq.Header.Set("Accept", "application/json") + resp, err := c.httpClient.Do(httpReq) + if err != nil { + return errors.New("fetch oidc metadata") + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096)) + return app.ErrVerificationFailed + } + decoder := json.NewDecoder(io.LimitReader(resp.Body, maxBytes+1)) + if err := decoder.Decode(out); err != nil { + return app.ErrVerificationFailed + } + if decoder.InputOffset() > maxBytes { + return app.ErrVerificationFailed + } + return nil +} + +func parseProviderURL(raw string, allowInsecureLocalhost bool) (*url.URL, error) { + parsed, err := url.Parse(strings.TrimSpace(raw)) + if err != nil || parsed.Scheme == "" || parsed.Host == "" || parsed.User != nil { + return nil, app.ErrValidation + } + if parsed.Scheme == "https" { + return parsed, nil + } + if parsed.Scheme == "http" && allowInsecureLocalhost && localhostHost(parsed.Hostname()) { + return parsed, nil + } + return nil, app.ErrValidation +} + +func localhostHost(host string) bool { + if strings.EqualFold(host, "localhost") { + return true + } + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() +} diff --git a/internal/adapters/identity/oidcdiscovery/oidcdiscovery_test.go b/internal/adapters/identity/oidcdiscovery/oidcdiscovery_test.go new file mode 100644 index 0000000..aec7e95 --- /dev/null +++ b/internal/adapters/identity/oidcdiscovery/oidcdiscovery_test.go @@ -0,0 +1,73 @@ +package oidcdiscovery + +import ( + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/aatuh/evydence/internal/app" +) + +func TestFetchOIDCTrustMaterialFetchesDiscoveryAndJWKS(t *testing.T) { + jwks := map[string]any{"keys": []any{map[string]any{"kty": "OKP", "crv": "Ed25519", "kid": "kid-1", "x": base64.RawURLEncoding.EncodeToString([]byte("public-key"))}}} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/issuer/.well-known/openid-configuration": + _ = json.NewEncoder(w).Encode(map[string]any{"issuer": "http://" + r.Host + "/issuer", "jwks_uri": "http://" + r.Host + "/keys"}) + case "/keys": + _ = json.NewEncoder(w).Encode(jwks) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + client := New(Config{AllowInsecureForLocalhost: true}) + result, err := client.FetchOIDCTrustMaterial(t.Context(), app.OIDCDiscoveryRequest{TenantID: "ten_1", ProviderID: "sso_1", Issuer: server.URL + "/issuer"}) + if err != nil { + t.Fatalf("FetchOIDCTrustMaterial: %v", err) + } + if result.Issuer != server.URL+"/issuer" || len(result.JWKS) == 0 || len(result.Checks) != 2 { + t.Fatalf("result = %#v", result) + } +} + +func TestFetchOIDCTrustMaterialRejectsMismatchedIssuer(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{"issuer": "http://localhost/other", "jwks_uri": "http://localhost/keys"}) + })) + defer server.Close() + + client := New(Config{AllowInsecureForLocalhost: true}) + if _, err := client.FetchOIDCTrustMaterial(t.Context(), app.OIDCDiscoveryRequest{Issuer: server.URL}); err == nil { + t.Fatal("FetchOIDCTrustMaterial err=nil, want error") + } +} + +func TestFetchOIDCTrustMaterialRequiresHTTPSUnlessLocalAllowed(t *testing.T) { + client := New(Config{}) + if _, err := client.FetchOIDCTrustMaterial(t.Context(), app.OIDCDiscoveryRequest{Issuer: "http://127.0.0.1:8080/issuer"}); err == nil { + t.Fatal("FetchOIDCTrustMaterial err=nil, want insecure issuer rejection") + } +} + +func TestFetchOIDCTrustMaterialDoesNotReturnRawProviderBodies(t *testing.T) { + secretBody := `{"issuer":"bad","jwks_uri":"token-secret-value"}` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(secretBody)) + })) + defer server.Close() + + client := New(Config{AllowInsecureForLocalhost: true}) + _, err := client.FetchOIDCTrustMaterial(t.Context(), app.OIDCDiscoveryRequest{Issuer: server.URL}) + if err == nil { + t.Fatal("FetchOIDCTrustMaterial err=nil, want error") + } + if strings.Contains(err.Error(), "token-secret-value") { + t.Fatalf("error leaked provider body: %v", err) + } +} diff --git a/internal/adapters/identity/oidcuserinfo/oidcuserinfo.go b/internal/adapters/identity/oidcuserinfo/oidcuserinfo.go new file mode 100644 index 0000000..dfb6242 --- /dev/null +++ b/internal/adapters/identity/oidcuserinfo/oidcuserinfo.go @@ -0,0 +1,184 @@ +package oidcuserinfo + +import ( + "context" + "encoding/json" + "errors" + "io" + "net" + "net/http" + "net/url" + "strings" + "time" + + "github.com/aatuh/evydence/internal/app" + "github.com/aatuh/evydence/internal/domain" +) + +const defaultTimeout = 10 * time.Second + +type Config struct { + Timeout time.Duration + AllowInsecureForLocalhost bool + Client *http.Client +} + +type Validator struct { + client *http.Client + allowInsecureForLocalhost bool +} + +type discoveryDocument struct { + UserInfoEndpoint string `json:"userinfo_endpoint"` +} + +type userInfoDocument map[string]any + +func New(cfg Config) *Validator { + timeout := cfg.Timeout + if timeout <= 0 { + timeout = defaultTimeout + } + client := cfg.Client + if client == nil { + client = &http.Client{Timeout: timeout} + } + return &Validator{client: client, allowInsecureForLocalhost: cfg.AllowInsecureForLocalhost} +} + +func (v *Validator) ValidateProviderIdentity(ctx context.Context, req app.ProviderIdentityValidationRequest) (app.ProviderIdentityValidationResult, error) { + if v == nil || v.client == nil { + return app.ProviderIdentityValidationResult{}, app.ErrValidation + } + if strings.TrimSpace(req.ProviderType) != "oidc" || strings.TrimSpace(req.Subject) == "" || strings.TrimSpace(req.AccessToken) == "" || len(strings.TrimSpace(req.AccessToken)) > 16*1024 { + return app.ProviderIdentityValidationResult{}, app.ErrValidation + } + issuer, err := validateURL(req.Issuer, v.allowInsecureForLocalhost) + if err != nil { + return failed("oidc_issuer_url"), app.ErrValidation + } + discoveryURL := issuer.JoinPath(".well-known", "openid-configuration").String() + var discovery discoveryDocument + if err := v.getJSON(ctx, discoveryURL, "", &discovery); err != nil { + return failed("oidc_discovery_fetch"), app.ErrVerificationFailed + } + userInfoURL, err := validateURL(discovery.UserInfoEndpoint, v.allowInsecureForLocalhost) + if err != nil { + return failed("oidc_userinfo_endpoint"), app.ErrVerificationFailed + } + var info userInfoDocument + if err := v.getJSON(ctx, userInfoURL.String(), req.AccessToken, &info); err != nil { + return failed("oidc_userinfo_fetch"), app.ErrVerificationFailed + } + subject, _ := info["sub"].(string) + if strings.TrimSpace(subject) == "" || subject != strings.TrimSpace(req.Subject) { + return app.ProviderIdentityValidationResult{Checks: []domain.VerifyCheck{ + {Name: "oidc_discovery_fetch", Result: "passed"}, + {Name: "oidc_userinfo_fetch", Result: "passed"}, + {Name: "oidc_userinfo_subject", Result: "failed"}, + }, Limitations: limitations()}, app.ErrVerificationFailed + } + groups := groupsFromUserInfo(info, req.GroupsClaim) + checks := []domain.VerifyCheck{ + {Name: "oidc_discovery_fetch", Result: "passed"}, + {Name: "oidc_userinfo_fetch", Result: "passed"}, + {Name: "oidc_userinfo_subject", Result: "passed"}, + } + if strings.TrimSpace(req.GroupsClaim) != "" { + if len(groups) == 0 { + checks = append(checks, domain.VerifyCheck{Name: "oidc_userinfo_groups", Result: "warning", Detail: "Configured groups claim was absent or empty in UserInfo."}) + } else { + checks = append(checks, domain.VerifyCheck{Name: "oidc_userinfo_groups", Result: "passed"}) + } + } + return app.ProviderIdentityValidationResult{Checks: checks, Groups: groups, Limitations: limitations()}, nil +} + +func (v *Validator) getJSON(ctx context.Context, endpoint, bearerToken string, target any) error { + httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return err + } + httpReq.Header.Set("Accept", "application/json") + if bearerToken != "" { + httpReq.Header.Set("Authorization", "Bearer "+bearerToken) + } + resp, err := v.client.Do(httpReq) + if err != nil { + return errors.New("provider request failed") + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096)) + return errors.New("provider request failed") + } + decoder := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)) + if err := decoder.Decode(target); err != nil { + return errors.New("provider response decode failed") + } + return nil +} + +func validateURL(value string, allowInsecureLocalhost bool) (*url.URL, error) { + parsed, err := url.Parse(strings.TrimSpace(value)) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return nil, errors.New("invalid provider url") + } + if parsed.Scheme == "https" { + return parsed, nil + } + if parsed.Scheme == "http" && allowInsecureLocalhost && localhostHost(parsed.Hostname()) { + return parsed, nil + } + return nil, errors.New("provider url must use https") +} + +func localhostHost(host string) bool { + if host == "localhost" { + return true + } + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() +} + +func groupsFromUserInfo(info userInfoDocument, claim string) []string { + claim = strings.TrimSpace(claim) + if claim == "" { + return nil + } + raw, ok := info[claim] + if !ok { + return nil + } + groups := []string{} + switch typed := raw.(type) { + case []any: + for _, item := range typed { + if value, ok := item.(string); ok && strings.TrimSpace(value) != "" { + groups = append(groups, strings.TrimSpace(value)) + } + } + case []string: + for _, item := range typed { + if strings.TrimSpace(item) != "" { + groups = append(groups, strings.TrimSpace(item)) + } + } + case string: + if strings.TrimSpace(typed) != "" { + groups = append(groups, strings.TrimSpace(typed)) + } + } + return groups +} + +func failed(name string) app.ProviderIdentityValidationResult { + return app.ProviderIdentityValidationResult{ + Checks: []domain.VerifyCheck{{Name: name, Result: "failed"}}, + Limitations: limitations(), + } +} + +func limitations() []string { + return []string{"Live OIDC UserInfo validation used a supplied bearer access token; Evydence does not store the token and does not synchronize provider groups permanently."} +} diff --git a/internal/adapters/identity/oidcuserinfo/oidcuserinfo_test.go b/internal/adapters/identity/oidcuserinfo/oidcuserinfo_test.go new file mode 100644 index 0000000..ea5b8c9 --- /dev/null +++ b/internal/adapters/identity/oidcuserinfo/oidcuserinfo_test.go @@ -0,0 +1,92 @@ +package oidcuserinfo + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/aatuh/evydence/internal/app" +) + +func TestValidateProviderIdentityFetchesUserInfoAndGroups(t *testing.T) { + var gotAuth string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/.well-known/openid-configuration": + _ = json.NewEncoder(w).Encode(map[string]any{"userinfo_endpoint": "http://" + r.Host + "/userinfo"}) + case "/userinfo": + gotAuth = r.Header.Get("Authorization") + _ = json.NewEncoder(w).Encode(map[string]any{"sub": "sub-1", "groups": []string{"security", "engineering"}}) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + validator := New(Config{AllowInsecureForLocalhost: true}) + result, err := validator.ValidateProviderIdentity(t.Context(), app.ProviderIdentityValidationRequest{ + ProviderType: "oidc", + Issuer: server.URL, + Subject: "sub-1", + GroupsClaim: "groups", + AccessToken: "secret-token", + }) + if err != nil { + t.Fatal(err) + } + if gotAuth != "Bearer secret-token" { + t.Fatalf("authorization header = %q", gotAuth) + } + if len(result.Groups) != 2 || result.Groups[0] != "security" { + t.Fatalf("groups = %#v", result.Groups) + } + if len(result.Checks) < 4 || result.Checks[2].Name != "oidc_userinfo_subject" || result.Checks[2].Result != "passed" { + t.Fatalf("checks = %#v", result.Checks) + } +} + +func TestValidateProviderIdentityRejectsSubjectMismatch(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/.well-known/openid-configuration": + _ = json.NewEncoder(w).Encode(map[string]any{"userinfo_endpoint": "http://" + r.Host + "/userinfo"}) + case "/userinfo": + _ = json.NewEncoder(w).Encode(map[string]any{"sub": "other"}) + } + })) + defer server.Close() + + validator := New(Config{AllowInsecureForLocalhost: true}) + result, err := validator.ValidateProviderIdentity(t.Context(), app.ProviderIdentityValidationRequest{ProviderType: "oidc", Issuer: server.URL, Subject: "sub-1", AccessToken: "secret-token"}) + if err == nil { + t.Fatal("expected verification failure") + } + if len(result.Checks) != 3 || result.Checks[2].Result != "failed" { + t.Fatalf("checks = %#v", result.Checks) + } +} + +func TestValidateProviderIdentityRequiresHTTPSExceptLocalhostOverride(t *testing.T) { + validator := New(Config{}) + if _, err := validator.ValidateProviderIdentity(t.Context(), app.ProviderIdentityValidationRequest{ProviderType: "oidc", Issuer: "http://example.com", Subject: "sub-1", AccessToken: "secret-token"}); err == nil { + t.Fatal("expected insecure remote issuer to be rejected") + } +} + +func TestValidateProviderIdentityHidesBearerTokenFromErrors(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "token secret-token rejected", http.StatusUnauthorized) + })) + defer server.Close() + + validator := New(Config{AllowInsecureForLocalhost: true}) + _, err := validator.ValidateProviderIdentity(t.Context(), app.ProviderIdentityValidationRequest{ProviderType: "oidc", Issuer: server.URL, Subject: "sub-1", AccessToken: "secret-token"}) + if err == nil { + t.Fatal("expected verification failure") + } + if strings.Contains(err.Error(), "secret-token") { + t.Fatalf("error leaked token: %v", err) + } +} diff --git a/internal/adapters/objectstore/s3/s3.go b/internal/adapters/objectstore/s3/s3.go index 23ca0ce..fe6b62a 100644 --- a/internal/adapters/objectstore/s3/s3.go +++ b/internal/adapters/objectstore/s3/s3.go @@ -6,11 +6,13 @@ import ( "fmt" "io" "strings" + "time" "github.com/minio/minio-go/v7" "github.com/minio/minio-go/v7/pkg/credentials" "github.com/aatuh/evydence/internal/app" + "github.com/aatuh/evydence/internal/domain" ) type Config struct { @@ -99,6 +101,45 @@ func (s *Store) Get(ctx context.Context, key string) (app.Object, error) { }, nil } +func (s *Store) VerifyObjectRetention(ctx context.Context, req app.ObjectRetentionRequest) (app.ObjectRetentionResult, error) { + if s == nil || s.client == nil { + return app.ObjectRetentionResult{}, app.ErrValidation + } + tenantPrefix := "tenants/" + strings.TrimSpace(req.TenantID) + "/" + objectPrefix := strings.TrimSpace(req.ObjectPrefix) + objectKey := strings.TrimSpace(req.ObjectKey) + if strings.TrimSpace(req.TenantID) == "" || !strings.HasPrefix(objectPrefix, tenantPrefix) { + return app.ObjectRetentionResult{}, app.ErrValidation + } + if objectKey != "" && (!strings.HasPrefix(objectKey, tenantPrefix) || !strings.HasPrefix(objectKey, objectPrefix)) { + return app.ObjectRetentionResult{}, app.ErrValidation + } + versioning, err := s.client.GetBucketVersioning(ctx, s.bucket) + if err != nil { + return app.ObjectRetentionResult{}, fmt.Errorf("check s3 bucket versioning: %w", err) + } + mode, validity, unit, err := s.client.GetBucketObjectLockConfig(ctx, s.bucket) + if err != nil && !objectLockConfigMissing(err) { + return app.ObjectRetentionResult{}, fmt.Errorf("check s3 object lock: %w", err) + } + var objectMode *minio.RetentionMode + var retainUntil *time.Time + var legalHold *minio.LegalHoldStatus + if objectKey != "" { + objectMode, retainUntil, err = s.client.GetObjectRetention(ctx, s.bucket, objectKey, "") + if err != nil && !objectLockConfigMissing(err) { + return app.ObjectRetentionResult{}, fmt.Errorf("check s3 object retention: %w", err) + } + if req.RequireLegalHold { + legalHold, err = s.client.GetObjectLegalHold(ctx, s.bucket, objectKey, minio.GetObjectLegalHoldOptions{}) + if err != nil && !objectLockConfigMissing(err) { + return app.ObjectRetentionResult{}, fmt.Errorf("check s3 object legal hold: %w", err) + } + } + } + return evaluateObjectRetention(req, versioning.Enabled(), mode, validity, unit, objectMode, retainUntil, legalHold, time.Now().UTC()), nil +} + func metadataValue(metadata map[string]string, keys ...string) string { for _, key := range keys { if value := metadata[key]; value != "" { @@ -107,3 +148,100 @@ func metadataValue(metadata map[string]string, keys ...string) string { } return "" } + +func objectLockConfigMissing(err error) bool { + resp := minio.ToErrorResponse(err) + return resp.Code == "NoSuchObjectLockConfiguration" || resp.Code == "ObjectLockConfigurationNotFoundError" +} + +func evaluateObjectRetention(req app.ObjectRetentionRequest, versioningEnabled bool, mode *minio.RetentionMode, validity *uint, unit *minio.ValidityUnit, objectMode *minio.RetentionMode, retainUntil *time.Time, legalHold *minio.LegalHoldStatus, now time.Time) app.ObjectRetentionResult { + expectedPrefix := "tenants/" + strings.TrimSpace(req.TenantID) + "/" + prefixOK := strings.HasPrefix(strings.TrimSpace(req.ObjectPrefix), expectedPrefix) + objectKey := strings.TrimSpace(req.ObjectKey) + objectKeyOK := objectKey == "" || (strings.HasPrefix(objectKey, expectedPrefix) && strings.HasPrefix(objectKey, strings.TrimSpace(req.ObjectPrefix))) + expectedMode := retentionMode(req.Mode) + actualMode := "" + if mode != nil { + actualMode = strings.ToUpper(mode.String()) + } + actualObjectMode := "" + if objectMode != nil { + actualObjectMode = strings.ToUpper(objectMode.String()) + } + retentionDays := retentionDays(validity, unit) + modeOK := expectedMode != "" && actualMode == expectedMode + retentionOK := req.RetentionDays > 0 && retentionDays >= uint(req.RetentionDays) + objectModeOK := objectKey == "" || (expectedMode != "" && actualObjectMode == expectedMode) + objectRetainUntilOK := objectKey == "" || (retainUntil != nil && retainUntil.UTC().After(now.Add(time.Duration(req.RetentionDays)*24*time.Hour-time.Second))) + legalHoldOK := !req.RequireLegalHold || (objectKey != "" && legalHold != nil && *legalHold == minio.LegalHoldEnabled) + checks := []domain.VerifyCheck{ + {Name: "s3_bucket_versioning", Result: checkResult(versioningEnabled), Detail: "Bucket versioning must be enabled for object-lock retention."}, + {Name: "s3_object_lock_mode", Result: checkResult(modeOK), Detail: "Bucket default object-lock mode must match the policy mode."}, + {Name: "s3_object_lock_retention", Result: checkResult(retentionOK), Detail: "Bucket default object-lock retention must meet or exceed the policy duration."}, + {Name: "tenant_object_prefix", Result: checkResult(prefixOK), Detail: "Object prefix must stay under the tenant namespace."}, + } + if objectKey != "" { + checks = append(checks, + domain.VerifyCheck{Name: "tenant_object_key", Result: checkResult(objectKeyOK), Detail: "Sample object key must stay under the tenant namespace and configured prefix."}, + domain.VerifyCheck{Name: "s3_object_retention_mode", Result: checkResult(objectModeOK), Detail: "Sample object retention mode must match the policy mode."}, + domain.VerifyCheck{Name: "s3_object_retention_until", Result: checkResult(objectRetainUntilOK), Detail: "Sample object retain-until timestamp must meet or exceed the policy duration."}, + ) + } + if req.RequireLegalHold { + checks = append(checks, domain.VerifyCheck{Name: "s3_object_legal_hold", Result: checkResult(legalHoldOK), Detail: "Sample object legal hold must be enabled when the policy requires legal hold proof."}) + } + enforced := versioningEnabled && modeOK && retentionOK && prefixOK && objectKeyOK && objectModeOK && objectRetainUntilOK && legalHoldOK + limitations := []string{ + "S3/MinIO checks validate bucket-level versioning and default object-lock settings.", + "Operators remain responsible for bucket creation mode, IAM policy, lifecycle rules, backups, and deployment-specific retention review.", + } + if objectKey == "" { + limitations = append(limitations, "No sample object key was supplied, so object-level retention was not verified.") + } else { + limitations = append(limitations, "Object-level retention was checked for the configured sample object key only.") + } + if req.RequireLegalHold { + limitations = append(limitations, "Object-level legal hold was checked for the configured sample object key only.") + } + return app.ObjectRetentionResult{ + Provider: "s3", + Enforced: enforced, + Checks: checks, + Limitations: limitations, + } +} + +func retentionMode(mode string) string { + switch strings.ToLower(strings.TrimSpace(mode)) { + case "governance": + return minio.Governance.String() + case "compliance": + return minio.Compliance.String() + default: + return "" + } +} + +func retentionDays(validity *uint, unit *minio.ValidityUnit) uint { + if validity == nil || unit == nil { + return 0 + } + switch *unit { + case minio.Days: + return *validity + case minio.Years: + if *validity > ^uint(0)/365 { + return ^uint(0) + } + return *validity * 365 + default: + return 0 + } +} + +func checkResult(ok bool) string { + if ok { + return "passed" + } + return "failed" +} diff --git a/internal/adapters/objectstore/s3/s3_test.go b/internal/adapters/objectstore/s3/s3_test.go index 001aa15..0819d20 100644 --- a/internal/adapters/objectstore/s3/s3_test.go +++ b/internal/adapters/objectstore/s3/s3_test.go @@ -4,6 +4,9 @@ import ( "context" "errors" "testing" + "time" + + "github.com/minio/minio-go/v7" "github.com/aatuh/evydence/internal/app" ) @@ -14,3 +17,148 @@ func TestNewRejectsIncompleteConfigWithoutNetwork(t *testing.T) { t.Fatalf("err = %v, want validation", err) } } + +func TestPutGetRejectUninitializedStoreAndUnsafeKeys(t *testing.T) { + if err := (*Store)(nil).Put(context.Background(), app.Object{Key: "tenants/ten_1/raw", TenantID: "ten_1"}); !errors.Is(err, app.ErrValidation) { + t.Fatalf("nil put err = %v, want validation", err) + } + if _, err := (*Store)(nil).Get(context.Background(), "tenants/ten_1/raw"); !errors.Is(err, app.ErrValidation) { + t.Fatalf("nil get err = %v, want validation", err) + } + + store := &Store{} + err := store.Put(context.Background(), app.Object{Key: "tenants/other/raw", TenantID: "ten_1"}) + if !errors.Is(err, app.ErrValidation) { + t.Fatalf("cross-tenant key err = %v, want validation", err) + } + if _, err := store.Get(context.Background(), ""); !errors.Is(err, app.ErrValidation) { + t.Fatalf("empty key err = %v, want validation", err) + } +} + +func TestMetadataValueUsesFirstNonEmptyKey(t *testing.T) { + metadata := map[string]string{ + "X-Amz-Meta-Evydence-Tenant-Id": "", + "evydence-tenant-id": "ten_1", + "evydence-digest": "sha256:abc", + } + if got := metadataValue(metadata, "X-Amz-Meta-Evydence-Tenant-Id", "evydence-tenant-id"); got != "ten_1" { + t.Fatalf("tenant metadata = %q", got) + } + if got := metadataValue(metadata, "missing", "evydence-digest"); got != "sha256:abc" { + t.Fatalf("digest metadata = %q", got) + } + if got := metadataValue(metadata, "missing"); got != "" { + t.Fatalf("missing metadata = %q", got) + } +} + +func TestEvaluateObjectRetentionRequiresVersioningLockAndTenantPrefix(t *testing.T) { + mode := minio.Compliance + validity := uint(90) + unit := minio.Days + result := evaluateObjectRetention(app.ObjectRetentionRequest{ + TenantID: "ten_1", + ObjectPrefix: "tenants/ten_1/raw/", + Mode: "compliance", + RetentionDays: 30, + }, true, &mode, &validity, &unit, nil, nil, nil, time.Date(2026, 5, 29, 0, 0, 0, 0, time.UTC)) + if !result.Enforced { + t.Fatalf("expected enforced retention: %#v", result) + } + if len(result.Checks) != 4 || result.Checks[0].Result != "passed" { + t.Fatalf("checks = %#v", result.Checks) + } + if len(result.Limitations) == 0 { + t.Fatal("expected limitations") + } +} + +func TestEvaluateObjectRetentionReportsMissingProviderControls(t *testing.T) { + mode := minio.Governance + validity := uint(1) + unit := minio.Days + result := evaluateObjectRetention(app.ObjectRetentionRequest{ + TenantID: "ten_1", + ObjectPrefix: "tenants/other/raw/", + Mode: "compliance", + RetentionDays: 30, + }, false, &mode, &validity, &unit, nil, nil, nil, time.Date(2026, 5, 29, 0, 0, 0, 0, time.UTC)) + if result.Enforced { + t.Fatalf("unexpected enforced retention: %#v", result) + } + failed := 0 + for _, check := range result.Checks { + if check.Result == "failed" { + failed++ + } + } + if failed != 4 { + t.Fatalf("failed checks = %d, checks = %#v", failed, result.Checks) + } +} + +func TestEvaluateObjectRetentionChecksSampleObjectRetention(t *testing.T) { + mode := minio.Compliance + validity := uint(90) + unit := minio.Days + now := time.Date(2026, 5, 29, 0, 0, 0, 0, time.UTC) + retainUntil := now.Add(45 * 24 * time.Hour) + result := evaluateObjectRetention(app.ObjectRetentionRequest{ + TenantID: "ten_1", + ObjectPrefix: "tenants/ten_1/raw/", + ObjectKey: "tenants/ten_1/raw/sample.json", + Mode: "compliance", + RetentionDays: 30, + }, true, &mode, &validity, &unit, &mode, &retainUntil, nil, now) + if !result.Enforced { + t.Fatalf("expected object-level enforced retention: %#v", result) + } + if len(result.Checks) != 7 { + t.Fatalf("checks = %#v", result.Checks) + } +} + +func TestEvaluateObjectRetentionChecksRequiredLegalHold(t *testing.T) { + mode := minio.Compliance + validity := uint(90) + unit := minio.Days + now := time.Date(2026, 5, 29, 0, 0, 0, 0, time.UTC) + retainUntil := now.Add(45 * 24 * time.Hour) + legalHold := minio.LegalHoldEnabled + result := evaluateObjectRetention(app.ObjectRetentionRequest{ + TenantID: "ten_1", + ObjectPrefix: "tenants/ten_1/raw/", + ObjectKey: "tenants/ten_1/raw/sample.json", + Mode: "compliance", + RetentionDays: 30, + RequireLegalHold: true, + }, true, &mode, &validity, &unit, &mode, &retainUntil, &legalHold, now) + if !result.Enforced { + t.Fatalf("expected legal-hold enforced retention: %#v", result) + } + if got := result.Checks[len(result.Checks)-1]; got.Name != "s3_object_legal_hold" || got.Result != "passed" { + t.Fatalf("legal hold check = %#v", got) + } + + legalHold = minio.LegalHoldDisabled + result = evaluateObjectRetention(app.ObjectRetentionRequest{ + TenantID: "ten_1", + ObjectPrefix: "tenants/ten_1/raw/", + ObjectKey: "tenants/ten_1/raw/sample.json", + Mode: "compliance", + RetentionDays: 30, + RequireLegalHold: true, + }, true, &mode, &validity, &unit, &mode, &retainUntil, &legalHold, now) + if result.Enforced { + t.Fatalf("disabled legal hold should not be enforced: %#v", result) + } +} + +func TestRetentionDaysConvertsYears(t *testing.T) { + validity := uint(2) + unit := minio.Years + if got := retentionDays(&validity, &unit); got != uint(730) { + t.Fatalf("retention days = %d", got) + } +} diff --git a/internal/adapters/postgres/migrate.go b/internal/adapters/postgres/migrate.go index c4af6f6..5f636fc 100644 --- a/internal/adapters/postgres/migrate.go +++ b/internal/adapters/postgres/migrate.go @@ -12,25 +12,12 @@ import ( ) func (s *Store) ApplyMigrations(ctx context.Context, dir string) (int, error) { - entries, err := os.ReadDir(dir) + names, err := migrationNames(dir) if err != nil { - return 0, fmt.Errorf("read migrations: %w", err) - } - names := []string{} - for _, entry := range entries { - if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".up.sql") { - continue - } - names = append(names, entry.Name()) + return 0, err } - sort.Strings(names) - if _, err := s.pool.Exec(ctx, ` - CREATE TABLE IF NOT EXISTS schema_migrations ( - version text PRIMARY KEY, - applied_at timestamptz NOT NULL DEFAULT now() - ) - `); err != nil { - return 0, fmt.Errorf("ensure schema migrations table: %w", err) + if err := s.ensureSchemaMigrationsTable(ctx); err != nil { + return 0, err } applied := 0 for _, name := range names { @@ -55,7 +42,7 @@ func (s *Store) ApplyMigrations(ctx context.Context, dir string) (int, error) { _ = tx.Rollback(ctx) return applied, fmt.Errorf("apply migration %s: %w", version, err) } - if _, err := tx.Exec(ctx, `INSERT INTO schema_migrations (version) VALUES ($1)`, version); err != nil { + if _, err := tx.Exec(ctx, `INSERT INTO schema_migrations (version) VALUES ($1) ON CONFLICT (version) DO NOTHING`, version); err != nil { _ = tx.Rollback(ctx) return applied, fmt.Errorf("record migration %s: %w", version, err) } @@ -66,3 +53,65 @@ func (s *Store) ApplyMigrations(ctx context.Context, dir string) (int, error) { } return applied, nil } + +func (s *Store) PendingMigrationVersions(ctx context.Context, dir string) ([]string, error) { + names, err := migrationNames(dir) + if err != nil { + return nil, err + } + if err := s.ensureSchemaMigrationsTable(ctx); err != nil { + return nil, err + } + pending := []string{} + for _, name := range names { + version := strings.TrimSuffix(name, ".up.sql") + var exists bool + err := s.pool.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM schema_migrations WHERE version = $1)`, version).Scan(&exists) + if err != nil { + return nil, fmt.Errorf("check migration %s: %w", version, err) + } + if !exists { + pending = append(pending, version) + } + } + return pending, nil +} + +func (s *Store) RequireNoPendingMigrations(ctx context.Context, dir string) error { + pending, err := s.PendingMigrationVersions(ctx, dir) + if err != nil { + return err + } + if len(pending) > 0 { + return fmt.Errorf("database has %d unapplied migration(s); first pending migration is %s", len(pending), pending[0]) + } + return nil +} + +func (s *Store) ensureSchemaMigrationsTable(ctx context.Context) error { + if _, err := s.pool.Exec(ctx, ` + CREATE TABLE IF NOT EXISTS schema_migrations ( + version text PRIMARY KEY, + applied_at timestamptz NOT NULL DEFAULT now() + ) + `); err != nil { + return fmt.Errorf("ensure schema migrations table: %w", err) + } + return nil +} + +func migrationNames(dir string) ([]string, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return nil, fmt.Errorf("read migrations: %w", err) + } + names := []string{} + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".up.sql") { + continue + } + names = append(names, entry.Name()) + } + sort.Strings(names) + return names, nil +} diff --git a/internal/adapters/postgres/migration_compatibility_test.go b/internal/adapters/postgres/migration_compatibility_test.go new file mode 100644 index 0000000..a3f7051 --- /dev/null +++ b/internal/adapters/postgres/migration_compatibility_test.go @@ -0,0 +1,151 @@ +package postgres + +import ( + "context" + "fmt" + "net/url" + "os" + "path/filepath" + "sort" + "strings" + "testing" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +func TestMigrationCompatibilityFromEveryCommittedState(t *testing.T) { + databaseURL := os.Getenv("EVYDENCE_TEST_DATABASE_URL") + if strings.TrimSpace(databaseURL) == "" { + t.Skip("EVYDENCE_TEST_DATABASE_URL is not set") + } + names := migrationFileNames(t, "../../../migrations") + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + basePool, err := pgxpool.New(ctx, databaseURL) + if err != nil { + t.Fatal(err) + } + defer basePool.Close() + + for prefix := 0; prefix <= len(names); prefix++ { + prefix := prefix + t.Run(fmt.Sprintf("prefix_%02d", prefix), func(t *testing.T) { + schema := fmt.Sprintf("evydence_migration_%d_%02d", time.Now().UnixNano(), prefix) + quotedSchema := pgx.Identifier{schema}.Sanitize() + if _, err := basePool.Exec(ctx, "CREATE SCHEMA "+quotedSchema); err != nil { + t.Fatal(err) + } + defer func(cleanupCtx context.Context) { + _, _ = basePool.Exec(cleanupCtx, "DROP SCHEMA "+quotedSchema+" CASCADE") + }(context.WithoutCancel(ctx)) + + store, err := Open(ctx, databaseURLWithSearchPath(t, databaseURL, schema)) + if err != nil { + t.Fatal(err) + } + defer store.Close() + + applyMigrationPrefix(t, ctx, store, "../../../migrations", names[:prefix]) + applied, err := store.ApplyMigrations(ctx, "../../../migrations") + if err != nil { + t.Fatalf("upgrade from prefix %d: %v", prefix, err) + } + if want := len(names) - prefix; applied != want { + t.Fatalf("applied migrations = %d, want %d", applied, want) + } + var count int + if err := store.pool.QueryRow(ctx, `SELECT count(*) FROM schema_migrations`).Scan(&count); err != nil { + t.Fatal(err) + } + if count != len(names) { + t.Fatalf("schema_migrations count = %d, want %d", count, len(names)) + } + again, err := store.ApplyMigrations(ctx, "../../../migrations") + if err != nil { + t.Fatalf("idempotent apply from prefix %d: %v", prefix, err) + } + if again != 0 { + t.Fatalf("second apply = %d, want 0", again) + } + for _, table := range []string{"ledger_state", "resource_index", "outbox_jobs", "schema_migrations"} { + var exists bool + err := store.pool.QueryRow(ctx, `SELECT to_regclass($1) IS NOT NULL`, table).Scan(&exists) + if err != nil { + t.Fatal(err) + } + if !exists { + t.Fatalf("table %s is missing after migration upgrade", table) + } + } + }) + } +} + +func migrationFileNames(t *testing.T, dir string) []string { + t.Helper() + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + names := []string{} + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".up.sql") { + continue + } + names = append(names, entry.Name()) + } + sort.Strings(names) + if len(names) == 0 { + t.Fatal("no migration files found") + } + return names +} + +func applyMigrationPrefix(t *testing.T, ctx context.Context, store *Store, dir string, names []string) { + t.Helper() + if _, err := store.pool.Exec(ctx, ` + CREATE TABLE IF NOT EXISTS schema_migrations ( + version text PRIMARY KEY, + applied_at timestamptz NOT NULL DEFAULT now() + ) + `); err != nil { + t.Fatal(err) + } + for _, name := range names { + version := strings.TrimSuffix(name, ".up.sql") + body, err := os.ReadFile(filepath.Join(dir, name)) // #nosec G304 -- migration names come from ReadDir and are filtered to .up.sql files. + if err != nil { + t.Fatal(err) + } + tx, err := store.pool.BeginTx(ctx, pgx.TxOptions{}) + if err != nil { + t.Fatal(err) + } + if _, err := tx.Exec(ctx, string(body)); err != nil { + _ = tx.Rollback(ctx) + t.Fatalf("apply prefix migration %s: %v", version, err) + } + if _, err := tx.Exec(ctx, `INSERT INTO schema_migrations (version) VALUES ($1)`, version); err != nil { + _ = tx.Rollback(ctx) + t.Fatalf("record prefix migration %s: %v", version, err) + } + if err := tx.Commit(ctx); err != nil { + t.Fatal(err) + } + } +} + +func databaseURLWithSearchPath(t *testing.T, rawURL, schema string) string { + t.Helper() + parsed, err := url.Parse(rawURL) + if err != nil { + t.Fatal(err) + } + query := parsed.Query() + query.Set("search_path", schema) + parsed.RawQuery = query.Encode() + return parsed.String() +} diff --git a/internal/adapters/postgres/outbox.go b/internal/adapters/postgres/outbox.go new file mode 100644 index 0000000..5a3478e --- /dev/null +++ b/internal/adapters/postgres/outbox.go @@ -0,0 +1,148 @@ +package postgres + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + + "github.com/aatuh/evydence/internal/app" +) + +type ClaimedJob struct { + ID string + TenantID string + Kind string + SubjectType string + SubjectID string + Attempts int + Payload map[string]any +} + +func (s *Store) Enqueue(ctx context.Context, job app.OutboxJob) error { + return insertOutboxJob(ctx, s.pool, job) +} + +type outboxExecutor interface { + Exec(context.Context, string, ...any) (pgconn.CommandTag, error) +} + +func insertOutboxJob(ctx context.Context, exec outboxExecutor, job app.OutboxJob) error { + payload, err := json.Marshal(job.Payload) + if err != nil { + return fmt.Errorf("encode outbox payload: %w", err) + } + _, err = exec.Exec(ctx, ` + INSERT INTO outbox_jobs ( + id, tenant_id, kind, subject_type, subject_id, payload, status, + attempts, max_attempts, run_after, created_at, updated_at + ) + VALUES ($1, $2, $3, $4, $5, $6, 'queued', 0, 5, now(), $7, now()) + ON CONFLICT (id) DO NOTHING + `, job.ID, job.TenantID, job.Kind, job.SubjectType, job.SubjectID, payload, job.CreatedAt) + if err != nil { + return fmt.Errorf("enqueue outbox job: %w", err) + } + return nil +} + +func insertOutboxJobTx(ctx context.Context, tx pgx.Tx, job app.OutboxJob) error { + return insertOutboxJob(ctx, tx, job) +} + +func (s *Store) ClaimJobs(ctx context.Context, limit int) ([]ClaimedJob, error) { + if limit <= 0 { + limit = 10 + } + tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{}) + if err != nil { + return nil, fmt.Errorf("begin claim jobs transaction: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + rows, err := tx.Query(ctx, ` + WITH claimed AS ( + SELECT id + FROM outbox_jobs + WHERE status IN ('queued', 'retrying') + AND run_after <= now() + ORDER BY run_after, created_at + LIMIT $1 + FOR UPDATE SKIP LOCKED + ) + UPDATE outbox_jobs j + SET status = 'running', + attempts = attempts + 1, + locked_at = now(), + updated_at = now() + FROM claimed + WHERE j.id = claimed.id + RETURNING j.id, j.tenant_id, j.kind, j.subject_type, j.subject_id, j.attempts, j.payload + `, limit) + if err != nil { + return nil, fmt.Errorf("claim jobs: %w", err) + } + defer rows.Close() + jobs := []ClaimedJob{} + for rows.Next() { + var job ClaimedJob + var payload []byte + if err := rows.Scan(&job.ID, &job.TenantID, &job.Kind, &job.SubjectType, &job.SubjectID, &job.Attempts, &payload); err != nil { + return nil, fmt.Errorf("scan claimed job: %w", err) + } + if len(payload) > 0 { + if err := json.Unmarshal(payload, &job.Payload); err != nil { + return nil, fmt.Errorf("decode claimed job payload: %w", err) + } + } + jobs = append(jobs, job) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("read claimed jobs: %w", err) + } + if err := tx.Commit(ctx); err != nil { + return nil, fmt.Errorf("commit claim jobs transaction: %w", err) + } + return jobs, nil +} + +func (s *Store) CompleteJob(ctx context.Context, id string) error { + _, err := s.pool.Exec(ctx, ` + UPDATE outbox_jobs + SET status = 'succeeded', locked_at = NULL, last_error = NULL, updated_at = now() + WHERE id = $1 + `, id) + if err != nil { + return fmt.Errorf("complete outbox job: %w", err) + } + return nil +} + +func (s *Store) FailJob(ctx context.Context, id string, cause error) error { + message := "job failed" + if cause != nil { + message = cause.Error() + } + _, err := s.pool.Exec(ctx, ` + UPDATE outbox_jobs + SET status = CASE WHEN attempts >= max_attempts THEN 'failed' ELSE 'retrying' END, + run_after = now() + make_interval(secs => LEAST(300, POWER(2, attempts)::int)), + locked_at = NULL, + last_error = $2, + updated_at = now() + WHERE id = $1 + `, id, message) + if err != nil { + return fmt.Errorf("fail outbox job: %w", err) + } + return nil +} + +func (s *Store) CountPendingJobs(ctx context.Context) (int, error) { + var count int + if err := s.pool.QueryRow(ctx, `SELECT count(*) FROM outbox_jobs WHERE status IN ('queued', 'retrying', 'running')`).Scan(&count); err != nil { + return 0, fmt.Errorf("count outbox jobs: %w", err) + } + return count, nil +} diff --git a/internal/adapters/postgres/projections_test.go b/internal/adapters/postgres/projections_test.go new file mode 100644 index 0000000..f4528bc --- /dev/null +++ b/internal/adapters/postgres/projections_test.go @@ -0,0 +1,127 @@ +package postgres + +import ( + "testing" + "time" + + "github.com/aatuh/evydence/internal/app" + "github.com/aatuh/evydence/internal/domain" +) + +func TestResourceProjectionsCoverTenantScopedImplementedResources(t *testing.T) { + now := time.Date(2026, 5, 28, 12, 0, 0, 0, time.UTC) + state := app.PersistedState{ + Tenants: map[string]domain.Tenant{"ten_1": {ID: "ten_1", CreatedAt: now}}, + Organizations: map[string]domain.Organization{"org_1": {ID: "org_1", TenantID: "ten_1", CreatedAt: now}}, + Users: map[string]domain.HumanUser{"usr_1": {ID: "usr_1", TenantID: "ten_1", CreatedAt: now}}, + RoleBindings: map[string]domain.RoleBinding{"rbac_1": {ID: "rbac_1", TenantID: "ten_1", CreatedAt: now}}, + SSOProviders: map[string]domain.SSOProvider{"sso_1": {ID: "sso_1", TenantID: "ten_1", CreatedAt: now}}, + IdentityLinks: map[string]domain.UserIdentityLink{"link_1": {ID: "link_1", TenantID: "ten_1", CreatedAt: now}}, + SSOSessions: map[string]domain.SSOSession{"sess_1": {ID: "sess_1", TenantID: "ten_1", CreatedAt: now}}, + Products: map[string]domain.Product{"prod_1": {ID: "prod_1", TenantID: "ten_1", CreatedAt: now}}, + Projects: map[string]domain.Project{"proj_1": {ID: "proj_1", TenantID: "ten_1", ProductID: "prod_1", CreatedAt: now}}, + Releases: map[string]domain.Release{"rel_1": {ID: "rel_1", TenantID: "ten_1", ProductID: "prod_1", CreatedAt: now}}, + Artifacts: map[string]domain.Artifact{"art_1": {ID: "art_1", TenantID: "ten_1", CreatedAt: now}}, + BuildRuns: map[string]domain.BuildRun{"build_1": {ID: "build_1", TenantID: "ten_1", ProjectID: "proj_1", ReleaseID: "rel_1", CreatedAt: now}}, + BuildAttestations: map[string]domain.BuildAttestation{"att_1": {ID: "att_1", TenantID: "ten_1", CreatedAt: now}}, + Evidence: map[string]domain.EvidenceItem{"ev_1": {ID: "ev_1", TenantID: "ten_1", ProductID: "prod_1", ProjectID: "proj_1", ReleaseID: "rel_1", CreatedAt: now}}, + ReleaseCandidates: map[string]domain.ReleaseCandidate{"rc_1": {ID: "rc_1", TenantID: "ten_1", ReleaseID: "rel_1", CreatedAt: now}}, + ContainerImages: map[string]domain.ContainerImage{"img_1": {ID: "img_1", TenantID: "ten_1", CreatedAt: now}}, + ArtifactSignatures: map[string]domain.ArtifactSignature{"asig_1": {ID: "asig_1", TenantID: "ten_1", CreatedAt: now}}, + Repositories: map[string]domain.SourceRepository{"repo_1": {ID: "repo_1", TenantID: "ten_1", ProjectID: "proj_1", CreatedAt: now}}, + Commits: map[string]domain.SourceCommit{"commit_1": {ID: "commit_1", TenantID: "ten_1", CreatedAt: now}}, + Branches: map[string]domain.SourceBranch{"branch_1": {ID: "branch_1", TenantID: "ten_1", CreatedAt: now}}, + PullRequests: map[string]domain.PullRequest{"pr_1": {ID: "pr_1", TenantID: "ten_1", CreatedAt: now}}, + Environments: map[string]domain.DeploymentEnvironment{"env_1": {ID: "env_1", TenantID: "ten_1", ProductID: "prod_1", CreatedAt: now}}, + Deployments: map[string]domain.DeploymentEvent{"dep_1": {ID: "dep_1", TenantID: "ten_1", ReleaseID: "rel_1", CreatedAt: now}}, + Incidents: map[string]domain.Incident{"inc_1": {ID: "inc_1", TenantID: "ten_1", ProductID: "prod_1", ReleaseID: "rel_1", CreatedAt: now}}, + TimelineEvents: map[string]domain.IncidentTimelineEvent{"tl_1": {ID: "tl_1", TenantID: "ten_1", CreatedAt: now}}, + RemediationTasks: map[string]domain.RemediationTask{"task_1": {ID: "task_1", TenantID: "ten_1", ReleaseID: "rel_1", CreatedAt: now}}, + SecurityScans: map[string]domain.SecurityScan{"secscan_1": {ID: "secscan_1", TenantID: "ten_1", ProductID: "prod_1", ReleaseID: "rel_1", CreatedAt: now}}, + ManualSecurityDocs: map[string]domain.ManualSecurityDocument{"doc_1": {ID: "doc_1", TenantID: "ten_1", ProductID: "prod_1", ReleaseID: "rel_1", CreatedAt: now}}, + SBOMDiffs: map[string]domain.SBOMDiff{"diff_1": {ID: "diff_1", TenantID: "ten_1", ReleaseID: "rel_1", CreatedAt: now}}, + DependencyChanges: map[string]domain.DependencyChange{"depchg_1": {ID: "depchg_1", TenantID: "ten_1", CreatedAt: now}}, + VulnerabilityWorkflow: map[string]domain.VulnerabilityWorkflowRecord{"vw_1": {ID: "vw_1", TenantID: "ten_1", ReleaseID: "rel_1", CreatedAt: now}}, + ContractDiffs: map[string]domain.ContractDiff{"cdiff_1": {ID: "cdiff_1", TenantID: "ten_1", ProductID: "prod_1", ReleaseID: "rel_1", CreatedAt: now}}, + CustomPolicies: map[string]domain.CustomPolicy{"pol_1": {ID: "pol_1", TenantID: "ten_1", CreatedAt: now}}, + CustomPolicyEvaluations: map[string]domain.CustomPolicyEvaluation{"peval_1": {ID: "peval_1", TenantID: "ten_1", ReleaseID: "rel_1", CreatedAt: now}}, + Waivers: map[string]domain.Waiver{"waiver_1": {ID: "waiver_1", TenantID: "ten_1", CreatedAt: now}}, + Approvals: map[string]domain.ApprovalRecord{"app_1": {ID: "app_1", TenantID: "ten_1", CreatedAt: now}}, + RedactionProfiles: map[string]domain.RedactionProfile{"red_1": {ID: "red_1", TenantID: "ten_1", CreatedAt: now}}, + CustomerPackages: map[string]domain.CustomerSecurityPackage{"pkg_1": {ID: "pkg_1", TenantID: "ten_1", ProductID: "prod_1", ReleaseID: "rel_1", CreatedAt: now}}, + HTMLReports: map[string]domain.HTMLReportPackage{"html_1": {ID: "html_1", TenantID: "ten_1", ProductID: "prod_1", ReleaseID: "rel_1", CreatedAt: now}}, + ReportTemplates: map[string]domain.CustomReportTemplate{"tmpl_1": {ID: "tmpl_1", TenantID: "ten_1", CreatedAt: now}}, + RenderedReports: map[string]domain.RenderedCustomReport{"rend_1": {ID: "rend_1", TenantID: "ten_1", CreatedAt: now}}, + EvidenceBundles: map[string]domain.EvidenceBundle{"eb_1": {ID: "eb_1", TenantID: "ten_1", ReleaseID: "rel_1", CreatedAt: now}}, + BundleImports: map[string]domain.EvidenceBundleImport{"ebi_1": {ID: "ebi_1", TenantID: "ten_1", CreatedAt: now}}, + DSSETrustRoots: map[string]domain.DSSETrustRoot{"trust_1": {ID: "trust_1", TenantID: "ten_1", CreatedAt: now}}, + CollectorReleases: map[string]domain.CollectorRelease{"cr_1": {ID: "cr_1", TenantID: "ten_1", CreatedAt: now}}, + CosignVerifications: map[string]domain.CosignVerification{"cosign_1": {ID: "cosign_1", TenantID: "ten_1", CreatedAt: now}}, + SigningProviders: map[string]domain.SigningProvider{"sp_1": {ID: "sp_1", TenantID: "ten_1", CreatedAt: now}}, + MerkleBatches: map[string]domain.MerkleBatch{"mb_1": {ID: "mb_1", TenantID: "ten_1", CreatedAt: now}}, + TransparencyCheckpoints: map[string]domain.TransparencyCheckpoint{"tc_1": {ID: "tc_1", TenantID: "ten_1", CreatedAt: now}}, + ObjectRetentionPolicies: map[string]domain.ObjectRetentionPolicy{"orp_1": {ID: "orp_1", TenantID: "ten_1", CreatedAt: now}}, + BackupManifests: map[string]domain.BackupManifest{"bak_1": {ID: "bak_1", TenantID: "ten_1", CreatedAt: now}}, + LegalHolds: map[string]domain.LegalHold{"hold_1": {ID: "hold_1", TenantID: "ten_1", CreatedAt: now}}, + RetentionOverrides: map[string]domain.RetentionOverride{"ret_1": {ID: "ret_1", TenantID: "ten_1", CreatedAt: now}}, + CustomerPortalAccess: map[string]domain.CustomerPortalAccess{"cpa_1": {ID: "cpa_1", TenantID: "ten_1", CreatedAt: now}}, + QuestionnaireTemplates: map[string]domain.QuestionnaireTemplate{"qt_1": {ID: "qt_1", TenantID: "ten_1", CreatedAt: now}}, + QuestionnairePackages: map[string]domain.QuestionnairePackage{"qp_1": {ID: "qp_1", TenantID: "ten_1", ProductID: "prod_1", ReleaseID: "rel_1", CreatedAt: now}}, + CommercialCollectors: map[string]domain.CommercialCollectorDefinition{"cc_1": {ID: "cc_1", TenantID: "ten_1", CreatedAt: now}}, + EvidenceSummaries: map[string]domain.EvidenceSummary{"sum_1": {ID: "sum_1", TenantID: "ten_1", CreatedAt: now}}, + QuestionnaireDrafts: map[string]domain.QuestionnaireDraft{"qd_1": {ID: "qd_1", TenantID: "ten_1", ProductID: "prod_1", ReleaseID: "rel_1", CreatedAt: now}}, + GraphSnapshots: map[string]domain.EvidenceGraphSnapshot{"graph_1": {ID: "graph_1", TenantID: "ten_1", ProductID: "prod_1", ReleaseID: "rel_1", CreatedAt: now}}, + SaaSProfiles: map[string]domain.SaaSEditionProfile{"saas_1": {ID: "saas_1", TenantID: "ten_1", CreatedAt: now}}, + PublicTransparencyLogs: map[string]domain.PublicTransparencyLog{"ptl_1": {ID: "ptl_1", TenantID: "ten_1", CreatedAt: now}}, + PublicTransparencyItems: map[string]domain.PublicTransparencyLogEntry{"pte_1": {ID: "pte_1", TenantID: "ten_1", CreatedAt: now}}, + MarketplaceCollectors: map[string]domain.MarketplaceCollector{"mc_1": {ID: "mc_1", TenantID: "ten_1", CreatedAt: now}}, + PDFReports: map[string]domain.PDFReportPackage{"pdf_1": {ID: "pdf_1", TenantID: "ten_1", ProductID: "prod_1", ReleaseID: "rel_1", CreatedAt: now}}, + AnomalyReports: map[string]domain.AnomalyReport{"anom_1": {ID: "anom_1", TenantID: "ten_1", CreatedAt: now}}, + ProviderVerifications: map[string]domain.ProviderVerification{"pv_1": {ID: "pv_1", TenantID: "ten_1", CreatedAt: now}}, + SigningOperations: map[string]domain.SigningOperation{"so_1": {ID: "so_1", TenantID: "ten_1", CreatedAt: now}}, + ControlFrameworks: map[string]domain.ControlFramework{"cf_1": {ID: "cf_1", TenantID: "ten_1", CreatedAt: now}}, + SecurityControls: map[string]domain.SecurityControl{"ctrl_1": {ID: "ctrl_1", TenantID: "ten_1", CreatedAt: now}}, + ControlEvidence: map[string]domain.ControlEvidence{"ce_1": {ID: "ce_1", TenantID: "ten_1", ProductID: "prod_1", ReleaseID: "rel_1", CreatedAt: now}}, + SBOMs: map[string]domain.SBOM{"sbom_1": {ID: "sbom_1", TenantID: "ten_1", ReleaseID: "rel_1", CreatedAt: now}}, + Scans: map[string]domain.VulnerabilityScan{"scan_1": {ID: "scan_1", TenantID: "ten_1", ReleaseID: "rel_1", CreatedAt: now}}, + VEXDocuments: map[string]domain.VEXDocument{"vex_1": {ID: "vex_1", TenantID: "ten_1", ReleaseID: "rel_1", CreatedAt: now}}, + Contracts: map[string]domain.OpenAPIContract{"api_1": {ID: "api_1", TenantID: "ten_1", ProductID: "prod_1", ReleaseID: "rel_1", CreatedAt: now}}, + Bundles: map[string]domain.ReleaseBundle{"bundle_1": {ID: "bundle_1", TenantID: "ten_1", ReleaseID: "rel_1", CreatedAt: now}}, + } + + projections := resourceProjections(state) + if len(projections) != 76 { + t.Fatalf("projection count = %d, want 76", len(projections)) + } + byType := map[string]resourceProjection{} + for _, projection := range projections { + byType[projection.ResourceType] = projection + if projection.TenantID != "ten_1" { + t.Fatalf("projection lost tenant scope: %#v", projection) + } + } + for _, resourceType := range []string{ + "tenant", "product", "project", "release", "evidence_item", "build_run", + "release_candidate", "deployment", "customer_security_package", + "control_evidence", "public_transparency_log_entry", "release_bundle", + } { + if byType[resourceType].ResourceID == "" { + t.Fatalf("missing projection type %s in %#v", resourceType, byType) + } + } + if byType["project"].ProductID != "prod_1" || byType["project"].ProjectID != "proj_1" { + t.Fatalf("project projection = %#v", byType["project"]) + } + if byType["release"].ProductID != "prod_1" || byType["release"].ReleaseID != "rel_1" { + t.Fatalf("release projection = %#v", byType["release"]) + } + if byType["customer_security_package"].ProductID != "prod_1" || byType["customer_security_package"].ReleaseID != "rel_1" { + t.Fatalf("customer package projection = %#v", byType["customer_security_package"]) + } + if nullableString("") != nil { + t.Fatal("empty nullableString should be nil") + } + if got := nullableString("prod_1"); got != "prod_1" { + t.Fatalf("nullableString = %#v", got) + } +} diff --git a/internal/adapters/postgres/sql_helpers.go b/internal/adapters/postgres/sql_helpers.go new file mode 100644 index 0000000..aa0cb6b --- /dev/null +++ b/internal/adapters/postgres/sql_helpers.go @@ -0,0 +1,85 @@ +package postgres + +import ( + "database/sql" + "encoding/json" + "time" +) + +func nullableString(value string) any { + if value == "" { + return nil + } + return value +} + +func nullableSQLString(value sql.NullString) string { + if !value.Valid { + return "" + } + return value.String +} + +func nullableSQLTime(value sql.NullTime) *time.Time { + if !value.Valid || value.Time.IsZero() { + return nil + } + t := value.Time.UTC() + return &t +} + +func decodeJSON(raw []byte, out any) error { + if len(raw) == 0 || string(raw) == "null" { + return nil + } + return json.Unmarshal(raw, out) +} + +func nullableTime(value *time.Time) any { + if value == nil || value.IsZero() { + return nil + } + return value.UTC() +} + +func nullableInt64(value int64) any { + if value == 0 { + return nil + } + return value +} + +func nullableInt(value int) any { + if value == 0 { + return nil + } + return value +} + +func nonZeroInt(value, fallback int) int { + if value == 0 { + return fallback + } + return value +} + +func nullableBytes(value []byte) any { + if len(value) == 0 { + return nil + } + return value +} + +func textArray(value []string) []string { + if value == nil { + return []string{} + } + return value +} + +func nonZeroTime(value time.Time) time.Time { + if value.IsZero() { + return time.Now().UTC() + } + return value.UTC() +} diff --git a/internal/adapters/postgres/store.go b/internal/adapters/postgres/store.go index 6b29170..047ab66 100644 --- a/internal/adapters/postgres/store.go +++ b/internal/adapters/postgres/store.go @@ -2,88 +2,4969 @@ package postgres import ( "context" + "database/sql" "encoding/json" "errors" "fmt" + "strings" "time" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" "github.com/aatuh/evydence/internal/app" + "github.com/aatuh/evydence/internal/domain" ) type Store struct { - pool *pgxpool.Pool + pool *pgxpool.Pool + loadMode LoadMode + disableSnapshotWrites bool } -type ClaimedJob struct { - ID string - TenantID string - Kind string - SubjectType string - SubjectID string - Attempts int - Payload map[string]any +type LoadMode string + +const ( + LoadModeSnapshotPreferred LoadMode = "snapshot_preferred" + LoadModeRelationalPreferred LoadMode = "relational_preferred" + LoadModeRelationalOnly LoadMode = "relational_only" +) + +type StoreOptions struct { + LoadMode LoadMode + DisableSnapshotWrites bool } +const apiWriterLeaseKey int64 = 0x65767964656e6365 + func Open(ctx context.Context, databaseURL string) (*Store, error) { + return OpenWithOptions(ctx, databaseURL, StoreOptions{}) +} + +func OpenWithOptions(ctx context.Context, databaseURL string, opts StoreOptions) (*Store, error) { + loadMode, err := normalizeLoadMode(opts.LoadMode) + if err != nil { + return nil, err + } pool, err := pgxpool.New(ctx, databaseURL) if err != nil { return nil, fmt.Errorf("open postgres pool: %w", err) } - if err := pool.Ping(ctx); err != nil { - pool.Close() - return nil, fmt.Errorf("ping postgres: %w", err) + if err := pool.Ping(ctx); err != nil { + pool.Close() + return nil, fmt.Errorf("ping postgres: %w", err) + } + return &Store{pool: pool, loadMode: loadMode, disableSnapshotWrites: opts.DisableSnapshotWrites}, nil +} + +func (s *Store) Close() { + if s != nil && s.pool != nil { + s.pool.Close() + } +} + +func (s *Store) LoadState(ctx context.Context) (app.PersistedState, bool, error) { + switch s.loadMode { + case LoadModeRelationalPreferred: + state, ok, err := s.loadRelationalState(ctx) + if err != nil || ok { + return state, ok, err + } + return s.loadSnapshotState(ctx) + case LoadModeRelationalOnly: + return s.loadRelationalState(ctx) + default: + state, ok, err := s.loadSnapshotState(ctx) + if err != nil || ok { + return state, ok, err + } + return s.loadRelationalState(ctx) + } +} + +func ResolveLoadMode(raw string, production bool) (LoadMode, error) { + if strings.TrimSpace(raw) == "" && production { + return LoadModeRelationalOnly, nil + } + return normalizeLoadMode(LoadMode(raw)) +} + +func ValidateProductionLoadMode(mode LoadMode) error { + if mode != LoadModeRelationalOnly { + return errors.New("production requires EVYDENCE_POSTGRES_LOAD_MODE=relational_only or unset") + } + return nil +} + +func (s *Store) AcquireAPIWriterLease(ctx context.Context) (func(), error) { + if s == nil || s.pool == nil { + return nil, app.ErrValidation + } + conn, err := s.pool.Acquire(ctx) + if err != nil { + return nil, fmt.Errorf("acquire api writer lease connection: %w", err) + } + releaseConn := true + defer func() { + if releaseConn { + conn.Release() + } + }() + var acquired bool + if err := conn.QueryRow(ctx, `SELECT pg_try_advisory_lock($1)`, apiWriterLeaseKey).Scan(&acquired); err != nil { + return nil, fmt.Errorf("acquire api writer lease: %w", err) + } + if !acquired { + return nil, errors.New("another Evydence API writer is already active") + } + releaseConn = false + releaseCtx := context.WithoutCancel(ctx) + return func() { + _, _ = conn.Exec(releaseCtx, `SELECT pg_advisory_unlock($1)`, apiWriterLeaseKey) + conn.Release() + }, nil +} + +func normalizeLoadMode(mode LoadMode) (LoadMode, error) { + switch strings.ToLower(strings.TrimSpace(string(mode))) { + case "", "snapshot", "snapshot_preferred", "snapshot-preferred": + return LoadModeSnapshotPreferred, nil + case "relational", "relational_preferred", "relational-preferred": + return LoadModeRelationalPreferred, nil + case "relational_only", "relational-only": + return LoadModeRelationalOnly, nil + default: + return "", fmt.Errorf("unsupported postgres load mode %q", mode) + } +} + +func (s *Store) loadSnapshotState(ctx context.Context) (app.PersistedState, bool, error) { + var body []byte + err := s.pool.QueryRow(ctx, `SELECT state FROM ledger_state WHERE id = 'default'`).Scan(&body) + if errors.Is(err, pgx.ErrNoRows) { + return app.PersistedState{}, false, nil + } + if err != nil { + return app.PersistedState{}, false, fmt.Errorf("load ledger state: %w", err) + } + var state app.PersistedState + if err := json.Unmarshal(body, &state); err != nil { + return app.PersistedState{}, false, fmt.Errorf("decode ledger state: %w", err) + } + return state, true, nil +} + +func (s *Store) loadRelationalState(ctx context.Context) (app.PersistedState, bool, error) { + state := relationalEmptyState() + loaded := false + if err := s.loadRelationalTenants(ctx, &state, &loaded); err != nil { + return app.PersistedState{}, false, err + } + if err := s.loadRelationalIdentity(ctx, &state, &loaded); err != nil { + return app.PersistedState{}, false, err + } + if err := s.loadRelationalAPIKeys(ctx, &state, &loaded); err != nil { + return app.PersistedState{}, false, err + } + if err := s.loadRelationalCustomerPortalAccess(ctx, &state, &loaded); err != nil { + return app.PersistedState{}, false, err + } + if err := s.loadRelationalReleaseCore(ctx, &state, &loaded); err != nil { + return app.PersistedState{}, false, err + } + if err := s.loadRelationalRiskBuildControls(ctx, &state, &loaded); err != nil { + return app.PersistedState{}, false, err + } + if err := s.loadRelationalSourceDeploymentLifecycle(ctx, &state, &loaded); err != nil { + return app.PersistedState{}, false, err + } + if err := s.loadRelationalIncidentSecurityGovernance(ctx, &state, &loaded); err != nil { + return app.PersistedState{}, false, err + } + if err := s.loadRelationalIntegrityProviderRows(ctx, &state, &loaded); err != nil { + return app.PersistedState{}, false, err + } + if err := s.loadRelationalPackageReportRetention(ctx, &state, &loaded); err != nil { + return app.PersistedState{}, false, err + } + if err := s.loadRelationalFutureExtensionRows(ctx, &state, &loaded); err != nil { + return app.PersistedState{}, false, err + } + if err := s.loadRelationalIdempotency(ctx, &state, &loaded); err != nil { + return app.PersistedState{}, false, err + } + return state, loaded, nil +} + +func relationalEmptyState() app.PersistedState { + return app.PersistedState{ + Tenants: map[string]domain.Tenant{}, + Organizations: map[string]domain.Organization{}, + Users: map[string]domain.HumanUser{}, + RoleBindings: map[string]domain.RoleBinding{}, + SSOProviders: map[string]domain.SSOProvider{}, + IdentityLinks: map[string]domain.UserIdentityLink{}, + SSOSessions: map[string]domain.SSOSession{}, + SSOSessionHashes: map[string]string{}, + APIKeys: map[string]domain.APIKey{}, + APIKeyHashes: map[string]string{}, + Collectors: map[string]domain.Collector{}, + CollectorReleases: map[string]domain.CollectorRelease{}, + BuildRuns: map[string]domain.BuildRun{}, + BuildAttestations: map[string]domain.BuildAttestation{}, + EvidenceLifecycle: map[string]domain.EvidenceLifecycleEvent{}, + ReleaseCandidates: map[string]domain.ReleaseCandidate{}, + ContainerImages: map[string]domain.ContainerImage{}, + ArtifactSignatures: map[string]domain.ArtifactSignature{}, + Repositories: map[string]domain.SourceRepository{}, + Commits: map[string]domain.SourceCommit{}, + Branches: map[string]domain.SourceBranch{}, + PullRequests: map[string]domain.PullRequest{}, + Environments: map[string]domain.DeploymentEnvironment{}, + Deployments: map[string]domain.DeploymentEvent{}, + Incidents: map[string]domain.Incident{}, + TimelineEvents: map[string]domain.IncidentTimelineEvent{}, + IncidentWebhookReceivers: map[string]domain.IncidentWebhookReceiver{}, + IncidentWebhookEvents: map[string]domain.IncidentWebhookEvent{}, + RemediationTasks: map[string]domain.RemediationTask{}, + SecurityScans: map[string]domain.SecurityScan{}, + ManualSecurityDocs: map[string]domain.ManualSecurityDocument{}, + SBOMDiffs: map[string]domain.SBOMDiff{}, + DependencyChanges: map[string]domain.DependencyChange{}, + VulnerabilityWorkflow: map[string]domain.VulnerabilityWorkflowRecord{}, + ContractDiffs: map[string]domain.ContractDiff{}, + CustomPolicies: map[string]domain.CustomPolicy{}, + CustomPolicyEvaluations: map[string]domain.CustomPolicyEvaluation{}, + Waivers: map[string]domain.Waiver{}, + Approvals: map[string]domain.ApprovalRecord{}, + DSSETrustRoots: map[string]domain.DSSETrustRoot{}, + CosignVerifications: map[string]domain.CosignVerification{}, + SigningProviders: map[string]domain.SigningProvider{}, + MerkleBatches: map[string]domain.MerkleBatch{}, + TransparencyCheckpoints: map[string]domain.TransparencyCheckpoint{}, + CustomerPortalAccess: map[string]domain.CustomerPortalAccess{}, + CustomerPortalHashes: map[string]string{}, + RedactionProfiles: map[string]domain.RedactionProfile{}, + CustomerPackages: map[string]domain.CustomerSecurityPackage{}, + HTMLReports: map[string]domain.HTMLReportPackage{}, + ReportTemplates: map[string]domain.CustomReportTemplate{}, + RenderedReports: map[string]domain.RenderedCustomReport{}, + EvidenceBundles: map[string]domain.EvidenceBundle{}, + BundleImports: map[string]domain.EvidenceBundleImport{}, + ObjectRetentionPolicies: map[string]domain.ObjectRetentionPolicy{}, + BackupManifests: map[string]domain.BackupManifest{}, + LegalHolds: map[string]domain.LegalHold{}, + RetentionOverrides: map[string]domain.RetentionOverride{}, + QuestionnaireTemplates: map[string]domain.QuestionnaireTemplate{}, + QuestionnairePackages: map[string]domain.QuestionnairePackage{}, + QuestionnaireAnswerLibrary: map[string]domain.QuestionnaireAnswerLibraryEntry{}, + CommercialCollectors: map[string]domain.CommercialCollectorDefinition{}, + EvidenceSummaries: map[string]domain.EvidenceSummary{}, + QuestionnaireDrafts: map[string]domain.QuestionnaireDraft{}, + GraphSnapshots: map[string]domain.EvidenceGraphSnapshot{}, + SaaSProfiles: map[string]domain.SaaSEditionProfile{}, + PublicTransparencyLogs: map[string]domain.PublicTransparencyLog{}, + PublicTransparencyItems: map[string]domain.PublicTransparencyLogEntry{}, + MarketplaceCollectors: map[string]domain.MarketplaceCollector{}, + PDFReports: map[string]domain.PDFReportPackage{}, + AnomalyReports: map[string]domain.AnomalyReport{}, + ProviderVerifications: map[string]domain.ProviderVerification{}, + SigningOperations: map[string]domain.SigningOperation{}, + ControlFrameworks: map[string]domain.ControlFramework{}, + SecurityControls: map[string]domain.SecurityControl{}, + ControlEvidence: map[string]domain.ControlEvidence{}, + Products: map[string]domain.Product{}, + Projects: map[string]domain.Project{}, + Releases: map[string]domain.Release{}, + Artifacts: map[string]domain.Artifact{}, + Evidence: map[string]domain.EvidenceItem{}, + SBOMs: map[string]domain.SBOM{}, + Scans: map[string]domain.VulnerabilityScan{}, + VEXDocuments: map[string]domain.VEXDocument{}, + VEXImportReports: map[string]domain.VEXImportReport{}, + Decisions: map[string]domain.VulnerabilityDecision{}, + Contracts: map[string]domain.OpenAPIContract{}, + Policies: map[string]domain.PolicyEvaluation{}, + Exceptions: map[string]domain.Exception{}, + Bundles: map[string]domain.ReleaseBundle{}, + SigningKeys: map[string]domain.SigningKey{}, + SigningKeyPrivate: map[string][]byte{}, + Signatures: map[string]domain.Signature{}, + Verifications: map[string]domain.VerificationResult{}, + Chain: map[string][]domain.AuditChainEntry{}, + Idempotency: map[string]app.IdempotencyRecord{}, + } +} + +func (s *Store) loadRelationalTenants(ctx context.Context, state *app.PersistedState, loaded *bool) error { + rows, err := s.pool.Query(ctx, `SELECT id, name, created_at FROM tenants`) + if err != nil { + return fmt.Errorf("load relational tenants: %w", err) + } + defer rows.Close() + for rows.Next() { + var tenant domain.Tenant + if err := rows.Scan(&tenant.ID, &tenant.Name, &tenant.CreatedAt); err != nil { + return fmt.Errorf("scan relational tenant: %w", err) + } + state.Tenants[tenant.ID] = tenant + *loaded = true + } + return rows.Err() +} + +func (s *Store) loadRelationalIdentity(ctx context.Context, state *app.PersistedState, loaded *bool) error { + if err := s.loadRelationalOrganizations(ctx, state, loaded); err != nil { + return err + } + if err := s.loadRelationalUsers(ctx, state, loaded); err != nil { + return err + } + if err := s.loadRelationalRoleBindings(ctx, state, loaded); err != nil { + return err + } + if err := s.loadRelationalSSOProviders(ctx, state, loaded); err != nil { + return err + } + if err := s.loadRelationalIdentityLinks(ctx, state, loaded); err != nil { + return err + } + if err := s.loadRelationalSSOSessions(ctx, state, loaded); err != nil { + return err + } + return nil +} + +func (s *Store) loadRelationalOrganizations(ctx context.Context, state *app.PersistedState, loaded *bool) error { + rows, err := s.pool.Query(ctx, `SELECT id, tenant_id, name, slug, status, schema_version, created_at FROM organizations`) + if err != nil { + return fmt.Errorf("load relational organizations: %w", err) + } + defer rows.Close() + for rows.Next() { + var org domain.Organization + if err := rows.Scan(&org.ID, &org.TenantID, &org.Name, &org.Slug, &org.Status, &org.SchemaVersion, &org.CreatedAt); err != nil { + return fmt.Errorf("scan relational organization: %w", err) + } + state.Organizations[org.ID] = org + *loaded = true + } + return rows.Err() +} + +func (s *Store) loadRelationalUsers(ctx context.Context, state *app.PersistedState, loaded *bool) error { + rows, err := s.pool.Query(ctx, `SELECT id, tenant_id, organization_id, email, display_name, status, deactivated_at, schema_version, created_at FROM human_users`) + if err != nil { + return fmt.Errorf("load relational users: %w", err) + } + defer rows.Close() + for rows.Next() { + var user domain.HumanUser + var organizationID sql.NullString + var deactivatedAt sql.NullTime + if err := rows.Scan(&user.ID, &user.TenantID, &organizationID, &user.Email, &user.DisplayName, &user.Status, &deactivatedAt, &user.SchemaVersion, &user.CreatedAt); err != nil { + return fmt.Errorf("scan relational user: %w", err) + } + user.OrganizationID = nullableSQLString(organizationID) + user.DeactivatedAt = nullableSQLTime(deactivatedAt) + state.Users[user.ID] = user + *loaded = true + } + return rows.Err() +} + +func (s *Store) loadRelationalRoleBindings(ctx context.Context, state *app.PersistedState, loaded *bool) error { + rows, err := s.pool.Query(ctx, `SELECT id, tenant_id, subject_type, subject_id, role, resource_type, resource_id, schema_version, created_at FROM role_bindings`) + if err != nil { + return fmt.Errorf("load relational role bindings: %w", err) + } + defer rows.Close() + for rows.Next() { + var binding domain.RoleBinding + var resourceType, resourceID sql.NullString + if err := rows.Scan(&binding.ID, &binding.TenantID, &binding.SubjectType, &binding.SubjectID, &binding.Role, &resourceType, &resourceID, &binding.SchemaVersion, &binding.CreatedAt); err != nil { + return fmt.Errorf("scan relational role binding: %w", err) + } + binding.ResourceType = nullableSQLString(resourceType) + binding.ResourceID = nullableSQLString(resourceID) + state.RoleBindings[binding.ID] = binding + *loaded = true + } + return rows.Err() +} + +func (s *Store) loadRelationalSSOProviders(ctx context.Context, state *app.PersistedState, loaded *bool) error { + rows, err := s.pool.Query(ctx, ` + SELECT id, tenant_id, name, type, issuer, client_id, groups_claim, + role_mapping, status, schema_version, created_at, jwks, + saml_signing_certificates, trust_material_updated_at + FROM sso_providers + `) + if err != nil { + return fmt.Errorf("load relational sso providers: %w", err) + } + defer rows.Close() + for rows.Next() { + var provider domain.SSOProvider + var groupsClaim sql.NullString + var roleMapping, jwks, samlCerts []byte + var trustMaterialUpdatedAt sql.NullTime + if err := rows.Scan( + &provider.ID, &provider.TenantID, &provider.Name, &provider.Type, &provider.Issuer, &provider.ClientID, &groupsClaim, + &roleMapping, &provider.Status, &provider.SchemaVersion, &provider.CreatedAt, &jwks, + &samlCerts, &trustMaterialUpdatedAt, + ); err != nil { + return fmt.Errorf("scan relational sso provider: %w", err) + } + provider.GroupsClaim = nullableSQLString(groupsClaim) + provider.TrustMaterialUpdatedAt = nullableSQLTime(trustMaterialUpdatedAt) + if err := decodeJSON(roleMapping, &provider.RoleMapping); err != nil { + return fmt.Errorf("decode relational sso role mapping: %w", err) + } + if err := decodeJSON(jwks, &provider.JWKS); err != nil { + return fmt.Errorf("decode relational sso jwks: %w", err) + } + if err := decodeJSON(samlCerts, &provider.SAMLSigningCertificates); err != nil { + return fmt.Errorf("decode relational sso certificates: %w", err) + } + state.SSOProviders[provider.ID] = provider + *loaded = true + } + return rows.Err() +} + +func (s *Store) loadRelationalIdentityLinks(ctx context.Context, state *app.PersistedState, loaded *bool) error { + rows, err := s.pool.Query(ctx, `SELECT id, tenant_id, user_id, provider_id, subject, email, verified, schema_version, created_at FROM user_identity_links`) + if err != nil { + return fmt.Errorf("load relational identity links: %w", err) + } + defer rows.Close() + for rows.Next() { + var link domain.UserIdentityLink + if err := rows.Scan(&link.ID, &link.TenantID, &link.UserID, &link.ProviderID, &link.Subject, &link.Email, &link.Verified, &link.SchemaVersion, &link.CreatedAt); err != nil { + return fmt.Errorf("scan relational identity link: %w", err) + } + state.IdentityLinks[link.ID] = link + *loaded = true + } + return rows.Err() +} + +func (s *Store) loadRelationalSSOSessions(ctx context.Context, state *app.PersistedState, loaded *bool) error { + rows, err := s.pool.Query(ctx, `SELECT id, tenant_id, user_id, provider_id, prefix, hash, COALESCE(groups, '[]'::jsonb), expires_at, revoked_at, schema_version, created_at FROM sso_sessions`) + if err != nil { + return fmt.Errorf("load relational sso sessions: %w", err) + } + defer rows.Close() + for rows.Next() { + var session domain.SSOSession + var hash string + var groups []byte + var revokedAt sql.NullTime + if err := rows.Scan(&session.ID, &session.TenantID, &session.UserID, &session.ProviderID, &session.Prefix, &hash, &groups, &session.ExpiresAt, &revokedAt, &session.SchemaVersion, &session.CreatedAt); err != nil { + return fmt.Errorf("scan relational sso session: %w", err) + } + if len(groups) > 0 { + if err := json.Unmarshal(groups, &session.Groups); err != nil { + return fmt.Errorf("decode relational sso session groups: %w", err) + } + } + session.RevokedAt = nullableSQLTime(revokedAt) + state.SSOSessions[session.ID] = session + state.SSOSessionHashes[session.ID] = hash + *loaded = true + } + return rows.Err() +} + +func (s *Store) loadRelationalAPIKeys(ctx context.Context, state *app.PersistedState, loaded *bool) error { + rows, err := s.pool.Query(ctx, ` + SELECT id, tenant_id, name, prefix, hash, scopes, expires_at, + revoked_at, last_used_at, created_at + FROM api_keys + `) + if err != nil { + return fmt.Errorf("load relational api keys: %w", err) + } + defer rows.Close() + for rows.Next() { + var key domain.APIKey + var hash string + var scopes []byte + var expiresAt, revokedAt, lastUsedAt sql.NullTime + if err := rows.Scan(&key.ID, &key.TenantID, &key.Name, &key.Prefix, &hash, &scopes, &expiresAt, &revokedAt, &lastUsedAt, &key.CreatedAt); err != nil { + return fmt.Errorf("scan relational api key: %w", err) + } + if err := decodeJSON(scopes, &key.Scopes); err != nil { + return fmt.Errorf("decode relational api key scopes: %w", err) + } + key.ExpiresAt = nullableSQLTime(expiresAt) + key.RevokedAt = nullableSQLTime(revokedAt) + key.LastUsedAt = nullableSQLTime(lastUsedAt) + state.APIKeys[key.ID] = key + state.APIKeyHashes[key.ID] = hash + *loaded = true + } + return rows.Err() +} + +func (s *Store) loadRelationalCustomerPortalAccess(ctx context.Context, state *app.PersistedState, loaded *bool) error { + rows, err := s.pool.Query(ctx, ` + SELECT id, tenant_id, package_id, customer_name, reviewer_name, reviewer_email, prefix, hash, + expires_at, revoked_at, access_count, failed_access_count, + last_accessed_at, last_failed_at, require_nda, nda_accepted_at, + nda_accepted_by, watermark, schema_version, created_at + FROM customer_portal_access + `) + if err != nil { + return fmt.Errorf("load relational customer portal access: %w", err) + } + defer rows.Close() + for rows.Next() { + var access domain.CustomerPortalAccess + var hash string + var revokedAt, lastAccessedAt, lastFailedAt, ndaAcceptedAt sql.NullTime + var reviewerName, reviewerEmail, ndaAcceptedBy, watermark sql.NullString + if err := rows.Scan( + &access.ID, &access.TenantID, &access.PackageID, &access.CustomerName, &reviewerName, &reviewerEmail, &access.Prefix, &hash, + &access.ExpiresAt, &revokedAt, &access.AccessCount, &access.FailedAccessCount, + &lastAccessedAt, &lastFailedAt, &access.RequireNDA, &ndaAcceptedAt, &ndaAcceptedBy, &watermark, &access.SchemaVersion, &access.CreatedAt, + ); err != nil { + return fmt.Errorf("scan relational customer portal access: %w", err) + } + access.ReviewerName = nullableSQLString(reviewerName) + access.ReviewerEmail = nullableSQLString(reviewerEmail) + access.RevokedAt = nullableSQLTime(revokedAt) + access.LastAccessedAt = nullableSQLTime(lastAccessedAt) + access.LastFailedAt = nullableSQLTime(lastFailedAt) + access.NDAAcceptedAt = nullableSQLTime(ndaAcceptedAt) + access.NDAAcceptedBy = nullableSQLString(ndaAcceptedBy) + access.Watermark = nullableSQLString(watermark) + state.CustomerPortalAccess[access.ID] = access + state.CustomerPortalHashes[access.ID] = hash + *loaded = true + } + return rows.Err() +} + +func (s *Store) loadRelationalReleaseCore(ctx context.Context, state *app.PersistedState, loaded *bool) error { + if err := s.loadRelationalProducts(ctx, state, loaded); err != nil { + return err + } + if err := s.loadRelationalProjects(ctx, state, loaded); err != nil { + return err + } + if err := s.loadRelationalReleases(ctx, state, loaded); err != nil { + return err + } + if err := s.loadRelationalArtifacts(ctx, state, loaded); err != nil { + return err + } + if err := s.loadRelationalEvidence(ctx, state, loaded); err != nil { + return err + } + if err := s.loadRelationalAuditChain(ctx, state, loaded); err != nil { + return err + } + if err := s.loadRelationalSigning(ctx, state, loaded); err != nil { + return err + } + if err := s.loadRelationalParsedResources(ctx, state, loaded); err != nil { + return err + } + return nil +} + +func (s *Store) loadRelationalProducts(ctx context.Context, state *app.PersistedState, loaded *bool) error { + rows, err := s.pool.Query(ctx, `SELECT id, tenant_id, name, slug, created_at FROM products`) + if err != nil { + return fmt.Errorf("load relational products: %w", err) + } + defer rows.Close() + for rows.Next() { + var product domain.Product + if err := rows.Scan(&product.ID, &product.TenantID, &product.Name, &product.Slug, &product.CreatedAt); err != nil { + return fmt.Errorf("scan relational product: %w", err) + } + state.Products[product.ID] = product + *loaded = true + } + return rows.Err() +} + +func (s *Store) loadRelationalProjects(ctx context.Context, state *app.PersistedState, loaded *bool) error { + rows, err := s.pool.Query(ctx, `SELECT id, tenant_id, product_id, name, created_at FROM projects`) + if err != nil { + return fmt.Errorf("load relational projects: %w", err) + } + defer rows.Close() + for rows.Next() { + var project domain.Project + if err := rows.Scan(&project.ID, &project.TenantID, &project.ProductID, &project.Name, &project.CreatedAt); err != nil { + return fmt.Errorf("scan relational project: %w", err) + } + state.Projects[project.ID] = project + *loaded = true + } + return rows.Err() +} + +func (s *Store) loadRelationalReleases(ctx context.Context, state *app.PersistedState, loaded *bool) error { + rows, err := s.pool.Query(ctx, `SELECT id, tenant_id, product_id, version, state, frozen_at, approved_at, created_at FROM releases`) + if err != nil { + return fmt.Errorf("load relational releases: %w", err) + } + defer rows.Close() + for rows.Next() { + var release domain.Release + var frozenAt, approvedAt sql.NullTime + if err := rows.Scan(&release.ID, &release.TenantID, &release.ProductID, &release.Version, &release.State, &frozenAt, &approvedAt, &release.CreatedAt); err != nil { + return fmt.Errorf("scan relational release: %w", err) + } + release.FrozenAt = nullableSQLTime(frozenAt) + release.ApprovedAt = nullableSQLTime(approvedAt) + state.Releases[release.ID] = release + *loaded = true + } + return rows.Err() +} + +func (s *Store) loadRelationalArtifacts(ctx context.Context, state *app.PersistedState, loaded *bool) error { + rows, err := s.pool.Query(ctx, `SELECT id, tenant_id, name, media_type, size, digest, created_at FROM artifacts`) + if err != nil { + return fmt.Errorf("load relational artifacts: %w", err) + } + defer rows.Close() + for rows.Next() { + var artifact domain.Artifact + if err := rows.Scan(&artifact.ID, &artifact.TenantID, &artifact.Name, &artifact.MediaType, &artifact.Size, &artifact.Digest, &artifact.CreatedAt); err != nil { + return fmt.Errorf("scan relational artifact: %w", err) + } + state.Artifacts[artifact.ID] = artifact + *loaded = true + } + return rows.Err() +} + +func (s *Store) loadRelationalEvidence(ctx context.Context, state *app.PersistedState, loaded *bool) error { + rows, err := s.pool.Query(ctx, ` + SELECT id, tenant_id, product_id, project_id, release_id, build_id, deployment_id, + type, subtype, title, source_system, source_identity, collector_id, + uploaded_by, observed_at, evidence_version, schema_version, payload_ref, + payload_hash, payload_media_type, payload_size, canonical_hash, + canonicalization, subject_refs, related_evidence_refs, supersedes, + superseded_by, trust_level, verification_status, signature_refs, + chain_entry_id, tags, metadata, warnings, limitations, created_at + FROM evidence_items + `) + if err != nil { + return fmt.Errorf("load relational evidence items: %w", err) + } + defer rows.Close() + for rows.Next() { + var evidence domain.EvidenceItem + var productID, projectID, releaseID, buildID, deploymentID, subtype, collectorID, uploadedBy, payloadRef, payloadMediaType, supersedes, supersededBy, chainEntryID sql.NullString + var payloadSize sql.NullInt64 + var sourceIdentity, subjectRefs, relatedRefs, signatureRefs, tags, metadata, warnings, limitations []byte + if err := rows.Scan( + &evidence.ID, &evidence.TenantID, &productID, &projectID, &releaseID, &buildID, &deploymentID, + &evidence.Type, &subtype, &evidence.Title, &evidence.SourceSystem, &sourceIdentity, &collectorID, + &uploadedBy, &evidence.ObservedAt, &evidence.EvidenceVersion, &evidence.SchemaVersion, &payloadRef, + &evidence.PayloadHash, &payloadMediaType, &payloadSize, &evidence.CanonicalHash, + &evidence.Canonicalization, &subjectRefs, &relatedRefs, &supersedes, + &supersededBy, &evidence.TrustLevel, &evidence.VerificationStatus, &signatureRefs, + &chainEntryID, &tags, &metadata, &warnings, &limitations, &evidence.CreatedAt, + ); err != nil { + return fmt.Errorf("scan relational evidence item: %w", err) + } + evidence.ProductID = nullableSQLString(productID) + evidence.ProjectID = nullableSQLString(projectID) + evidence.ReleaseID = nullableSQLString(releaseID) + evidence.BuildID = nullableSQLString(buildID) + evidence.DeploymentID = nullableSQLString(deploymentID) + evidence.Subtype = nullableSQLString(subtype) + evidence.CollectorID = nullableSQLString(collectorID) + evidence.UploadedBy = nullableSQLString(uploadedBy) + evidence.PayloadRef = nullableSQLString(payloadRef) + evidence.PayloadMediaType = nullableSQLString(payloadMediaType) + if payloadSize.Valid { + evidence.PayloadSize = payloadSize.Int64 + } + evidence.Supersedes = nullableSQLString(supersedes) + evidence.SupersededBy = nullableSQLString(supersededBy) + evidence.ChainEntryID = nullableSQLString(chainEntryID) + if err := decodeJSON(sourceIdentity, &evidence.SourceIdentity); err != nil { + return fmt.Errorf("decode relational evidence source identity: %w", err) + } + if err := decodeJSON(subjectRefs, &evidence.SubjectRefs); err != nil { + return fmt.Errorf("decode relational evidence subject refs: %w", err) + } + if err := decodeJSON(relatedRefs, &evidence.RelatedEvidenceRefs); err != nil { + return fmt.Errorf("decode relational evidence related refs: %w", err) + } + if err := decodeJSON(signatureRefs, &evidence.SignatureRefs); err != nil { + return fmt.Errorf("decode relational evidence signature refs: %w", err) + } + if err := decodeJSON(tags, &evidence.Tags); err != nil { + return fmt.Errorf("decode relational evidence tags: %w", err) + } + if err := decodeJSON(metadata, &evidence.Metadata); err != nil { + return fmt.Errorf("decode relational evidence metadata: %w", err) + } + if err := decodeJSON(warnings, &evidence.Warnings); err != nil { + return fmt.Errorf("decode relational evidence warnings: %w", err) + } + if err := decodeJSON(limitations, &evidence.Limitations); err != nil { + return fmt.Errorf("decode relational evidence limitations: %w", err) + } + state.Evidence[evidence.ID] = evidence + *loaded = true + } + return rows.Err() +} + +func (s *Store) loadRelationalAuditChain(ctx context.Context, state *app.PersistedState, loaded *bool) error { + rows, err := s.pool.Query(ctx, ` + SELECT id, tenant_id, sequence, entry_type, subject_type, subject_id, + actor_type, actor_id, occurred_at, payload_hash, + canonical_entry_hash, previous_entry_hash, entry_hash, + signature_ref, metadata, schema_version + FROM audit_chain_entries + ORDER BY tenant_id, sequence + `) + if err != nil { + return fmt.Errorf("load relational audit chain: %w", err) + } + defer rows.Close() + for rows.Next() { + var entry domain.AuditChainEntry + var payloadHash, signatureRef sql.NullString + var metadata []byte + if err := rows.Scan( + &entry.ID, &entry.TenantID, &entry.Sequence, &entry.EntryType, &entry.SubjectType, &entry.SubjectID, + &entry.ActorType, &entry.ActorID, &entry.OccurredAt, &payloadHash, + &entry.CanonicalEntryHash, &entry.PreviousEntryHash, &entry.EntryHash, + &signatureRef, &metadata, &entry.SchemaVersion, + ); err != nil { + return fmt.Errorf("scan relational audit chain entry: %w", err) + } + entry.PayloadHash = nullableSQLString(payloadHash) + entry.SignatureRef = nullableSQLString(signatureRef) + if err := decodeJSON(metadata, &entry.Metadata); err != nil { + return fmt.Errorf("decode relational audit metadata: %w", err) + } + state.Chain[entry.TenantID] = append(state.Chain[entry.TenantID], entry) + *loaded = true + } + return rows.Err() +} + +func (s *Store) loadRelationalSigning(ctx context.Context, state *app.PersistedState, loaded *bool) error { + keyRows, err := s.pool.Query(ctx, ` + SELECT id, tenant_id, kid, algorithm, status, public_key, + encrypted_private_key, created_at, revoked_at + FROM signing_keys + `) + if err != nil { + return fmt.Errorf("load relational signing keys: %w", err) + } + defer keyRows.Close() + for keyRows.Next() { + var key domain.SigningKey + var private []byte + var revokedAt sql.NullTime + if err := keyRows.Scan(&key.ID, &key.TenantID, &key.KID, &key.Algorithm, &key.Status, &key.PublicKey, &private, &key.CreatedAt, &revokedAt); err != nil { + return fmt.Errorf("scan relational signing key: %w", err) + } + key.RevokedAt = nullableSQLTime(revokedAt) + state.SigningKeys[key.ID] = key + if len(private) != 0 { + state.SigningKeyPrivate[key.ID] = append([]byte(nil), private...) + } + *loaded = true + } + if err := keyRows.Err(); err != nil { + return err + } + + sigRows, err := s.pool.Query(ctx, `SELECT id, tenant_id, subject_type, subject_id, key_id, algorithm, value, created_at FROM signatures`) + if err != nil { + return fmt.Errorf("load relational signatures: %w", err) + } + defer sigRows.Close() + for sigRows.Next() { + var signature domain.Signature + if err := sigRows.Scan(&signature.ID, &signature.TenantID, &signature.SubjectType, &signature.SubjectID, &signature.KeyID, &signature.Algorithm, &signature.Value, &signature.CreatedAt); err != nil { + return fmt.Errorf("scan relational signature: %w", err) + } + state.Signatures[signature.ID] = signature + *loaded = true + } + return sigRows.Err() +} + +func (s *Store) loadRelationalParsedResources(ctx context.Context, state *app.PersistedState, loaded *bool) error { + if err := s.loadRelationalSBOMs(ctx, state, loaded); err != nil { + return err + } + if err := s.loadRelationalScans(ctx, state, loaded); err != nil { + return err + } + if err := s.loadRelationalContracts(ctx, state, loaded); err != nil { + return err + } + if err := s.loadRelationalPolicies(ctx, state, loaded); err != nil { + return err + } + if err := s.loadRelationalBundles(ctx, state, loaded); err != nil { + return err + } + if err := s.loadRelationalVerifications(ctx, state, loaded); err != nil { + return err + } + return nil +} + +func (s *Store) loadRelationalSBOMs(ctx context.Context, state *app.PersistedState, loaded *bool) error { + rows, err := s.pool.Query(ctx, `SELECT id, tenant_id, evidence_id, release_id, artifact_id, format, spec_version, component_count, components, created_at FROM sboms`) + if err != nil { + return fmt.Errorf("load relational sboms: %w", err) + } + defer rows.Close() + for rows.Next() { + var sbom domain.SBOM + var releaseID, artifactID sql.NullString + var components []byte + if err := rows.Scan(&sbom.ID, &sbom.TenantID, &sbom.EvidenceID, &releaseID, &artifactID, &sbom.Format, &sbom.SpecVersion, &sbom.ComponentCount, &components, &sbom.CreatedAt); err != nil { + return fmt.Errorf("scan relational sbom: %w", err) + } + sbom.ReleaseID = nullableSQLString(releaseID) + sbom.ArtifactID = nullableSQLString(artifactID) + if err := decodeJSON(components, &sbom.Components); err != nil { + return fmt.Errorf("decode relational sbom components: %w", err) + } + state.SBOMs[sbom.ID] = sbom + *loaded = true + } + return rows.Err() +} + +func (s *Store) loadRelationalScans(ctx context.Context, state *app.PersistedState, loaded *bool) error { + rows, err := s.pool.Query(ctx, `SELECT id, tenant_id, evidence_id, release_id, scanner, target_ref, summary, findings, created_at FROM vulnerability_scans`) + if err != nil { + return fmt.Errorf("load relational vulnerability scans: %w", err) + } + defer rows.Close() + for rows.Next() { + var scan domain.VulnerabilityScan + var releaseID sql.NullString + var summary, findings []byte + if err := rows.Scan(&scan.ID, &scan.TenantID, &scan.EvidenceID, &releaseID, &scan.Scanner, &scan.TargetRef, &summary, &findings, &scan.CreatedAt); err != nil { + return fmt.Errorf("scan relational vulnerability scan: %w", err) + } + scan.ReleaseID = nullableSQLString(releaseID) + if err := decodeJSON(summary, &scan.Summary); err != nil { + return fmt.Errorf("decode relational vulnerability scan summary: %w", err) + } + if err := decodeJSON(findings, &scan.Findings); err != nil { + return fmt.Errorf("decode relational vulnerability scan findings: %w", err) + } + state.Scans[scan.ID] = scan + *loaded = true + } + return rows.Err() +} + +func (s *Store) loadRelationalContracts(ctx context.Context, state *app.PersistedState, loaded *bool) error { + rows, err := s.pool.Query(ctx, `SELECT id, tenant_id, product_id, release_id, version, hash, path_count, operations, evidence_id, created_at FROM openapi_contracts`) + if err != nil { + return fmt.Errorf("load relational openapi contracts: %w", err) + } + defer rows.Close() + for rows.Next() { + var contract domain.OpenAPIContract + var releaseID sql.NullString + var operations []byte + if err := rows.Scan(&contract.ID, &contract.TenantID, &contract.ProductID, &releaseID, &contract.Version, &contract.Hash, &contract.PathCount, &operations, &contract.EvidenceID, &contract.CreatedAt); err != nil { + return fmt.Errorf("scan relational openapi contract: %w", err) + } + contract.ReleaseID = nullableSQLString(releaseID) + if err := decodeJSON(operations, &contract.Operations); err != nil { + return fmt.Errorf("decode relational openapi operations: %w", err) + } + state.Contracts[contract.ID] = contract + *loaded = true + } + return rows.Err() +} + +func (s *Store) loadRelationalPolicies(ctx context.Context, state *app.PersistedState, loaded *bool) error { + rows, err := s.pool.Query(ctx, `SELECT id, tenant_id, release_id, result, policy_set, checks, created_at FROM policy_evaluations`) + if err != nil { + return fmt.Errorf("load relational policy evaluations: %w", err) + } + defer rows.Close() + for rows.Next() { + var policy domain.PolicyEvaluation + var checks []byte + if err := rows.Scan(&policy.ID, &policy.TenantID, &policy.ReleaseID, &policy.Result, &policy.PolicySet, &checks, &policy.CreatedAt); err != nil { + return fmt.Errorf("scan relational policy evaluation: %w", err) + } + if err := decodeJSON(checks, &policy.Checks); err != nil { + return fmt.Errorf("decode relational policy checks: %w", err) + } + state.Policies[policy.ID] = policy + *loaded = true + } + return rows.Err() +} + +func (s *Store) loadRelationalBundles(ctx context.Context, state *app.PersistedState, loaded *bool) error { + rows, err := s.pool.Query(ctx, `SELECT id, tenant_id, release_id, state, manifest, manifest_hash, signature_refs, created_at, published_at, revoked_at FROM release_bundles`) + if err != nil { + return fmt.Errorf("load relational release bundles: %w", err) + } + defer rows.Close() + for rows.Next() { + var bundle domain.ReleaseBundle + var manifest, signatureRefs []byte + var publishedAt, revokedAt sql.NullTime + if err := rows.Scan(&bundle.ID, &bundle.TenantID, &bundle.ReleaseID, &bundle.State, &manifest, &bundle.ManifestHash, &signatureRefs, &bundle.CreatedAt, &publishedAt, &revokedAt); err != nil { + return fmt.Errorf("scan relational release bundle: %w", err) + } + if err := decodeJSON(manifest, &bundle.Manifest); err != nil { + return fmt.Errorf("decode relational release bundle manifest: %w", err) + } + if err := decodeJSON(signatureRefs, &bundle.SignatureRefs); err != nil { + return fmt.Errorf("decode relational release bundle signature refs: %w", err) + } + bundle.PublishedAt = nullableSQLTime(publishedAt) + bundle.RevokedAt = nullableSQLTime(revokedAt) + state.Bundles[bundle.ID] = bundle + *loaded = true + } + return rows.Err() +} + +func (s *Store) loadRelationalVerifications(ctx context.Context, state *app.PersistedState, loaded *bool) error { + rows, err := s.pool.Query(ctx, `SELECT id, tenant_id, subject_type, subject_id, result, checks, verified_at FROM verification_results`) + if err != nil { + return fmt.Errorf("load relational verification results: %w", err) + } + defer rows.Close() + for rows.Next() { + var verification domain.VerificationResult + var checks []byte + if err := rows.Scan(&verification.ID, &verification.TenantID, &verification.SubjectType, &verification.SubjectID, &verification.Result, &checks, &verification.VerifiedAt); err != nil { + return fmt.Errorf("scan relational verification result: %w", err) + } + if err := decodeJSON(checks, &verification.Checks); err != nil { + return fmt.Errorf("decode relational verification checks: %w", err) + } + state.Verifications[verification.ID] = verification + *loaded = true + } + return rows.Err() +} + +func (s *Store) loadRelationalRiskBuildControls(ctx context.Context, state *app.PersistedState, loaded *bool) error { + if err := s.loadRelationalCollectorsAndBuilds(ctx, state, loaded); err != nil { + return err + } + if err := s.loadRelationalRiskDecisions(ctx, state, loaded); err != nil { + return err + } + if err := s.loadRelationalControls(ctx, state, loaded); err != nil { + return err + } + return nil +} + +func (s *Store) loadRelationalCollectorsAndBuilds(ctx context.Context, state *app.PersistedState, loaded *bool) error { + collectorRows, err := s.pool.Query(ctx, `SELECT id, tenant_id, name, type, version, api_key_id, status, allowed_scopes, last_seen_at, schema_version, created_at FROM collectors`) + if err != nil { + return fmt.Errorf("load relational collectors: %w", err) + } + defer collectorRows.Close() + for collectorRows.Next() { + var collector domain.Collector + var allowedScopes []byte + var lastSeenAt sql.NullTime + if err := collectorRows.Scan(&collector.ID, &collector.TenantID, &collector.Name, &collector.Type, &collector.Version, &collector.APIKeyID, &collector.Status, &allowedScopes, &lastSeenAt, &collector.SchemaVersion, &collector.CreatedAt); err != nil { + return fmt.Errorf("scan relational collector: %w", err) + } + if err := decodeJSON(allowedScopes, &collector.AllowedScopes); err != nil { + return fmt.Errorf("decode relational collector scopes: %w", err) + } + collector.LastSeenAt = nullableSQLTime(lastSeenAt) + state.Collectors[collector.ID] = collector + *loaded = true + } + if err := collectorRows.Err(); err != nil { + return err + } + + buildRows, err := s.pool.Query(ctx, ` + SELECT id, tenant_id, project_id, release_id, collector_id, provider, + commit_sha, repository, workflow_ref, run_id, run_attempt, + job_id, actor, ref, oidc_subject, status, started_at, + finished_at, parameters_hash, environment_hash, source_identity, + outputs, schema_version, created_at + FROM build_runs + `) + if err != nil { + return fmt.Errorf("load relational build runs: %w", err) + } + defer buildRows.Close() + for buildRows.Next() { + var build domain.BuildRun + var collectorID, repository, workflowRef, runID, jobID, actor, ref, oidcSubject, parametersHash, environmentHash sql.NullString + var runAttempt sql.NullInt32 + var finishedAt sql.NullTime + var sourceIdentity, outputs []byte + if err := buildRows.Scan( + &build.ID, &build.TenantID, &build.ProjectID, &build.ReleaseID, &collectorID, &build.Provider, + &build.CommitSHA, &repository, &workflowRef, &runID, &runAttempt, + &jobID, &actor, &ref, &oidcSubject, &build.Status, &build.StartedAt, + &finishedAt, ¶metersHash, &environmentHash, &sourceIdentity, + &outputs, &build.SchemaVersion, &build.CreatedAt, + ); err != nil { + return fmt.Errorf("scan relational build run: %w", err) + } + build.CollectorID = nullableSQLString(collectorID) + build.Repository = nullableSQLString(repository) + build.WorkflowRef = nullableSQLString(workflowRef) + build.RunID = nullableSQLString(runID) + if runAttempt.Valid { + build.RunAttempt = int(runAttempt.Int32) + } + build.JobID = nullableSQLString(jobID) + build.Actor = nullableSQLString(actor) + build.Ref = nullableSQLString(ref) + build.OIDCSubject = nullableSQLString(oidcSubject) + build.FinishedAt = nullableSQLTime(finishedAt) + build.ParametersHash = nullableSQLString(parametersHash) + build.EnvironmentHash = nullableSQLString(environmentHash) + if err := decodeJSON(sourceIdentity, &build.SourceIdentity); err != nil { + return fmt.Errorf("decode relational build source identity: %w", err) + } + if err := decodeJSON(outputs, &build.Outputs); err != nil { + return fmt.Errorf("decode relational build outputs: %w", err) + } + state.BuildRuns[build.ID] = build + *loaded = true + } + if err := buildRows.Err(); err != nil { + return err + } + + attestationRows, err := s.pool.Query(ctx, ` + SELECT id, tenant_id, build_id, evidence_id, payload_ref, payload_hash, + payload_size, payload_type, predicate_type, subject_digests, + builder_id, build_type, materials_count, signature_count, + verification_status, schema_version, created_at + FROM build_attestations + `) + if err != nil { + return fmt.Errorf("load relational build attestations: %w", err) + } + defer attestationRows.Close() + for attestationRows.Next() { + var attestation domain.BuildAttestation + var payloadRef, builderID, buildType sql.NullString + var subjectDigests []byte + if err := attestationRows.Scan( + &attestation.ID, &attestation.TenantID, &attestation.BuildID, &attestation.EvidenceID, &payloadRef, &attestation.PayloadHash, + &attestation.PayloadSize, &attestation.PayloadType, &attestation.PredicateType, &subjectDigests, + &builderID, &buildType, &attestation.MaterialsCount, &attestation.SignatureCount, + &attestation.VerificationStatus, &attestation.SchemaVersion, &attestation.CreatedAt, + ); err != nil { + return fmt.Errorf("scan relational build attestation: %w", err) + } + attestation.PayloadRef = nullableSQLString(payloadRef) + attestation.BuilderID = nullableSQLString(builderID) + attestation.BuildType = nullableSQLString(buildType) + if err := decodeJSON(subjectDigests, &attestation.SubjectDigests); err != nil { + return fmt.Errorf("decode relational attestation subject digests: %w", err) + } + state.BuildAttestations[attestation.ID] = attestation + *loaded = true + } + return attestationRows.Err() +} + +func (s *Store) loadRelationalRiskDecisions(ctx context.Context, state *app.PersistedState, loaded *bool) error { + vexRows, err := s.pool.Query(ctx, `SELECT id, tenant_id, evidence_id, release_id, artifact_id, format, author, version, statement_count, status_summary, schema_version, created_at FROM vex_documents`) + if err != nil { + return fmt.Errorf("load relational vex documents: %w", err) + } + defer vexRows.Close() + for vexRows.Next() { + var document domain.VEXDocument + var releaseID, artifactID, version sql.NullString + var statusSummary []byte + if err := vexRows.Scan(&document.ID, &document.TenantID, &document.EvidenceID, &releaseID, &artifactID, &document.Format, &document.Author, &version, &document.StatementCount, &statusSummary, &document.SchemaVersion, &document.CreatedAt); err != nil { + return fmt.Errorf("scan relational vex document: %w", err) + } + document.ReleaseID = nullableSQLString(releaseID) + document.ArtifactID = nullableSQLString(artifactID) + document.Version = nullableSQLString(version) + if err := decodeJSON(statusSummary, &document.StatusSummary); err != nil { + return fmt.Errorf("decode relational vex summary: %w", err) + } + state.VEXDocuments[document.ID] = document + *loaded = true + } + if err := vexRows.Err(); err != nil { + return err + } + + reportRows, err := s.pool.Query(ctx, ` + SELECT id, tenant_id, vex_document_id, evidence_id, release_id, artifact_id, + parser_version, status, statement_count, decisions_created, + decisions_superseded, unsupported_fields, warnings, invalid_statements, + mapping_failures, failure_code, failure_detail, schema_version, created_at, updated_at + FROM vex_import_reports + `) + if err != nil { + return fmt.Errorf("load relational vex import reports: %w", err) + } + defer reportRows.Close() + for reportRows.Next() { + var report domain.VEXImportReport + var releaseID, artifactID sql.NullString + var warnings, invalidStatements, mappingFailures []byte + if err := reportRows.Scan( + &report.ID, &report.TenantID, &report.VEXDocumentID, &report.EvidenceID, &releaseID, &artifactID, + &report.ParserVersion, &report.Status, &report.StatementCount, &report.DecisionsCreated, + &report.DecisionsSuperseded, &report.UnsupportedFields, &warnings, &invalidStatements, + &mappingFailures, &report.FailureCode, &report.FailureDetail, &report.SchemaVersion, &report.CreatedAt, &report.UpdatedAt, + ); err != nil { + return fmt.Errorf("scan relational vex import report: %w", err) + } + report.ReleaseID = nullableSQLString(releaseID) + report.ArtifactID = nullableSQLString(artifactID) + if err := decodeJSON(warnings, &report.Warnings); err != nil { + return fmt.Errorf("decode relational vex import warnings: %w", err) + } + if err := decodeJSON(invalidStatements, &report.InvalidStatements); err != nil { + return fmt.Errorf("decode relational vex import invalid statements: %w", err) + } + if err := decodeJSON(mappingFailures, &report.MappingFailures); err != nil { + return fmt.Errorf("decode relational vex import mapping failures: %w", err) + } + state.VEXImportReports[report.ID] = report + *loaded = true + } + if err := reportRows.Err(); err != nil { + return err + } + + decisionRows, err := s.pool.Query(ctx, ` + SELECT id, tenant_id, finding_id, scan_id, release_id, vulnerability, + component, sbom_id, sbom_component_purl, sbom_component_name, + status, justification, impact_statement, action_statement, + customer_visible, internal_notes, source, evidence_id, evidence_ids, supporting_refs, vex_document_id, + supersedes, superseded_by, approved_by, reviewed_at, review_due_at, schema_version, created_at + FROM vulnerability_decisions + `) + if err != nil { + return fmt.Errorf("load relational vulnerability decisions: %w", err) + } + defer decisionRows.Close() + for decisionRows.Next() { + var decision domain.VulnerabilityDecision + var releaseID, component, sbomID, sbomComponentPURL, sbomComponentName, impactStatement, actionStatement, internalNotes, evidenceID, vexDocumentID, supersedes, supersededBy, approvedBy sql.NullString + var reviewedAt, reviewDueAt sql.NullTime + var supportingRefs []byte + if err := decisionRows.Scan( + &decision.ID, &decision.TenantID, &decision.FindingID, &decision.ScanID, &releaseID, &decision.Vulnerability, + &component, &sbomID, &sbomComponentPURL, &sbomComponentName, &decision.Status, &decision.Justification, &impactStatement, &actionStatement, + &decision.CustomerVisible, &internalNotes, &decision.Source, &evidenceID, &decision.EvidenceIDs, &supportingRefs, &vexDocumentID, &supersedes, &supersededBy, + &approvedBy, &reviewedAt, &reviewDueAt, &decision.SchemaVersion, &decision.CreatedAt, + ); err != nil { + return fmt.Errorf("scan relational vulnerability decision: %w", err) + } + decision.ReleaseID = nullableSQLString(releaseID) + decision.Component = nullableSQLString(component) + decision.SBOMID = nullableSQLString(sbomID) + decision.SBOMComponentPURL = nullableSQLString(sbomComponentPURL) + decision.SBOMComponentName = nullableSQLString(sbomComponentName) + decision.ImpactStatement = nullableSQLString(impactStatement) + decision.ActionStatement = nullableSQLString(actionStatement) + decision.InternalNotes = nullableSQLString(internalNotes) + decision.EvidenceID = nullableSQLString(evidenceID) + if err := decodeJSON(supportingRefs, &decision.SupportingRefs); err != nil { + return fmt.Errorf("decode relational vulnerability decision supporting refs: %w", err) + } + decision.VEXDocumentID = nullableSQLString(vexDocumentID) + decision.Supersedes = nullableSQLString(supersedes) + decision.SupersededBy = nullableSQLString(supersededBy) + decision.ApprovedBy = nullableSQLString(approvedBy) + decision.ReviewedAt = nullableSQLTime(reviewedAt) + decision.ReviewDueAt = nullableSQLTime(reviewDueAt) + state.Decisions[decision.ID] = decision + *loaded = true + } + if err := decisionRows.Err(); err != nil { + return err + } + + exceptionRows, err := s.pool.Query(ctx, `SELECT id, tenant_id, release_id, finding_id, control_id, reason, owner, expires_at, approved, approved_by, approved_at, created_at FROM exceptions`) + if err != nil { + return fmt.Errorf("load relational exceptions: %w", err) + } + defer exceptionRows.Close() + for exceptionRows.Next() { + var exception domain.Exception + var findingID, controlID, approvedBy sql.NullString + var approvedAt sql.NullTime + if err := exceptionRows.Scan(&exception.ID, &exception.TenantID, &exception.ReleaseID, &findingID, &controlID, &exception.Reason, &exception.Owner, &exception.ExpiresAt, &exception.Approved, &approvedBy, &approvedAt, &exception.CreatedAt); err != nil { + return fmt.Errorf("scan relational exception: %w", err) + } + exception.FindingID = nullableSQLString(findingID) + exception.ControlID = nullableSQLString(controlID) + exception.ApprovedBy = nullableSQLString(approvedBy) + exception.ApprovedAt = nullableSQLTime(approvedAt) + state.Exceptions[exception.ID] = exception + *loaded = true + } + return exceptionRows.Err() +} + +func (s *Store) loadRelationalControls(ctx context.Context, state *app.PersistedState, loaded *bool) error { + frameworkRows, err := s.pool.Query(ctx, `SELECT id, tenant_id, name, slug, version, description, status, schema_version, created_at FROM control_frameworks`) + if err != nil { + return fmt.Errorf("load relational control frameworks: %w", err) + } + defer frameworkRows.Close() + for frameworkRows.Next() { + var framework domain.ControlFramework + var description sql.NullString + if err := frameworkRows.Scan(&framework.ID, &framework.TenantID, &framework.Name, &framework.Slug, &framework.Version, &description, &framework.Status, &framework.SchemaVersion, &framework.CreatedAt); err != nil { + return fmt.Errorf("scan relational control framework: %w", err) + } + framework.Description = nullableSQLString(description) + state.ControlFrameworks[framework.ID] = framework + *loaded = true + } + if err := frameworkRows.Err(); err != nil { + return err + } + + controlRows, err := s.pool.Query(ctx, `SELECT id, tenant_id, framework_id, code, title, objective, evidence_requirements, applicability, limitations, schema_version, created_at FROM security_controls`) + if err != nil { + return fmt.Errorf("load relational security controls: %w", err) + } + defer controlRows.Close() + for controlRows.Next() { + var control domain.SecurityControl + var requirements, applicability, limitations []byte + if err := controlRows.Scan(&control.ID, &control.TenantID, &control.FrameworkID, &control.Code, &control.Title, &control.Objective, &requirements, &applicability, &limitations, &control.SchemaVersion, &control.CreatedAt); err != nil { + return fmt.Errorf("scan relational security control: %w", err) + } + if err := decodeJSON(requirements, &control.EvidenceRequirements); err != nil { + return fmt.Errorf("decode relational control requirements: %w", err) + } + if err := decodeJSON(applicability, &control.Applicability); err != nil { + return fmt.Errorf("decode relational control applicability: %w", err) + } + if err := decodeJSON(limitations, &control.Limitations); err != nil { + return fmt.Errorf("decode relational control limitations: %w", err) + } + state.SecurityControls[control.ID] = control + *loaded = true + } + if err := controlRows.Err(); err != nil { + return err + } + + evidenceRows, err := s.pool.Query(ctx, `SELECT id, tenant_id, control_id, evidence_type, subject_type, subject_id, product_id, release_id, confidence, notes, schema_version, created_at FROM control_evidence`) + if err != nil { + return fmt.Errorf("load relational control evidence: %w", err) + } + defer evidenceRows.Close() + for evidenceRows.Next() { + var evidence domain.ControlEvidence + var productID, releaseID, notes sql.NullString + if err := evidenceRows.Scan(&evidence.ID, &evidence.TenantID, &evidence.ControlID, &evidence.EvidenceType, &evidence.SubjectType, &evidence.SubjectID, &productID, &releaseID, &evidence.Confidence, ¬es, &evidence.SchemaVersion, &evidence.CreatedAt); err != nil { + return fmt.Errorf("scan relational control evidence: %w", err) + } + evidence.ProductID = nullableSQLString(productID) + evidence.ReleaseID = nullableSQLString(releaseID) + evidence.Notes = nullableSQLString(notes) + state.ControlEvidence[evidence.ID] = evidence + *loaded = true + } + return evidenceRows.Err() +} + +func (s *Store) loadRelationalSourceDeploymentLifecycle(ctx context.Context, state *app.PersistedState, loaded *bool) error { + if err := s.loadRelationalLifecycleAndCandidates(ctx, state, loaded); err != nil { + return err + } + if err := s.loadRelationalContainerAndSignatures(ctx, state, loaded); err != nil { + return err + } + if err := s.loadRelationalSource(ctx, state, loaded); err != nil { + return err + } + if err := s.loadRelationalDeployments(ctx, state, loaded); err != nil { + return err + } + return nil +} + +func (s *Store) loadRelationalLifecycleAndCandidates(ctx context.Context, state *app.PersistedState, loaded *bool) error { + lifecycleRows, err := s.pool.Query(ctx, `SELECT id, tenant_id, evidence_id, action, reason, details, replacement_id, actor_id, schema_version, created_at FROM evidence_lifecycle_events`) + if err != nil { + return fmt.Errorf("load relational evidence lifecycle: %w", err) + } + defer lifecycleRows.Close() + for lifecycleRows.Next() { + var event domain.EvidenceLifecycleEvent + var details []byte + var replacementID sql.NullString + if err := lifecycleRows.Scan(&event.ID, &event.TenantID, &event.EvidenceID, &event.Action, &event.Reason, &details, &replacementID, &event.ActorID, &event.SchemaVersion, &event.CreatedAt); err != nil { + return fmt.Errorf("scan relational evidence lifecycle: %w", err) + } + event.ReplacementID = nullableSQLString(replacementID) + if err := decodeJSON(details, &event.Details); err != nil { + return fmt.Errorf("decode relational evidence lifecycle details: %w", err) + } + state.EvidenceLifecycle[event.ID] = event + *loaded = true + } + if err := lifecycleRows.Err(); err != nil { + return err + } + + candidateRows, err := s.pool.Query(ctx, `SELECT id, tenant_id, release_id, name, state, snapshot_hash, document, schema_version, created_at, promoted_at, rejected_at FROM release_candidates`) + if err != nil { + return fmt.Errorf("load relational release candidates: %w", err) + } + defer candidateRows.Close() + for candidateRows.Next() { + var candidate domain.ReleaseCandidate + var document []byte + var promotedAt, rejectedAt sql.NullTime + if err := candidateRows.Scan(&candidate.ID, &candidate.TenantID, &candidate.ReleaseID, &candidate.Name, &candidate.State, &candidate.SnapshotHash, &document, &candidate.SchemaVersion, &candidate.CreatedAt, &promotedAt, &rejectedAt); err != nil { + return fmt.Errorf("scan relational release candidate: %w", err) + } + var embedded domain.ReleaseCandidate + if err := decodeJSON(document, &embedded); err != nil { + return fmt.Errorf("decode relational release candidate document: %w", err) + } + candidate.BuildIDs = embedded.BuildIDs + candidate.ArtifactIDs = embedded.ArtifactIDs + candidate.SBOMIDs = embedded.SBOMIDs + candidate.ScanIDs = embedded.ScanIDs + candidate.VEXIDs = embedded.VEXIDs + candidate.ContractIDs = embedded.ContractIDs + candidate.BundleIDs = embedded.BundleIDs + candidate.PromotedAt = nullableSQLTime(promotedAt) + candidate.RejectedAt = nullableSQLTime(rejectedAt) + state.ReleaseCandidates[candidate.ID] = candidate + *loaded = true + } + return candidateRows.Err() +} + +func (s *Store) loadRelationalContainerAndSignatures(ctx context.Context, state *app.PersistedState, loaded *bool) error { + imageRows, err := s.pool.Query(ctx, `SELECT id, tenant_id, artifact_id, repository, tag, digest, platform, schema_version, created_at FROM container_images`) + if err != nil { + return fmt.Errorf("load relational container images: %w", err) + } + defer imageRows.Close() + for imageRows.Next() { + var image domain.ContainerImage + var artifactID, tag, platform sql.NullString + if err := imageRows.Scan(&image.ID, &image.TenantID, &artifactID, &image.Repository, &tag, &image.Digest, &platform, &image.SchemaVersion, &image.CreatedAt); err != nil { + return fmt.Errorf("scan relational container image: %w", err) + } + image.ArtifactID = nullableSQLString(artifactID) + image.Tag = nullableSQLString(tag) + image.Platform = nullableSQLString(platform) + state.ContainerImages[image.ID] = image + *loaded = true + } + if err := imageRows.Err(); err != nil { + return err + } + + signatureRows, err := s.pool.Query(ctx, `SELECT id, tenant_id, artifact_id, subject_digest, algorithm, key_id, signature, payload_ref, payload_hash, verification_status, schema_version, created_at FROM artifact_signatures`) + if err != nil { + return fmt.Errorf("load relational artifact signatures: %w", err) + } + defer signatureRows.Close() + for signatureRows.Next() { + var signature domain.ArtifactSignature + var keyID, payloadRef, payloadHash sql.NullString + if err := signatureRows.Scan(&signature.ID, &signature.TenantID, &signature.ArtifactID, &signature.SubjectDigest, &signature.Algorithm, &keyID, &signature.Signature, &payloadRef, &payloadHash, &signature.VerificationStatus, &signature.SchemaVersion, &signature.CreatedAt); err != nil { + return fmt.Errorf("scan relational artifact signature: %w", err) + } + signature.KeyID = nullableSQLString(keyID) + signature.PayloadRef = nullableSQLString(payloadRef) + signature.PayloadHash = nullableSQLString(payloadHash) + state.ArtifactSignatures[signature.ID] = signature + *loaded = true + } + return signatureRows.Err() +} + +func (s *Store) loadRelationalSource(ctx context.Context, state *app.PersistedState, loaded *bool) error { + repositoryRows, err := s.pool.Query(ctx, `SELECT id, tenant_id, project_id, provider, full_name, clone_url, default_branch, schema_version, created_at FROM source_repositories`) + if err != nil { + return fmt.Errorf("load relational source repositories: %w", err) + } + defer repositoryRows.Close() + for repositoryRows.Next() { + var repository domain.SourceRepository + var projectID, cloneURL, defaultBranch sql.NullString + if err := repositoryRows.Scan(&repository.ID, &repository.TenantID, &projectID, &repository.Provider, &repository.FullName, &cloneURL, &defaultBranch, &repository.SchemaVersion, &repository.CreatedAt); err != nil { + return fmt.Errorf("scan relational source repository: %w", err) + } + repository.ProjectID = nullableSQLString(projectID) + repository.CloneURL = nullableSQLString(cloneURL) + repository.DefaultBranch = nullableSQLString(defaultBranch) + state.Repositories[repository.ID] = repository + *loaded = true + } + if err := repositoryRows.Err(); err != nil { + return err + } + + commitRows, err := s.pool.Query(ctx, `SELECT id, tenant_id, repository_id, sha, author, message_hash, committed_at, schema_version, created_at FROM source_commits`) + if err != nil { + return fmt.Errorf("load relational source commits: %w", err) + } + defer commitRows.Close() + for commitRows.Next() { + var commit domain.SourceCommit + var author, messageHash sql.NullString + if err := commitRows.Scan(&commit.ID, &commit.TenantID, &commit.RepositoryID, &commit.SHA, &author, &messageHash, &commit.CommittedAt, &commit.SchemaVersion, &commit.CreatedAt); err != nil { + return fmt.Errorf("scan relational source commit: %w", err) + } + commit.Author = nullableSQLString(author) + commit.MessageHash = nullableSQLString(messageHash) + state.Commits[commit.ID] = commit + *loaded = true + } + if err := commitRows.Err(); err != nil { + return err + } + + branchRows, err := s.pool.Query(ctx, `SELECT id, tenant_id, repository_id, name, head_commit_id, protected, protection_hash, schema_version, created_at FROM source_branches`) + if err != nil { + return fmt.Errorf("load relational source branches: %w", err) + } + defer branchRows.Close() + for branchRows.Next() { + var branch domain.SourceBranch + var headCommitID, protectionHash sql.NullString + if err := branchRows.Scan(&branch.ID, &branch.TenantID, &branch.RepositoryID, &branch.Name, &headCommitID, &branch.Protected, &protectionHash, &branch.SchemaVersion, &branch.CreatedAt); err != nil { + return fmt.Errorf("scan relational source branch: %w", err) + } + branch.HeadCommitID = nullableSQLString(headCommitID) + branch.ProtectionHash = nullableSQLString(protectionHash) + state.Branches[branch.ID] = branch + *loaded = true + } + if err := branchRows.Err(); err != nil { + return err + } + + prRows, err := s.pool.Query(ctx, `SELECT id, tenant_id, repository_id, provider, provider_id, title, state, source_branch, target_branch, head_commit_id, review_decision, schema_version, created_at FROM pull_requests`) + if err != nil { + return fmt.Errorf("load relational pull requests: %w", err) + } + defer prRows.Close() + for prRows.Next() { + var pr domain.PullRequest + var sourceBranch, targetBranch, headCommitID, reviewDecision sql.NullString + if err := prRows.Scan(&pr.ID, &pr.TenantID, &pr.RepositoryID, &pr.Provider, &pr.ProviderID, &pr.Title, &pr.State, &sourceBranch, &targetBranch, &headCommitID, &reviewDecision, &pr.SchemaVersion, &pr.CreatedAt); err != nil { + return fmt.Errorf("scan relational pull request: %w", err) + } + pr.SourceBranch = nullableSQLString(sourceBranch) + pr.TargetBranch = nullableSQLString(targetBranch) + pr.HeadCommitID = nullableSQLString(headCommitID) + pr.ReviewDecision = nullableSQLString(reviewDecision) + state.PullRequests[pr.ID] = pr + *loaded = true + } + return prRows.Err() +} + +func (s *Store) loadRelationalDeployments(ctx context.Context, state *app.PersistedState, loaded *bool) error { + environmentRows, err := s.pool.Query(ctx, `SELECT id, tenant_id, product_id, name, kind, schema_version, created_at FROM deployment_environments`) + if err != nil { + return fmt.Errorf("load relational deployment environments: %w", err) + } + defer environmentRows.Close() + for environmentRows.Next() { + var environment domain.DeploymentEnvironment + if err := environmentRows.Scan(&environment.ID, &environment.TenantID, &environment.ProductID, &environment.Name, &environment.Kind, &environment.SchemaVersion, &environment.CreatedAt); err != nil { + return fmt.Errorf("scan relational deployment environment: %w", err) + } + state.Environments[environment.ID] = environment + *loaded = true + } + if err := environmentRows.Err(); err != nil { + return err + } + + deploymentRows, err := s.pool.Query(ctx, `SELECT id, tenant_id, environment_id, release_id, artifact_ids, status, started_at, finished_at, rollback_of, evidence_id, schema_version, created_at FROM deployment_events`) + if err != nil { + return fmt.Errorf("load relational deployment events: %w", err) + } + defer deploymentRows.Close() + for deploymentRows.Next() { + var deployment domain.DeploymentEvent + var finishedAt sql.NullTime + var rollbackOf, evidenceID sql.NullString + if err := deploymentRows.Scan(&deployment.ID, &deployment.TenantID, &deployment.EnvironmentID, &deployment.ReleaseID, &deployment.ArtifactIDs, &deployment.Status, &deployment.StartedAt, &finishedAt, &rollbackOf, &evidenceID, &deployment.SchemaVersion, &deployment.CreatedAt); err != nil { + return fmt.Errorf("scan relational deployment event: %w", err) + } + deployment.FinishedAt = nullableSQLTime(finishedAt) + deployment.RollbackOf = nullableSQLString(rollbackOf) + deployment.EvidenceID = nullableSQLString(evidenceID) + state.Deployments[deployment.ID] = deployment + *loaded = true + } + return deploymentRows.Err() +} + +func (s *Store) loadRelationalIncidentSecurityGovernance(ctx context.Context, state *app.PersistedState, loaded *bool) error { + if err := s.loadRelationalIncidents(ctx, state, loaded); err != nil { + return err + } + if err := s.loadRelationalSecurityEvidence(ctx, state, loaded); err != nil { + return err + } + if err := s.loadRelationalDiffsAndPolicies(ctx, state, loaded); err != nil { + return err + } + if err := s.loadRelationalWaiversApprovalsTrust(ctx, state, loaded); err != nil { + return err + } + return nil +} + +func (s *Store) loadRelationalIncidents(ctx context.Context, state *app.PersistedState, loaded *bool) error { + incidentRows, err := s.pool.Query(ctx, `SELECT id, tenant_id, product_id, release_id, title, severity, status, opened_at, closed_at, schema_version, created_at FROM incidents`) + if err != nil { + return fmt.Errorf("load relational incidents: %w", err) + } + defer incidentRows.Close() + for incidentRows.Next() { + var incident domain.Incident + var releaseID sql.NullString + var closedAt sql.NullTime + if err := incidentRows.Scan(&incident.ID, &incident.TenantID, &incident.ProductID, &releaseID, &incident.Title, &incident.Severity, &incident.Status, &incident.OpenedAt, &closedAt, &incident.SchemaVersion, &incident.CreatedAt); err != nil { + return fmt.Errorf("scan relational incident: %w", err) + } + incident.ReleaseID = nullableSQLString(releaseID) + incident.ClosedAt = nullableSQLTime(closedAt) + state.Incidents[incident.ID] = incident + *loaded = true + } + if err := incidentRows.Err(); err != nil { + return err + } + + timelineRows, err := s.pool.Query(ctx, `SELECT id, tenant_id, incident_id, event_type, summary, evidence_id, occurred_at, schema_version, created_at FROM incident_timeline_events`) + if err != nil { + return fmt.Errorf("load relational incident timeline: %w", err) + } + defer timelineRows.Close() + for timelineRows.Next() { + var event domain.IncidentTimelineEvent + var evidenceID sql.NullString + if err := timelineRows.Scan(&event.ID, &event.TenantID, &event.IncidentID, &event.EventType, &event.Summary, &evidenceID, &event.OccurredAt, &event.SchemaVersion, &event.CreatedAt); err != nil { + return fmt.Errorf("scan relational incident timeline: %w", err) + } + event.EvidenceID = nullableSQLString(evidenceID) + state.TimelineEvents[event.ID] = event + *loaded = true + } + if err := timelineRows.Err(); err != nil { + return err + } + + receiverRows, err := s.pool.Query(ctx, `SELECT id, tenant_id, incident_id, name, provider, public_key, status, schema_version, created_at FROM incident_webhook_receivers`) + if err != nil { + return fmt.Errorf("load relational incident webhook receivers: %w", err) + } + defer receiverRows.Close() + for receiverRows.Next() { + var receiver domain.IncidentWebhookReceiver + if err := receiverRows.Scan(&receiver.ID, &receiver.TenantID, &receiver.IncidentID, &receiver.Name, &receiver.Provider, &receiver.PublicKey, &receiver.Status, &receiver.SchemaVersion, &receiver.CreatedAt); err != nil { + return fmt.Errorf("scan relational incident webhook receiver: %w", err) + } + state.IncidentWebhookReceivers[receiver.ID] = receiver + *loaded = true + } + if err := receiverRows.Err(); err != nil { + return err + } + + webhookRows, err := s.pool.Query(ctx, `SELECT id, tenant_id, receiver_id, incident_id, provider, event_id, payload_hash, signature_hash, timeline_event_id, result, schema_version, created_at FROM incident_webhook_events`) + if err != nil { + return fmt.Errorf("load relational incident webhook events: %w", err) + } + defer webhookRows.Close() + for webhookRows.Next() { + var event domain.IncidentWebhookEvent + var timelineEventID sql.NullString + if err := webhookRows.Scan(&event.ID, &event.TenantID, &event.ReceiverID, &event.IncidentID, &event.Provider, &event.EventID, &event.PayloadHash, &event.SignatureHash, &timelineEventID, &event.Result, &event.SchemaVersion, &event.CreatedAt); err != nil { + return fmt.Errorf("scan relational incident webhook event: %w", err) + } + event.TimelineEventID = nullableSQLString(timelineEventID) + state.IncidentWebhookEvents[event.ID] = event + *loaded = true + } + if err := webhookRows.Err(); err != nil { + return err + } + + taskRows, err := s.pool.Query(ctx, `SELECT id, tenant_id, incident_id, release_id, title, owner, status, due_at, evidence_id, schema_version, created_at FROM remediation_tasks`) + if err != nil { + return fmt.Errorf("load relational remediation tasks: %w", err) + } + defer taskRows.Close() + for taskRows.Next() { + var task domain.RemediationTask + var incidentID, releaseID, evidenceID sql.NullString + var dueAt sql.NullTime + if err := taskRows.Scan(&task.ID, &task.TenantID, &incidentID, &releaseID, &task.Title, &task.Owner, &task.Status, &dueAt, &evidenceID, &task.SchemaVersion, &task.CreatedAt); err != nil { + return fmt.Errorf("scan relational remediation task: %w", err) + } + task.IncidentID = nullableSQLString(incidentID) + task.ReleaseID = nullableSQLString(releaseID) + task.DueAt = nullableSQLTime(dueAt) + task.EvidenceID = nullableSQLString(evidenceID) + state.RemediationTasks[task.ID] = task + *loaded = true + } + return taskRows.Err() +} + +func (s *Store) loadRelationalSecurityEvidence(ctx context.Context, state *app.PersistedState, loaded *bool) error { + scanRows, err := s.pool.Query(ctx, `SELECT id, tenant_id, product_id, release_id, artifact_id, category, format, scanner, target_ref, evidence_id, payload_ref, payload_hash, finding_count, summary, redacted, quarantined, schema_version, created_at FROM security_scans`) + if err != nil { + return fmt.Errorf("load relational security scans: %w", err) + } + defer scanRows.Close() + for scanRows.Next() { + var scan domain.SecurityScan + var productID, releaseID, artifactID, payloadRef sql.NullString + var summary []byte + if err := scanRows.Scan(&scan.ID, &scan.TenantID, &productID, &releaseID, &artifactID, &scan.Category, &scan.Format, &scan.Scanner, &scan.TargetRef, &scan.EvidenceID, &payloadRef, &scan.PayloadHash, &scan.FindingCount, &summary, &scan.Redacted, &scan.Quarantined, &scan.SchemaVersion, &scan.CreatedAt); err != nil { + return fmt.Errorf("scan relational security scan: %w", err) + } + scan.ProductID = nullableSQLString(productID) + scan.ReleaseID = nullableSQLString(releaseID) + scan.ArtifactID = nullableSQLString(artifactID) + scan.PayloadRef = nullableSQLString(payloadRef) + if err := decodeJSON(summary, &scan.Summary); err != nil { + return fmt.Errorf("decode relational security scan summary: %w", err) + } + state.SecurityScans[scan.ID] = scan + *loaded = true + } + if err := scanRows.Err(); err != nil { + return err + } + + docRows, err := s.pool.Query(ctx, `SELECT id, tenant_id, product_id, release_id, document_type, title, sensitivity, evidence_id, payload_ref, payload_hash, schema_version, created_at FROM manual_security_documents`) + if err != nil { + return fmt.Errorf("load relational manual security documents: %w", err) + } + defer docRows.Close() + for docRows.Next() { + var document domain.ManualSecurityDocument + var productID, releaseID, payloadRef sql.NullString + if err := docRows.Scan(&document.ID, &document.TenantID, &productID, &releaseID, &document.DocumentType, &document.Title, &document.Sensitivity, &document.EvidenceID, &payloadRef, &document.PayloadHash, &document.SchemaVersion, &document.CreatedAt); err != nil { + return fmt.Errorf("scan relational manual security document: %w", err) + } + document.ProductID = nullableSQLString(productID) + document.ReleaseID = nullableSQLString(releaseID) + document.PayloadRef = nullableSQLString(payloadRef) + state.ManualSecurityDocs[document.ID] = document + *loaded = true + } + return docRows.Err() +} + +func (s *Store) loadRelationalDiffsAndPolicies(ctx context.Context, state *app.PersistedState, loaded *bool) error { + sbomRows, err := s.pool.Query(ctx, `SELECT id, tenant_id, base_sbom_id, target_sbom_id, release_id, document, schema_version, created_at FROM sbom_diffs`) + if err != nil { + return fmt.Errorf("load relational sbom diffs: %w", err) + } + defer sbomRows.Close() + for sbomRows.Next() { + var diff domain.SBOMDiff + var releaseID sql.NullString + var document []byte + if err := sbomRows.Scan(&diff.ID, &diff.TenantID, &diff.BaseSBOMID, &diff.TargetSBOMID, &releaseID, &document, &diff.SchemaVersion, &diff.CreatedAt); err != nil { + return fmt.Errorf("scan relational sbom diff: %w", err) + } + diff.ReleaseID = nullableSQLString(releaseID) + var embedded domain.SBOMDiff + if err := decodeJSON(document, &embedded); err != nil { + return fmt.Errorf("decode relational sbom diff document: %w", err) + } + diff.AddedComponents = embedded.AddedComponents + diff.RemovedComponents = embedded.RemovedComponents + diff.UnchangedCount = embedded.UnchangedCount + diff.DependencyChanges = embedded.DependencyChanges + state.SBOMDiffs[diff.ID] = diff + *loaded = true + } + if err := sbomRows.Err(); err != nil { + return err + } + + dependencyRows, err := s.pool.Query(ctx, `SELECT id, tenant_id, sbom_diff_id, change_type, component, schema_version, created_at FROM dependency_changes`) + if err != nil { + return fmt.Errorf("load relational dependency changes: %w", err) + } + defer dependencyRows.Close() + for dependencyRows.Next() { + var change domain.DependencyChange + var component []byte + if err := dependencyRows.Scan(&change.ID, &change.TenantID, &change.SBOMDiffID, &change.ChangeType, &component, &change.SchemaVersion, &change.CreatedAt); err != nil { + return fmt.Errorf("scan relational dependency change: %w", err) + } + if err := decodeJSON(component, &change.Component); err != nil { + return fmt.Errorf("decode relational dependency component: %w", err) + } + state.DependencyChanges[change.ID] = change + *loaded = true + } + if err := dependencyRows.Err(); err != nil { + return err + } + + workflowRows, err := s.pool.Query(ctx, `SELECT id, tenant_id, finding_id, release_id, action, reason, actor_id, schema_version, created_at FROM vulnerability_workflow_records`) + if err != nil { + return fmt.Errorf("load relational vulnerability workflow: %w", err) + } + defer workflowRows.Close() + for workflowRows.Next() { + var record domain.VulnerabilityWorkflowRecord + var releaseID sql.NullString + if err := workflowRows.Scan(&record.ID, &record.TenantID, &record.FindingID, &releaseID, &record.Action, &record.Reason, &record.ActorID, &record.SchemaVersion, &record.CreatedAt); err != nil { + return fmt.Errorf("scan relational vulnerability workflow: %w", err) + } + record.ReleaseID = nullableSQLString(releaseID) + state.VulnerabilityWorkflow[record.ID] = record + *loaded = true + } + if err := workflowRows.Err(); err != nil { + return err + } + + contractRows, err := s.pool.Query(ctx, `SELECT id, tenant_id, base_contract_id, target_contract_id, product_id, release_id, result, document, schema_version, created_at FROM contract_diffs`) + if err != nil { + return fmt.Errorf("load relational contract diffs: %w", err) + } + defer contractRows.Close() + for contractRows.Next() { + var diff domain.ContractDiff + var releaseID sql.NullString + var document []byte + if err := contractRows.Scan(&diff.ID, &diff.TenantID, &diff.BaseContractID, &diff.TargetContractID, &diff.ProductID, &releaseID, &diff.Result, &document, &diff.SchemaVersion, &diff.CreatedAt); err != nil { + return fmt.Errorf("scan relational contract diff: %w", err) + } + diff.ReleaseID = nullableSQLString(releaseID) + var embedded domain.ContractDiff + if err := decodeJSON(document, &embedded); err != nil { + return fmt.Errorf("decode relational contract diff document: %w", err) + } + diff.BreakingChanges = embedded.BreakingChanges + diff.NonBreakingChanges = embedded.NonBreakingChanges + state.ContractDiffs[diff.ID] = diff + *loaded = true + } + if err := contractRows.Err(); err != nil { + return err + } + + policyRows, err := s.pool.Query(ctx, `SELECT id, tenant_id, name, version, description, rules, schema_version, created_at FROM custom_policies`) + if err != nil { + return fmt.Errorf("load relational custom policies: %w", err) + } + defer policyRows.Close() + for policyRows.Next() { + var policy domain.CustomPolicy + var description sql.NullString + var rules []byte + if err := policyRows.Scan(&policy.ID, &policy.TenantID, &policy.Name, &policy.Version, &description, &rules, &policy.SchemaVersion, &policy.CreatedAt); err != nil { + return fmt.Errorf("scan relational custom policy: %w", err) + } + policy.Description = nullableSQLString(description) + if err := decodeJSON(rules, &policy.Rules); err != nil { + return fmt.Errorf("decode relational custom policy rules: %w", err) + } + state.CustomPolicies[policy.ID] = policy + *loaded = true + } + if err := policyRows.Err(); err != nil { + return err + } + + evalRows, err := s.pool.Query(ctx, `SELECT id, tenant_id, policy_id, release_id, result, checks, input_hash, schema_version, created_at FROM custom_policy_evaluations`) + if err != nil { + return fmt.Errorf("load relational custom policy evaluations: %w", err) + } + defer evalRows.Close() + for evalRows.Next() { + var evaluation domain.CustomPolicyEvaluation + var checks []byte + if err := evalRows.Scan(&evaluation.ID, &evaluation.TenantID, &evaluation.PolicyID, &evaluation.ReleaseID, &evaluation.Result, &checks, &evaluation.InputHash, &evaluation.SchemaVersion, &evaluation.CreatedAt); err != nil { + return fmt.Errorf("scan relational custom policy evaluation: %w", err) + } + if err := decodeJSON(checks, &evaluation.Checks); err != nil { + return fmt.Errorf("decode relational custom policy checks: %w", err) + } + state.CustomPolicyEvaluations[evaluation.ID] = evaluation + *loaded = true + } + return evalRows.Err() +} + +func (s *Store) loadRelationalWaiversApprovalsTrust(ctx context.Context, state *app.PersistedState, loaded *bool) error { + waiverRows, err := s.pool.Query(ctx, `SELECT id, tenant_id, scope_type, scope_id, control_id, policy_id, owner, risk, reason, expires_at, approved, approved_by, approved_at, supersedes, superseded_by, schema_version, created_at FROM waivers`) + if err != nil { + return fmt.Errorf("load relational waivers: %w", err) + } + defer waiverRows.Close() + for waiverRows.Next() { + var waiver domain.Waiver + var controlID, policyID, approvedBy, supersedes, supersededBy sql.NullString + var approvedAt sql.NullTime + if err := waiverRows.Scan(&waiver.ID, &waiver.TenantID, &waiver.ScopeType, &waiver.ScopeID, &controlID, &policyID, &waiver.Owner, &waiver.Risk, &waiver.Reason, &waiver.ExpiresAt, &waiver.Approved, &approvedBy, &approvedAt, &supersedes, &supersededBy, &waiver.SchemaVersion, &waiver.CreatedAt); err != nil { + return fmt.Errorf("scan relational waiver: %w", err) + } + waiver.ControlID = nullableSQLString(controlID) + waiver.PolicyID = nullableSQLString(policyID) + waiver.ApprovedBy = nullableSQLString(approvedBy) + waiver.ApprovedAt = nullableSQLTime(approvedAt) + waiver.Supersedes = nullableSQLString(supersedes) + waiver.SupersededBy = nullableSQLString(supersededBy) + state.Waivers[waiver.ID] = waiver + *loaded = true + } + if err := waiverRows.Err(); err != nil { + return err + } + + approvalRows, err := s.pool.Query(ctx, `SELECT id, tenant_id, subject_type, subject_id, decision, reason, approver_id, evidence_id, schema_version, created_at FROM approval_records`) + if err != nil { + return fmt.Errorf("load relational approvals: %w", err) + } + defer approvalRows.Close() + for approvalRows.Next() { + var approval domain.ApprovalRecord + var evidenceID sql.NullString + if err := approvalRows.Scan(&approval.ID, &approval.TenantID, &approval.SubjectType, &approval.SubjectID, &approval.Decision, &approval.Reason, &approval.ApproverID, &evidenceID, &approval.SchemaVersion, &approval.CreatedAt); err != nil { + return fmt.Errorf("scan relational approval: %w", err) + } + approval.EvidenceID = nullableSQLString(evidenceID) + state.Approvals[approval.ID] = approval + *loaded = true + } + if err := approvalRows.Err(); err != nil { + return err + } + + trustRows, err := s.pool.Query(ctx, `SELECT id, tenant_id, name, key_id, algorithm, public_key, status, schema_version, created_at FROM dsse_trust_roots`) + if err != nil { + return fmt.Errorf("load relational dsse trust roots: %w", err) + } + defer trustRows.Close() + for trustRows.Next() { + var root domain.DSSETrustRoot + if err := trustRows.Scan(&root.ID, &root.TenantID, &root.Name, &root.KeyID, &root.Algorithm, &root.PublicKey, &root.Status, &root.SchemaVersion, &root.CreatedAt); err != nil { + return fmt.Errorf("scan relational dsse trust root: %w", err) + } + state.DSSETrustRoots[root.ID] = root + *loaded = true + } + return trustRows.Err() +} + +func (s *Store) loadRelationalIntegrityProviderRows(ctx context.Context, state *app.PersistedState, loaded *bool) error { + collectorRows, err := s.pool.Query(ctx, `SELECT id, tenant_id, collector_id, version, artifact_digest, signature_id, sbom_id, scan_id, pinned, verification_status, health_status, limitations, schema_version, created_at FROM collector_releases`) + if err != nil { + return fmt.Errorf("load relational collector releases: %w", err) + } + defer collectorRows.Close() + for collectorRows.Next() { + var release domain.CollectorRelease + var signatureID, sbomID, scanID sql.NullString + if err := collectorRows.Scan(&release.ID, &release.TenantID, &release.CollectorID, &release.Version, &release.ArtifactDigest, &signatureID, &sbomID, &scanID, &release.Pinned, &release.VerificationStatus, &release.HealthStatus, &release.Limitations, &release.SchemaVersion, &release.CreatedAt); err != nil { + return fmt.Errorf("scan relational collector release: %w", err) + } + release.SignatureID = nullableSQLString(signatureID) + release.SBOMID = nullableSQLString(sbomID) + release.ScanID = nullableSQLString(scanID) + state.CollectorReleases[release.ID] = release + *loaded = true + } + if err := collectorRows.Err(); err != nil { + return err + } + + cosignRows, err := s.pool.Query(ctx, `SELECT id, tenant_id, artifact_id, container_image_id, artifact_signature_id, subject_digest, rekor_uuid, rekor_log_index, certificate_identity, certificate_issuer, result, checks, schema_version, created_at FROM cosign_verifications`) + if err != nil { + return fmt.Errorf("load relational cosign verifications: %w", err) + } + defer cosignRows.Close() + for cosignRows.Next() { + var verification domain.CosignVerification + var artifactID, imageID, rekorUUID, rekorLogIndex, certIdentity, certIssuer sql.NullString + var checks []byte + if err := cosignRows.Scan(&verification.ID, &verification.TenantID, &artifactID, &imageID, &verification.ArtifactSignatureID, &verification.SubjectDigest, &rekorUUID, &rekorLogIndex, &certIdentity, &certIssuer, &verification.Result, &checks, &verification.SchemaVersion, &verification.CreatedAt); err != nil { + return fmt.Errorf("scan relational cosign verification: %w", err) + } + verification.ArtifactID = nullableSQLString(artifactID) + verification.ContainerImageID = nullableSQLString(imageID) + verification.RekorUUID = nullableSQLString(rekorUUID) + verification.RekorLogIndex = nullableSQLString(rekorLogIndex) + verification.CertificateIdentity = nullableSQLString(certIdentity) + verification.CertificateIssuer = nullableSQLString(certIssuer) + if err := decodeJSON(checks, &verification.Checks); err != nil { + return fmt.Errorf("decode relational cosign checks: %w", err) + } + state.CosignVerifications[verification.ID] = verification + *loaded = true + } + if err := cosignRows.Err(); err != nil { + return err + } + + providerRows, err := s.pool.Query(ctx, `SELECT id, tenant_id, name, type, status, key_ref, encrypted, schema_version, created_at FROM signing_providers`) + if err != nil { + return fmt.Errorf("load relational signing providers: %w", err) + } + defer providerRows.Close() + for providerRows.Next() { + var provider domain.SigningProvider + if err := providerRows.Scan(&provider.ID, &provider.TenantID, &provider.Name, &provider.Type, &provider.Status, &provider.KeyRef, &provider.Encrypted, &provider.SchemaVersion, &provider.CreatedAt); err != nil { + return fmt.Errorf("scan relational signing provider: %w", err) + } + state.SigningProviders[provider.ID] = provider + *loaded = true + } + if err := providerRows.Err(); err != nil { + return err + } + + batchRows, err := s.pool.Query(ctx, `SELECT id, tenant_id, from_sequence, to_sequence, entry_count, leaf_hashes, root_hash, signature_refs, schema_version, created_at FROM merkle_batches`) + if err != nil { + return fmt.Errorf("load relational merkle batches: %w", err) + } + defer batchRows.Close() + for batchRows.Next() { + var batch domain.MerkleBatch + if err := batchRows.Scan(&batch.ID, &batch.TenantID, &batch.FromSequence, &batch.ToSequence, &batch.EntryCount, &batch.LeafHashes, &batch.RootHash, &batch.SignatureRefs, &batch.SchemaVersion, &batch.CreatedAt); err != nil { + return fmt.Errorf("scan relational merkle batch: %w", err) + } + state.MerkleBatches[batch.ID] = batch + *loaded = true + } + if err := batchRows.Err(); err != nil { + return err + } + + checkpointRows, err := s.pool.Query(ctx, `SELECT id, tenant_id, batch_id, provider, external_url, external_id, timestamp_hash, state, schema_version, created_at FROM transparency_checkpoints`) + if err != nil { + return fmt.Errorf("load relational transparency checkpoints: %w", err) + } + defer checkpointRows.Close() + for checkpointRows.Next() { + var checkpoint domain.TransparencyCheckpoint + var externalURL, externalID sql.NullString + if err := checkpointRows.Scan(&checkpoint.ID, &checkpoint.TenantID, &checkpoint.BatchID, &checkpoint.Provider, &externalURL, &externalID, &checkpoint.TimestampHash, &checkpoint.State, &checkpoint.SchemaVersion, &checkpoint.CreatedAt); err != nil { + return fmt.Errorf("scan relational transparency checkpoint: %w", err) + } + checkpoint.ExternalURL = nullableSQLString(externalURL) + checkpoint.ExternalID = nullableSQLString(externalID) + state.TransparencyCheckpoints[checkpoint.ID] = checkpoint + *loaded = true + } + return checkpointRows.Err() +} + +func (s *Store) loadRelationalPackageReportRetention(ctx context.Context, state *app.PersistedState, loaded *bool) error { + if err := s.loadRelationalPackages(ctx, state, loaded); err != nil { + return err + } + if err := s.loadRelationalReports(ctx, state, loaded); err != nil { + return err + } + if err := s.loadRelationalRetention(ctx, state, loaded); err != nil { + return err + } + return nil +} + +func (s *Store) loadRelationalPackages(ctx context.Context, state *app.PersistedState, loaded *bool) error { + profileRows, err := s.pool.Query(ctx, `SELECT id, tenant_id, name, description, allowed_types, excluded_fields, schema_version, created_at FROM redaction_profiles`) + if err != nil { + return fmt.Errorf("load relational redaction profiles: %w", err) + } + defer profileRows.Close() + for profileRows.Next() { + var profile domain.RedactionProfile + var description sql.NullString + if err := profileRows.Scan(&profile.ID, &profile.TenantID, &profile.Name, &description, &profile.AllowedTypes, &profile.ExcludedFields, &profile.SchemaVersion, &profile.CreatedAt); err != nil { + return fmt.Errorf("scan relational redaction profile: %w", err) + } + profile.Description = nullableSQLString(description) + state.RedactionProfiles[profile.ID] = profile + *loaded = true + } + if err := profileRows.Err(); err != nil { + return err + } + + packageRows, err := s.pool.Query(ctx, `SELECT id, tenant_id, product_id, release_id, redaction_profile_id, title, state, manifest, manifest_hash, expires_at, access_count, schema_version, created_at FROM customer_security_packages`) + if err != nil { + return fmt.Errorf("load relational customer packages: %w", err) + } + defer packageRows.Close() + for packageRows.Next() { + var pkg domain.CustomerSecurityPackage + var releaseID sql.NullString + var manifest []byte + if err := packageRows.Scan(&pkg.ID, &pkg.TenantID, &pkg.ProductID, &releaseID, &pkg.RedactionProfileID, &pkg.Title, &pkg.State, &manifest, &pkg.ManifestHash, &pkg.ExpiresAt, &pkg.AccessCount, &pkg.SchemaVersion, &pkg.CreatedAt); err != nil { + return fmt.Errorf("scan relational customer package: %w", err) + } + pkg.ReleaseID = nullableSQLString(releaseID) + if err := decodeJSON(manifest, &pkg.Manifest); err != nil { + return fmt.Errorf("decode relational customer package manifest: %w", err) + } + state.CustomerPackages[pkg.ID] = pkg + *loaded = true + } + if err := packageRows.Err(); err != nil { + return err + } + + bundleRows, err := s.pool.Query(ctx, `SELECT id, tenant_id, release_id, evidence_ids, manifest, manifest_hash, signature_refs, verification_text, schema_version, created_at FROM evidence_bundles`) + if err != nil { + return fmt.Errorf("load relational evidence bundles: %w", err) + } + defer bundleRows.Close() + for bundleRows.Next() { + var bundle domain.EvidenceBundle + var releaseID sql.NullString + var manifest []byte + if err := bundleRows.Scan(&bundle.ID, &bundle.TenantID, &releaseID, &bundle.EvidenceIDs, &manifest, &bundle.ManifestHash, &bundle.SignatureRefs, &bundle.VerificationText, &bundle.SchemaVersion, &bundle.CreatedAt); err != nil { + return fmt.Errorf("scan relational evidence bundle: %w", err) + } + bundle.ReleaseID = nullableSQLString(releaseID) + if err := decodeJSON(manifest, &bundle.Manifest); err != nil { + return fmt.Errorf("decode relational evidence bundle manifest: %w", err) + } + state.EvidenceBundles[bundle.ID] = bundle + *loaded = true + } + if err := bundleRows.Err(); err != nil { + return err + } + + importRows, err := s.pool.Query(ctx, `SELECT id, tenant_id, bundle_hash, result, imported_count, schema_version, created_at FROM evidence_bundle_imports`) + if err != nil { + return fmt.Errorf("load relational evidence bundle imports: %w", err) + } + defer importRows.Close() + for importRows.Next() { + var imported domain.EvidenceBundleImport + if err := importRows.Scan(&imported.ID, &imported.TenantID, &imported.BundleHash, &imported.Result, &imported.ImportedCount, &imported.SchemaVersion, &imported.CreatedAt); err != nil { + return fmt.Errorf("scan relational evidence bundle import: %w", err) + } + state.BundleImports[imported.ID] = imported + *loaded = true + } + return importRows.Err() +} + +func (s *Store) loadRelationalReports(ctx context.Context, state *app.PersistedState, loaded *bool) error { + htmlRows, err := s.pool.Query(ctx, `SELECT id, tenant_id, report_type, product_id, release_id, html, hash, schema_version, created_at FROM html_report_packages`) + if err != nil { + return fmt.Errorf("load relational html reports: %w", err) + } + defer htmlRows.Close() + for htmlRows.Next() { + var report domain.HTMLReportPackage + var releaseID sql.NullString + if err := htmlRows.Scan(&report.ID, &report.TenantID, &report.ReportType, &report.ProductID, &releaseID, &report.HTML, &report.Hash, &report.SchemaVersion, &report.CreatedAt); err != nil { + return fmt.Errorf("scan relational html report: %w", err) + } + report.ReleaseID = nullableSQLString(releaseID) + state.HTMLReports[report.ID] = report + *loaded = true + } + if err := htmlRows.Err(); err != nil { + return err + } + + templateRows, err := s.pool.Query(ctx, `SELECT id, tenant_id, name, version, report_type, allowed_fields, template, schema_version, created_at FROM report_templates`) + if err != nil { + return fmt.Errorf("load relational report templates: %w", err) + } + defer templateRows.Close() + for templateRows.Next() { + var template domain.CustomReportTemplate + if err := templateRows.Scan(&template.ID, &template.TenantID, &template.Name, &template.Version, &template.ReportType, &template.AllowedFields, &template.Template, &template.SchemaVersion, &template.CreatedAt); err != nil { + return fmt.Errorf("scan relational report template: %w", err) + } + state.ReportTemplates[template.ID] = template + *loaded = true + } + if err := templateRows.Err(); err != nil { + return err + } + + renderRows, err := s.pool.Query(ctx, `SELECT id, tenant_id, template_id, subject_type, subject_id, output, hash, schema_version, created_at FROM rendered_reports`) + if err != nil { + return fmt.Errorf("load relational rendered reports: %w", err) + } + defer renderRows.Close() + for renderRows.Next() { + var report domain.RenderedCustomReport + var output []byte + if err := renderRows.Scan(&report.ID, &report.TenantID, &report.TemplateID, &report.SubjectType, &report.SubjectID, &output, &report.Hash, &report.SchemaVersion, &report.CreatedAt); err != nil { + return fmt.Errorf("scan relational rendered report: %w", err) + } + if err := decodeJSON(output, &report.Output); err != nil { + return fmt.Errorf("decode relational rendered report output: %w", err) + } + state.RenderedReports[report.ID] = report + *loaded = true + } + if err := renderRows.Err(); err != nil { + return err + } + + pdfRows, err := s.pool.Query(ctx, `SELECT id, tenant_id, report_type, product_id, release_id, title, payload_ref, payload_hash, payload_size, limitations, schema_version, created_at FROM pdf_report_packages`) + if err != nil { + return fmt.Errorf("load relational pdf reports: %w", err) + } + defer pdfRows.Close() + for pdfRows.Next() { + var report domain.PDFReportPackage + var productID, releaseID, payloadRef sql.NullString + if err := pdfRows.Scan(&report.ID, &report.TenantID, &report.ReportType, &productID, &releaseID, &report.Title, &payloadRef, &report.PayloadHash, &report.PayloadSize, &report.Limitations, &report.SchemaVersion, &report.CreatedAt); err != nil { + return fmt.Errorf("scan relational pdf report: %w", err) + } + report.ProductID = nullableSQLString(productID) + report.ReleaseID = nullableSQLString(releaseID) + report.PayloadRef = nullableSQLString(payloadRef) + state.PDFReports[report.ID] = report + *loaded = true + } + if err := pdfRows.Err(); err != nil { + return err + } + + anomalyRows, err := s.pool.Query(ctx, `SELECT id, tenant_id, subject_type, subject_id, result, signals, assumptions, limitations, schema_version, created_at FROM anomaly_reports`) + if err != nil { + return fmt.Errorf("load relational anomaly reports: %w", err) + } + defer anomalyRows.Close() + for anomalyRows.Next() { + var report domain.AnomalyReport + var signals []byte + if err := anomalyRows.Scan(&report.ID, &report.TenantID, &report.SubjectType, &report.SubjectID, &report.Result, &signals, &report.Assumptions, &report.Limitations, &report.SchemaVersion, &report.CreatedAt); err != nil { + return fmt.Errorf("scan relational anomaly report: %w", err) + } + if err := decodeJSON(signals, &report.Signals); err != nil { + return fmt.Errorf("decode relational anomaly report signals: %w", err) + } + state.AnomalyReports[report.ID] = report + *loaded = true + } + return anomalyRows.Err() +} + +func (s *Store) loadRelationalRetention(ctx context.Context, state *app.PersistedState, loaded *bool) error { + policyRows, err := s.pool.Query(ctx, `SELECT id, tenant_id, name, object_prefix, COALESCE(object_key, ''), COALESCE(require_legal_hold, false), mode, retention_days, status, verified_at, verification_hash, verification_checks, verification_limitations, schema_version, created_at FROM object_retention_policies`) + if err != nil { + return fmt.Errorf("load relational object retention policies: %w", err) + } + defer policyRows.Close() + for policyRows.Next() { + var policy domain.ObjectRetentionPolicy + var verifiedAt sql.NullTime + var verificationHash sql.NullString + var checks []byte + if err := policyRows.Scan(&policy.ID, &policy.TenantID, &policy.Name, &policy.ObjectPrefix, &policy.ObjectKey, &policy.RequireLegalHold, &policy.Mode, &policy.RetentionDays, &policy.Status, &verifiedAt, &verificationHash, &checks, &policy.VerificationLimitations, &policy.SchemaVersion, &policy.CreatedAt); err != nil { + return fmt.Errorf("scan relational object retention policy: %w", err) + } + policy.VerifiedAt = nullableSQLTime(verifiedAt) + policy.VerificationHash = nullableSQLString(verificationHash) + if err := decodeJSON(checks, &policy.VerificationChecks); err != nil { + return fmt.Errorf("decode relational object retention checks: %w", err) + } + state.ObjectRetentionPolicies[policy.ID] = policy + *loaded = true + } + if err := policyRows.Err(); err != nil { + return err + } + + backupRows, err := s.pool.Query(ctx, `SELECT id, tenant_id, state_hash, resource_counts, consistency_checks, limitations, schema_version, created_at FROM backup_manifests`) + if err != nil { + return fmt.Errorf("load relational backup manifests: %w", err) + } + defer backupRows.Close() + for backupRows.Next() { + var manifest domain.BackupManifest + var counts, checks []byte + if err := backupRows.Scan(&manifest.ID, &manifest.TenantID, &manifest.StateHash, &counts, &checks, &manifest.Limitations, &manifest.SchemaVersion, &manifest.CreatedAt); err != nil { + return fmt.Errorf("scan relational backup manifest: %w", err) + } + if err := decodeJSON(counts, &manifest.ResourceCounts); err != nil { + return fmt.Errorf("decode relational backup manifest counts: %w", err) + } + if err := decodeJSON(checks, &manifest.ConsistencyChecks); err != nil { + return fmt.Errorf("decode relational backup manifest checks: %w", err) + } + state.BackupManifests[manifest.ID] = manifest + *loaded = true + } + if err := backupRows.Err(); err != nil { + return err + } + + holdRows, err := s.pool.Query(ctx, `SELECT id, tenant_id, scope_type, scope_id, reason, owner, released_at, schema_version, created_at FROM legal_holds`) + if err != nil { + return fmt.Errorf("load relational legal holds: %w", err) + } + defer holdRows.Close() + for holdRows.Next() { + var hold domain.LegalHold + var releasedAt sql.NullTime + if err := holdRows.Scan(&hold.ID, &hold.TenantID, &hold.ScopeType, &hold.ScopeID, &hold.Reason, &hold.Owner, &releasedAt, &hold.SchemaVersion, &hold.CreatedAt); err != nil { + return fmt.Errorf("scan relational legal hold: %w", err) + } + hold.ReleasedAt = nullableSQLTime(releasedAt) + state.LegalHolds[hold.ID] = hold + *loaded = true + } + if err := holdRows.Err(); err != nil { + return err + } + + overrideRows, err := s.pool.Query(ctx, `SELECT id, tenant_id, scope_type, scope_id, retention_until, reason, owner, schema_version, created_at FROM retention_overrides`) + if err != nil { + return fmt.Errorf("load relational retention overrides: %w", err) + } + defer overrideRows.Close() + for overrideRows.Next() { + var override domain.RetentionOverride + if err := overrideRows.Scan(&override.ID, &override.TenantID, &override.ScopeType, &override.ScopeID, &override.RetentionUntil, &override.Reason, &override.Owner, &override.SchemaVersion, &override.CreatedAt); err != nil { + return fmt.Errorf("scan relational retention override: %w", err) + } + state.RetentionOverrides[override.ID] = override + *loaded = true + } + if err := overrideRows.Err(); err != nil { + return err + } + + questionRows, err := s.pool.Query(ctx, `SELECT id, tenant_id, name, version, questions, schema_version, created_at FROM questionnaire_templates`) + if err != nil { + return fmt.Errorf("load relational questionnaire templates: %w", err) + } + defer questionRows.Close() + for questionRows.Next() { + var template domain.QuestionnaireTemplate + var questions []byte + if err := questionRows.Scan(&template.ID, &template.TenantID, &template.Name, &template.Version, &questions, &template.SchemaVersion, &template.CreatedAt); err != nil { + return fmt.Errorf("scan relational questionnaire template: %w", err) + } + if err := decodeJSON(questions, &template.Questions); err != nil { + return fmt.Errorf("decode relational questionnaire questions: %w", err) + } + state.QuestionnaireTemplates[template.ID] = template + *loaded = true + } + if err := questionRows.Err(); err != nil { + return err + } + + qpRows, err := s.pool.Query(ctx, `SELECT id, tenant_id, template_id, package_id, product_id, release_id, responses, manifest_hash, schema_version, created_at FROM questionnaire_packages`) + if err != nil { + return fmt.Errorf("load relational questionnaire packages: %w", err) + } + defer qpRows.Close() + for qpRows.Next() { + var pkg domain.QuestionnairePackage + var packageID, productID, releaseID sql.NullString + var responses []byte + if err := qpRows.Scan(&pkg.ID, &pkg.TenantID, &pkg.TemplateID, &packageID, &productID, &releaseID, &responses, &pkg.ManifestHash, &pkg.SchemaVersion, &pkg.CreatedAt); err != nil { + return fmt.Errorf("scan relational questionnaire package: %w", err) + } + pkg.PackageID = nullableSQLString(packageID) + pkg.ProductID = nullableSQLString(productID) + pkg.ReleaseID = nullableSQLString(releaseID) + if err := decodeJSON(responses, &pkg.Responses); err != nil { + return fmt.Errorf("decode relational questionnaire responses: %w", err) + } + state.QuestionnairePackages[pkg.ID] = pkg + *loaded = true + } + if err := qpRows.Err(); err != nil { + return err + } + + answerRows, err := s.pool.Query(ctx, `SELECT id, tenant_id, question_id, evidence_type, control_id, product_id, release_id, answer, evidence_ids, limitations, schema_version, created_at FROM questionnaire_answer_library`) + if err != nil { + return fmt.Errorf("load relational questionnaire answer library: %w", err) + } + defer answerRows.Close() + for answerRows.Next() { + var entry domain.QuestionnaireAnswerLibraryEntry + var questionID, evidenceType, controlID, productID, releaseID sql.NullString + if err := answerRows.Scan(&entry.ID, &entry.TenantID, &questionID, &evidenceType, &controlID, &productID, &releaseID, &entry.Answer, &entry.EvidenceIDs, &entry.Limitations, &entry.SchemaVersion, &entry.CreatedAt); err != nil { + return fmt.Errorf("scan relational questionnaire answer library: %w", err) + } + entry.QuestionID = nullableSQLString(questionID) + entry.EvidenceType = nullableSQLString(evidenceType) + entry.ControlID = nullableSQLString(controlID) + entry.ProductID = nullableSQLString(productID) + entry.ReleaseID = nullableSQLString(releaseID) + state.QuestionnaireAnswerLibrary[entry.ID] = entry + *loaded = true + } + return answerRows.Err() +} + +func (s *Store) loadRelationalFutureExtensionRows(ctx context.Context, state *app.PersistedState, loaded *bool) error { + commercialRows, err := s.pool.Query(ctx, `SELECT id, tenant_id, name, provider, version, manifest_hash, allowed_scopes, status, schema_version, created_at FROM commercial_collectors`) + if err != nil { + return fmt.Errorf("load relational commercial collectors: %w", err) + } + defer commercialRows.Close() + for commercialRows.Next() { + var collector domain.CommercialCollectorDefinition + if err := commercialRows.Scan(&collector.ID, &collector.TenantID, &collector.Name, &collector.Provider, &collector.Version, &collector.ManifestHash, &collector.AllowedScopes, &collector.Status, &collector.SchemaVersion, &collector.CreatedAt); err != nil { + return fmt.Errorf("scan relational commercial collector: %w", err) + } + state.CommercialCollectors[collector.ID] = collector + *loaded = true + } + if err := commercialRows.Err(); err != nil { + return err + } + + summaryRows, err := s.pool.Query(ctx, `SELECT id, tenant_id, subject_type, subject_id, evidence_ids, summary, citations, assumptions, limitations, schema_version, created_at FROM evidence_summaries`) + if err != nil { + return fmt.Errorf("load relational evidence summaries: %w", err) + } + defer summaryRows.Close() + for summaryRows.Next() { + var summary domain.EvidenceSummary + var citations []byte + if err := summaryRows.Scan(&summary.ID, &summary.TenantID, &summary.SubjectType, &summary.SubjectID, &summary.EvidenceIDs, &summary.Summary, &citations, &summary.Assumptions, &summary.Limitations, &summary.SchemaVersion, &summary.CreatedAt); err != nil { + return fmt.Errorf("scan relational evidence summary: %w", err) + } + if err := decodeJSON(citations, &summary.Citations); err != nil { + return fmt.Errorf("decode relational evidence summary citations: %w", err) + } + state.EvidenceSummaries[summary.ID] = summary + *loaded = true + } + if err := summaryRows.Err(); err != nil { + return err + } + + draftRows, err := s.pool.Query(ctx, `SELECT id, tenant_id, template_id, product_id, release_id, responses, manifest_hash, limitations, schema_version, created_at FROM questionnaire_drafts`) + if err != nil { + return fmt.Errorf("load relational questionnaire drafts: %w", err) + } + defer draftRows.Close() + for draftRows.Next() { + var draft domain.QuestionnaireDraft + var productID, releaseID sql.NullString + var responses []byte + if err := draftRows.Scan(&draft.ID, &draft.TenantID, &draft.TemplateID, &productID, &releaseID, &responses, &draft.ManifestHash, &draft.Limitations, &draft.SchemaVersion, &draft.CreatedAt); err != nil { + return fmt.Errorf("scan relational questionnaire draft: %w", err) + } + draft.ProductID = nullableSQLString(productID) + draft.ReleaseID = nullableSQLString(releaseID) + if err := decodeJSON(responses, &draft.Responses); err != nil { + return fmt.Errorf("decode relational questionnaire draft responses: %w", err) + } + state.QuestionnaireDrafts[draft.ID] = draft + *loaded = true + } + if err := draftRows.Err(); err != nil { + return err + } + + graphRows, err := s.pool.Query(ctx, `SELECT id, tenant_id, product_id, release_id, nodes, edges, graph_hash, limitations, schema_version, created_at FROM evidence_graph_snapshots`) + if err != nil { + return fmt.Errorf("load relational graph snapshots: %w", err) + } + defer graphRows.Close() + for graphRows.Next() { + var graph domain.EvidenceGraphSnapshot + var productID, releaseID sql.NullString + var nodes, edges []byte + if err := graphRows.Scan(&graph.ID, &graph.TenantID, &productID, &releaseID, &nodes, &edges, &graph.GraphHash, &graph.Limitations, &graph.SchemaVersion, &graph.CreatedAt); err != nil { + return fmt.Errorf("scan relational graph snapshot: %w", err) + } + graph.ProductID = nullableSQLString(productID) + graph.ReleaseID = nullableSQLString(releaseID) + if err := decodeJSON(nodes, &graph.Nodes); err != nil { + return fmt.Errorf("decode relational graph nodes: %w", err) + } + if err := decodeJSON(edges, &graph.Edges); err != nil { + return fmt.Errorf("decode relational graph edges: %w", err) + } + state.GraphSnapshots[graph.ID] = graph + *loaded = true + } + if err := graphRows.Err(); err != nil { + return err + } + + saasRows, err := s.pool.Query(ctx, `SELECT id, tenant_id, name, region, admin_tenant_id, isolation_model, status, config_hash, limitations, schema_version, created_at FROM saas_edition_profiles`) + if err != nil { + return fmt.Errorf("load relational saas profiles: %w", err) + } + defer saasRows.Close() + for saasRows.Next() { + var profile domain.SaaSEditionProfile + if err := saasRows.Scan(&profile.ID, &profile.TenantID, &profile.Name, &profile.Region, &profile.AdminTenantID, &profile.IsolationModel, &profile.Status, &profile.ConfigHash, &profile.Limitations, &profile.SchemaVersion, &profile.CreatedAt); err != nil { + return fmt.Errorf("scan relational saas profile: %w", err) + } + state.SaaSProfiles[profile.ID] = profile + *loaded = true + } + if err := saasRows.Err(); err != nil { + return err + } + + logRows, err := s.pool.Query(ctx, `SELECT id, tenant_id, name, endpoint, public_key, state, schema_version, created_at FROM public_transparency_logs`) + if err != nil { + return fmt.Errorf("load relational public transparency logs: %w", err) + } + defer logRows.Close() + for logRows.Next() { + var log domain.PublicTransparencyLog + if err := logRows.Scan(&log.ID, &log.TenantID, &log.Name, &log.Endpoint, &log.PublicKey, &log.State, &log.SchemaVersion, &log.CreatedAt); err != nil { + return fmt.Errorf("scan relational public transparency log: %w", err) + } + state.PublicTransparencyLogs[log.ID] = log + *loaded = true + } + if err := logRows.Err(); err != nil { + return err + } + + entryRows, err := s.pool.Query(ctx, `SELECT id, tenant_id, log_id, checkpoint_id, merkle_batch_id, external_id, entry_hash, inclusion_root_hash, inclusion_proof_hash, inclusion_verified_at, verification_checks, verification_limitations, state, schema_version, created_at FROM public_transparency_log_entries`) + if err != nil { + return fmt.Errorf("load relational public transparency entries: %w", err) + } + defer entryRows.Close() + for entryRows.Next() { + var entry domain.PublicTransparencyLogEntry + var rootHash, proofHash sql.NullString + var verifiedAt sql.NullTime + var checks []byte + if err := entryRows.Scan(&entry.ID, &entry.TenantID, &entry.LogID, &entry.CheckpointID, &entry.MerkleBatchID, &entry.ExternalID, &entry.EntryHash, &rootHash, &proofHash, &verifiedAt, &checks, &entry.VerificationLimitations, &entry.State, &entry.SchemaVersion, &entry.CreatedAt); err != nil { + return fmt.Errorf("scan relational public transparency entry: %w", err) + } + entry.InclusionRootHash = nullableSQLString(rootHash) + entry.InclusionProofHash = nullableSQLString(proofHash) + entry.InclusionVerifiedAt = nullableSQLTime(verifiedAt) + if err := decodeJSON(checks, &entry.VerificationChecks); err != nil { + return fmt.Errorf("decode relational transparency checks: %w", err) + } + state.PublicTransparencyItems[entry.ID] = entry + *loaded = true + } + if err := entryRows.Err(); err != nil { + return err + } + + marketRows, err := s.pool.Query(ctx, `SELECT id, tenant_id, name, provider, version, publisher, manifest_hash, signature_id, sbom_id, scan_id, state, limitations, schema_version, created_at FROM marketplace_collectors`) + if err != nil { + return fmt.Errorf("load relational marketplace collectors: %w", err) + } + defer marketRows.Close() + for marketRows.Next() { + var collector domain.MarketplaceCollector + var signatureID, sbomID, scanID sql.NullString + if err := marketRows.Scan(&collector.ID, &collector.TenantID, &collector.Name, &collector.Provider, &collector.Version, &collector.Publisher, &collector.ManifestHash, &signatureID, &sbomID, &scanID, &collector.State, &collector.Limitations, &collector.SchemaVersion, &collector.CreatedAt); err != nil { + return fmt.Errorf("scan relational marketplace collector: %w", err) + } + collector.SignatureID = nullableSQLString(signatureID) + collector.SBOMID = nullableSQLString(sbomID) + collector.ScanID = nullableSQLString(scanID) + state.MarketplaceCollectors[collector.ID] = collector + *loaded = true + } + if err := marketRows.Err(); err != nil { + return err + } + + providerRows, err := s.pool.Query(ctx, `SELECT id, tenant_id, provider_type, provider_id, subject, result, checks, limitations, schema_version, created_at FROM provider_verifications`) + if err != nil { + return fmt.Errorf("load relational provider verifications: %w", err) + } + defer providerRows.Close() + for providerRows.Next() { + var verification domain.ProviderVerification + var checks []byte + if err := providerRows.Scan(&verification.ID, &verification.TenantID, &verification.ProviderType, &verification.ProviderID, &verification.Subject, &verification.Result, &checks, &verification.Limitations, &verification.SchemaVersion, &verification.CreatedAt); err != nil { + return fmt.Errorf("scan relational provider verification: %w", err) + } + if err := decodeJSON(checks, &verification.Checks); err != nil { + return fmt.Errorf("decode relational provider verification checks: %w", err) + } + state.ProviderVerifications[verification.ID] = verification + *loaded = true + } + if err := providerRows.Err(); err != nil { + return err + } + + signingRows, err := s.pool.Query(ctx, `SELECT id, tenant_id, provider_id, subject_type, subject_id, payload_hash, signature_ref, result, checks, schema_version, created_at FROM signing_operations`) + if err != nil { + return fmt.Errorf("load relational signing operations: %w", err) + } + defer signingRows.Close() + for signingRows.Next() { + var operation domain.SigningOperation + var signatureRef sql.NullString + var checks []byte + if err := signingRows.Scan(&operation.ID, &operation.TenantID, &operation.ProviderID, &operation.SubjectType, &operation.SubjectID, &operation.PayloadHash, &signatureRef, &operation.Result, &checks, &operation.SchemaVersion, &operation.CreatedAt); err != nil { + return fmt.Errorf("scan relational signing operation: %w", err) + } + operation.SignatureRef = nullableSQLString(signatureRef) + if err := decodeJSON(checks, &operation.Checks); err != nil { + return fmt.Errorf("decode relational signing operation checks: %w", err) + } + state.SigningOperations[operation.ID] = operation + *loaded = true + } + return signingRows.Err() +} + +func (s *Store) loadRelationalIdempotency(ctx context.Context, state *app.PersistedState, loaded *bool) error { + rows, err := s.pool.Query(ctx, `SELECT tenant_id, actor_key_id, method, path, idempotency_key, request_hash, status, response, created_at FROM idempotency_records`) + if err != nil { + return fmt.Errorf("load relational idempotency records: %w", err) + } + defer rows.Close() + for rows.Next() { + var tenantID, actorID, method, path, idempotencyKey string + var record app.IdempotencyRecord + var response []byte + if err := rows.Scan(&tenantID, &actorID, &method, &path, &idempotencyKey, &record.RequestHash, &record.Status, &response, &record.CreatedAt); err != nil { + return fmt.Errorf("scan relational idempotency record: %w", err) + } + if err := decodeJSON(response, &record.Response); err != nil { + return fmt.Errorf("decode relational idempotency response: %w", err) + } + key := app.NewIdempotencyRecordKey(tenantID, actorID, method, path, idempotencyKey) + state.Idempotency[key] = record + *loaded = true + } + return rows.Err() +} + +func (s *Store) SaveState(ctx context.Context, state app.PersistedState) error { + return s.saveState(ctx, state, !s.disableSnapshotWrites) +} + +func (s *Store) SaveRelationalState(ctx context.Context, state app.PersistedState) error { + return s.saveState(ctx, state, false) +} + +func (s *Store) saveState(ctx context.Context, state app.PersistedState, writeSnapshot bool) error { + tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{}) + if err != nil { + return fmt.Errorf("begin save ledger state transaction: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + if writeSnapshot { + body, err := json.Marshal(state) + if err != nil { + return fmt.Errorf("encode ledger state: %w", err) + } + _, err = tx.Exec(ctx, ` + INSERT INTO ledger_state (id, state, updated_at) + VALUES ('default', $1, now()) + ON CONFLICT (id) DO UPDATE SET state = EXCLUDED.state, updated_at = EXCLUDED.updated_at + `, body) + if err != nil { + return fmt.Errorf("save ledger state: %w", err) + } + } + if err := syncResourceIndex(ctx, tx, state); err != nil { + return err + } + if err := syncIdentityAndIdempotency(ctx, tx, state); err != nil { + return err + } + if err := syncReleaseLedgerCore(ctx, tx, state); err != nil { + return err + } + if err := syncRiskBuildControlRows(ctx, tx, state); err != nil { + return err + } + if err := syncSourceDeploymentLifecycleRows(ctx, tx, state); err != nil { + return err + } + if err := syncIncidentSecurityGovernanceRows(ctx, tx, state); err != nil { + return err + } + if err := syncIntegrityProviderRows(ctx, tx, state); err != nil { + return err + } + if err := syncPackageReportRetentionRows(ctx, tx, state); err != nil { + return err + } + if err := syncFutureExtensionRows(ctx, tx, state); err != nil { + return err + } + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("commit save ledger state transaction: %w", err) + } + return nil +} + +func (s *Store) ApplyCriticalMutation(ctx context.Context, mutation app.CriticalMutation) error { + tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{}) + if err != nil { + return fmt.Errorf("begin critical mutation transaction: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + state := criticalMutationState(mutation) + if err := syncCriticalIdentityAndIdempotency(ctx, tx, mutation); err != nil { + return err + } + if err := syncRiskBuildControlRows(ctx, tx, state); err != nil { + return err + } + if err := syncReleaseLedgerCore(ctx, tx, state); err != nil { + return err + } + if err := syncPackageReportRetentionRows(ctx, tx, state); err != nil { + return err + } + for _, job := range mutation.OutboxJobs { + if err := insertOutboxJobTx(ctx, tx, job); err != nil { + return err + } + } + if err := syncCriticalResourceIndex(ctx, tx, state); err != nil { + return err + } + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("commit critical mutation transaction: %w", err) + } + return nil +} + +func (s *Store) ApplyReleaseLedgerMutation(ctx context.Context, mutation app.ReleaseLedgerMutation) error { + tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{}) + if err != nil { + return fmt.Errorf("begin release ledger mutation transaction: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + state := releaseLedgerMutationState(mutation) + if err := syncReleaseLedgerCore(ctx, tx, state); err != nil { + return err + } + if err := syncRiskBuildControlRows(ctx, tx, state); err != nil { + return err + } + if err := syncSourceDeploymentLifecycleRows(ctx, tx, state); err != nil { + return err + } + for _, job := range mutation.OutboxJobs { + if err := insertOutboxJobTx(ctx, tx, job); err != nil { + return err + } + } + if err := syncCriticalResourceIndex(ctx, tx, state); err != nil { + return err + } + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("commit release ledger mutation transaction: %w", err) + } + return nil +} + +func criticalMutationState(mutation app.CriticalMutation) app.PersistedState { + state := relationalEmptyState() + for _, tenant := range mutation.Tenants { + state.Tenants[tenant.ID] = tenant + } + for _, key := range mutation.APIKeys { + state.APIKeys[key.ID] = key + } + for id, hash := range mutation.APIKeyHashes { + state.APIKeyHashes[id] = hash + } + for _, collector := range mutation.Collectors { + state.Collectors[collector.ID] = collector + } + for _, session := range mutation.SSOSessions { + state.SSOSessions[session.ID] = session + } + for id, hash := range mutation.SSOSessionHashes { + state.SSOSessionHashes[id] = hash + } + for _, access := range mutation.CustomerPortalAccess { + state.CustomerPortalAccess[access.ID] = access + } + for id, hash := range mutation.CustomerPortalHashes { + state.CustomerPortalHashes[id] = hash + } + for key, record := range mutation.Idempotency { + state.Idempotency[key] = record + } + for _, key := range mutation.SigningKeys { + state.SigningKeys[key.ID] = key + } + for id, private := range mutation.SigningKeyPrivate { + state.SigningKeyPrivate[id] = append([]byte(nil), private...) + } + for _, signature := range mutation.Signatures { + state.Signatures[signature.ID] = signature + } + for _, bundle := range mutation.ReleaseBundles { + state.Bundles[bundle.ID] = bundle + } + for _, verification := range mutation.VerificationResults { + state.Verifications[verification.ID] = verification + } + for _, verification := range mutation.ProviderVerifications { + state.ProviderVerifications[verification.ID] = verification + } + for _, decision := range mutation.VulnerabilityDecisions { + state.Decisions[decision.ID] = decision + } + for _, entry := range mutation.AuditChainEntries { + state.Chain[entry.TenantID] = append(state.Chain[entry.TenantID], entry) + } + return state +} + +func releaseLedgerMutationState(mutation app.ReleaseLedgerMutation) app.PersistedState { + state := relationalEmptyState() + for _, product := range mutation.Products { + state.Products[product.ID] = product + } + for _, project := range mutation.Projects { + state.Projects[project.ID] = project + } + for _, release := range mutation.Releases { + state.Releases[release.ID] = release + } + for _, artifact := range mutation.Artifacts { + state.Artifacts[artifact.ID] = artifact + } + for _, evidence := range mutation.Evidence { + state.Evidence[evidence.ID] = evidence + } + for _, event := range mutation.EvidenceLifecycle { + state.EvidenceLifecycle[event.ID] = event + } + for _, sbom := range mutation.SBOMs { + state.SBOMs[sbom.ID] = sbom + } + for _, scan := range mutation.Scans { + state.Scans[scan.ID] = scan + } + for _, contract := range mutation.Contracts { + state.Contracts[contract.ID] = contract + } + for _, vex := range mutation.VEXDocuments { + state.VEXDocuments[vex.ID] = vex + } + for _, report := range mutation.VEXImportReports { + state.VEXImportReports[report.ID] = report + } + for _, decision := range mutation.VulnerabilityDecisions { + state.Decisions[decision.ID] = decision + } + for _, entry := range mutation.AuditChainEntries { + state.Chain[entry.TenantID] = append(state.Chain[entry.TenantID], entry) + } + return state +} + +func syncReleaseLedgerCore(ctx context.Context, tx pgx.Tx, state app.PersistedState) error { + for _, product := range state.Products { + if product.ID == "" || product.TenantID == "" { + continue + } + if _, err := tx.Exec(ctx, ` + INSERT INTO products (id, tenant_id, name, slug, created_at) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, slug = EXCLUDED.slug + `, product.ID, product.TenantID, product.Name, product.Slug, nonZeroTime(product.CreatedAt)); err != nil { + return fmt.Errorf("upsert product row: %w", err) + } + } + for _, project := range state.Projects { + if project.ID == "" || project.TenantID == "" || project.ProductID == "" { + continue + } + if _, err := tx.Exec(ctx, ` + INSERT INTO projects (id, tenant_id, product_id, name, created_at) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (id) DO UPDATE SET product_id = EXCLUDED.product_id, name = EXCLUDED.name + `, project.ID, project.TenantID, project.ProductID, project.Name, nonZeroTime(project.CreatedAt)); err != nil { + return fmt.Errorf("upsert project row: %w", err) + } + } + for _, release := range state.Releases { + if release.ID == "" || release.TenantID == "" || release.ProductID == "" { + continue + } + if _, err := tx.Exec(ctx, ` + INSERT INTO releases (id, tenant_id, product_id, version, state, frozen_at, approved_at, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + ON CONFLICT (id) DO UPDATE SET + state = EXCLUDED.state, + frozen_at = EXCLUDED.frozen_at, + approved_at = EXCLUDED.approved_at + `, release.ID, release.TenantID, release.ProductID, release.Version, release.State, nullableTime(release.FrozenAt), nullableTime(release.ApprovedAt), nonZeroTime(release.CreatedAt)); err != nil { + return fmt.Errorf("upsert release row: %w", err) + } + } + for _, artifact := range state.Artifacts { + if artifact.ID == "" || artifact.TenantID == "" { + continue + } + if _, err := tx.Exec(ctx, ` + INSERT INTO artifacts (id, tenant_id, name, media_type, size, digest, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7) + ON CONFLICT (id) DO UPDATE SET + name = EXCLUDED.name, + media_type = EXCLUDED.media_type, + size = EXCLUDED.size, + digest = EXCLUDED.digest + `, artifact.ID, artifact.TenantID, artifact.Name, artifact.MediaType, artifact.Size, artifact.Digest, nonZeroTime(artifact.CreatedAt)); err != nil { + return fmt.Errorf("upsert artifact row: %w", err) + } + } + for _, evidence := range state.Evidence { + if evidence.ID == "" || evidence.TenantID == "" { + continue + } + sourceIdentity, err := json.Marshal(evidence.SourceIdentity) + if err != nil { + return fmt.Errorf("encode evidence source identity: %w", err) + } + subjectRefs, err := json.Marshal(evidence.SubjectRefs) + if err != nil { + return fmt.Errorf("encode evidence subject refs: %w", err) + } + relatedRefs, err := json.Marshal(evidence.RelatedEvidenceRefs) + if err != nil { + return fmt.Errorf("encode evidence related refs: %w", err) + } + signatureRefs, err := json.Marshal(evidence.SignatureRefs) + if err != nil { + return fmt.Errorf("encode evidence signature refs: %w", err) + } + tags, err := json.Marshal(evidence.Tags) + if err != nil { + return fmt.Errorf("encode evidence tags: %w", err) + } + metadata, err := json.Marshal(evidence.Metadata) + if err != nil { + return fmt.Errorf("encode evidence metadata: %w", err) + } + warnings, err := json.Marshal(evidence.Warnings) + if err != nil { + return fmt.Errorf("encode evidence warnings: %w", err) + } + limitations, err := json.Marshal(evidence.Limitations) + if err != nil { + return fmt.Errorf("encode evidence limitations: %w", err) + } + if _, err := tx.Exec(ctx, ` + INSERT INTO evidence_items ( + id, tenant_id, product_id, project_id, release_id, build_id, deployment_id, + type, subtype, title, source_system, source_identity, collector_id, + uploaded_by, observed_at, evidence_version, schema_version, payload_ref, + payload_hash, payload_media_type, payload_size, canonical_hash, + canonicalization, subject_refs, related_evidence_refs, supersedes, + superseded_by, trust_level, verification_status, signature_refs, + chain_entry_id, tags, metadata, warnings, limitations, created_at + ) + VALUES ( + $1, $2, $3, $4, $5, $6, $7, + $8, $9, $10, $11, $12, $13, + $14, $15, $16, $17, $18, + $19, $20, $21, $22, + $23, $24, $25, $26, + $27, $28, $29, $30, + $31, $32, $33, $34, $35, $36 + ) + ON CONFLICT (id) DO UPDATE SET + superseded_by = EXCLUDED.superseded_by, + verification_status = EXCLUDED.verification_status, + signature_refs = EXCLUDED.signature_refs, + chain_entry_id = EXCLUDED.chain_entry_id, + tags = EXCLUDED.tags, + metadata = EXCLUDED.metadata, + warnings = EXCLUDED.warnings, + limitations = EXCLUDED.limitations + `, evidence.ID, evidence.TenantID, nullableString(evidence.ProductID), nullableString(evidence.ProjectID), nullableString(evidence.ReleaseID), nullableString(evidence.BuildID), nullableString(evidence.DeploymentID), + evidence.Type, nullableString(evidence.Subtype), evidence.Title, evidence.SourceSystem, sourceIdentity, nullableString(evidence.CollectorID), + nullableString(evidence.UploadedBy), nonZeroTime(evidence.ObservedAt), nonZeroInt(evidence.EvidenceVersion, 1), evidence.SchemaVersion, nullableString(evidence.PayloadRef), + evidence.PayloadHash, nullableString(evidence.PayloadMediaType), nullableInt64(evidence.PayloadSize), evidence.CanonicalHash, + evidence.Canonicalization, subjectRefs, relatedRefs, nullableString(evidence.Supersedes), + nullableString(evidence.SupersededBy), evidence.TrustLevel, evidence.VerificationStatus, signatureRefs, + nullableString(evidence.ChainEntryID), tags, metadata, warnings, limitations, nonZeroTime(evidence.CreatedAt)); err != nil { + return fmt.Errorf("upsert evidence item row: %w", err) + } + } + for _, tenantChain := range state.Chain { + for _, entry := range tenantChain { + if entry.ID == "" || entry.TenantID == "" { + continue + } + metadata, err := json.Marshal(entry.Metadata) + if err != nil { + return fmt.Errorf("encode audit chain metadata: %w", err) + } + if _, err := tx.Exec(ctx, ` + INSERT INTO audit_chain_entries ( + id, tenant_id, sequence, entry_type, subject_type, subject_id, + actor_type, actor_id, occurred_at, payload_hash, + canonical_entry_hash, previous_entry_hash, entry_hash, + signature_ref, metadata, schema_version + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) + ON CONFLICT (id) DO NOTHING + `, entry.ID, entry.TenantID, entry.Sequence, entry.EntryType, entry.SubjectType, entry.SubjectID, + entry.ActorType, entry.ActorID, nonZeroTime(entry.OccurredAt), nullableString(entry.PayloadHash), + entry.CanonicalEntryHash, entry.PreviousEntryHash, entry.EntryHash, + nullableString(entry.SignatureRef), metadata, entry.SchemaVersion); err != nil { + return fmt.Errorf("insert audit chain row: %w", err) + } + } + } + for id, key := range state.SigningKeys { + if key.ID == "" || key.TenantID == "" { + continue + } + private := state.SigningKeyPrivate[id] + if len(private) == 0 { + private = key.Private + } + if _, err := tx.Exec(ctx, ` + INSERT INTO signing_keys ( + id, tenant_id, kid, algorithm, status, public_key, + encrypted_private_key, created_at, revoked_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + ON CONFLICT (id) DO UPDATE SET + status = EXCLUDED.status, + public_key = EXCLUDED.public_key, + encrypted_private_key = EXCLUDED.encrypted_private_key, + revoked_at = EXCLUDED.revoked_at + `, key.ID, key.TenantID, key.KID, key.Algorithm, key.Status, key.PublicKey, nullableBytes(private), nonZeroTime(key.CreatedAt), nullableTime(key.RevokedAt)); err != nil { + return fmt.Errorf("upsert signing key row: %w", err) + } + } + for _, signature := range state.Signatures { + if signature.ID == "" || signature.TenantID == "" || signature.KeyID == "" { + continue + } + if _, err := tx.Exec(ctx, ` + INSERT INTO signatures ( + id, tenant_id, subject_type, subject_id, key_id, algorithm, value, created_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + ON CONFLICT (id) DO NOTHING + `, signature.ID, signature.TenantID, signature.SubjectType, signature.SubjectID, signature.KeyID, signature.Algorithm, signature.Value, nonZeroTime(signature.CreatedAt)); err != nil { + return fmt.Errorf("insert signature row: %w", err) + } + } + for _, sbom := range state.SBOMs { + if sbom.ID == "" || sbom.TenantID == "" || sbom.EvidenceID == "" { + continue + } + components, err := json.Marshal(sbom.Components) + if err != nil { + return fmt.Errorf("encode sbom components: %w", err) + } + if _, err := tx.Exec(ctx, ` + INSERT INTO sboms ( + id, tenant_id, evidence_id, release_id, artifact_id, format, + spec_version, component_count, components, created_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + ON CONFLICT (id) DO UPDATE SET components = EXCLUDED.components, component_count = EXCLUDED.component_count + `, sbom.ID, sbom.TenantID, sbom.EvidenceID, nullableString(sbom.ReleaseID), nullableString(sbom.ArtifactID), sbom.Format, sbom.SpecVersion, sbom.ComponentCount, components, nonZeroTime(sbom.CreatedAt)); err != nil { + return fmt.Errorf("upsert sbom row: %w", err) + } + } + for _, scan := range state.Scans { + if scan.ID == "" || scan.TenantID == "" || scan.EvidenceID == "" { + continue + } + summary, err := json.Marshal(scan.Summary) + if err != nil { + return fmt.Errorf("encode vulnerability scan summary: %w", err) + } + findings, err := json.Marshal(scan.Findings) + if err != nil { + return fmt.Errorf("encode vulnerability scan findings: %w", err) + } + if _, err := tx.Exec(ctx, ` + INSERT INTO vulnerability_scans ( + id, tenant_id, evidence_id, release_id, scanner, target_ref, + summary, findings, created_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + ON CONFLICT (id) DO UPDATE SET summary = EXCLUDED.summary, findings = EXCLUDED.findings + `, scan.ID, scan.TenantID, scan.EvidenceID, nullableString(scan.ReleaseID), scan.Scanner, scan.TargetRef, summary, findings, nonZeroTime(scan.CreatedAt)); err != nil { + return fmt.Errorf("upsert vulnerability scan row: %w", err) + } + } + for _, contract := range state.Contracts { + if contract.ID == "" || contract.TenantID == "" || contract.ProductID == "" || contract.EvidenceID == "" { + continue + } + operations, err := json.Marshal(contract.Operations) + if err != nil { + return fmt.Errorf("encode openapi operations: %w", err) + } + if _, err := tx.Exec(ctx, ` + INSERT INTO openapi_contracts ( + id, tenant_id, product_id, release_id, version, hash, + path_count, operations, evidence_id, created_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + ON CONFLICT (id) DO UPDATE SET path_count = EXCLUDED.path_count, operations = EXCLUDED.operations + `, contract.ID, contract.TenantID, contract.ProductID, nullableString(contract.ReleaseID), contract.Version, contract.Hash, contract.PathCount, operations, contract.EvidenceID, nonZeroTime(contract.CreatedAt)); err != nil { + return fmt.Errorf("upsert openapi contract row: %w", err) + } + } + for _, policy := range state.Policies { + if policy.ID == "" || policy.TenantID == "" || policy.ReleaseID == "" { + continue + } + checks, err := json.Marshal(policy.Checks) + if err != nil { + return fmt.Errorf("encode policy checks: %w", err) + } + if _, err := tx.Exec(ctx, ` + INSERT INTO policy_evaluations ( + id, tenant_id, release_id, result, policy_set, checks, created_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7) + ON CONFLICT (id) DO UPDATE SET result = EXCLUDED.result, checks = EXCLUDED.checks + `, policy.ID, policy.TenantID, policy.ReleaseID, policy.Result, policy.PolicySet, checks, nonZeroTime(policy.CreatedAt)); err != nil { + return fmt.Errorf("upsert policy evaluation row: %w", err) + } + } + for _, bundle := range state.Bundles { + if bundle.ID == "" || bundle.TenantID == "" || bundle.ReleaseID == "" { + continue + } + manifest, err := json.Marshal(bundle.Manifest) + if err != nil { + return fmt.Errorf("encode release bundle manifest: %w", err) + } + signatureRefs, err := json.Marshal(bundle.SignatureRefs) + if err != nil { + return fmt.Errorf("encode release bundle signature refs: %w", err) + } + if _, err := tx.Exec(ctx, ` + INSERT INTO release_bundles ( + id, tenant_id, release_id, state, manifest, manifest_hash, + signature_refs, created_at, published_at, revoked_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + ON CONFLICT (id) DO UPDATE SET + state = EXCLUDED.state, + signature_refs = EXCLUDED.signature_refs, + published_at = EXCLUDED.published_at, + revoked_at = EXCLUDED.revoked_at + `, bundle.ID, bundle.TenantID, bundle.ReleaseID, bundle.State, manifest, bundle.ManifestHash, signatureRefs, nonZeroTime(bundle.CreatedAt), nullableTime(bundle.PublishedAt), nullableTime(bundle.RevokedAt)); err != nil { + return fmt.Errorf("upsert release bundle row: %w", err) + } + } + for _, verification := range state.Verifications { + if verification.ID == "" || verification.TenantID == "" { + continue + } + checks, err := json.Marshal(verification.Checks) + if err != nil { + return fmt.Errorf("encode verification checks: %w", err) + } + if _, err := tx.Exec(ctx, ` + INSERT INTO verification_results ( + id, tenant_id, subject_type, subject_id, result, checks, verified_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7) + ON CONFLICT (id) DO UPDATE SET result = EXCLUDED.result, checks = EXCLUDED.checks + `, verification.ID, verification.TenantID, verification.SubjectType, verification.SubjectID, verification.Result, checks, nonZeroTime(verification.VerifiedAt)); err != nil { + return fmt.Errorf("upsert verification result row: %w", err) + } + } + return nil +} + +func syncRiskBuildControlRows(ctx context.Context, tx pgx.Tx, state app.PersistedState) error { + for _, collector := range state.Collectors { + if collector.ID == "" || collector.TenantID == "" || collector.APIKeyID == "" { + continue + } + allowedScopes, err := json.Marshal(collector.AllowedScopes) + if err != nil { + return fmt.Errorf("encode collector scopes: %w", err) + } + if _, err := tx.Exec(ctx, ` + INSERT INTO collectors ( + id, tenant_id, name, type, version, api_key_id, status, + allowed_scopes, last_seen_at, schema_version, created_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + ON CONFLICT (id) DO UPDATE SET + name = EXCLUDED.name, + version = EXCLUDED.version, + api_key_id = EXCLUDED.api_key_id, + status = EXCLUDED.status, + allowed_scopes = EXCLUDED.allowed_scopes, + last_seen_at = EXCLUDED.last_seen_at, + schema_version = EXCLUDED.schema_version + `, collector.ID, collector.TenantID, collector.Name, collector.Type, collector.Version, collector.APIKeyID, collector.Status, allowedScopes, nullableTime(collector.LastSeenAt), collector.SchemaVersion, nonZeroTime(collector.CreatedAt)); err != nil { + return fmt.Errorf("upsert collector row: %w", err) + } + } + for _, build := range state.BuildRuns { + if build.ID == "" || build.TenantID == "" || build.ProjectID == "" || build.ReleaseID == "" { + continue + } + sourceIdentity, err := json.Marshal(build.SourceIdentity) + if err != nil { + return fmt.Errorf("encode build source identity: %w", err) + } + outputs, err := json.Marshal(build.Outputs) + if err != nil { + return fmt.Errorf("encode build outputs: %w", err) + } + if _, err := tx.Exec(ctx, ` + INSERT INTO build_runs ( + id, tenant_id, project_id, release_id, collector_id, provider, + commit_sha, repository, workflow_ref, run_id, run_attempt, + job_id, actor, ref, oidc_subject, status, started_at, + finished_at, parameters_hash, environment_hash, source_identity, + outputs, schema_version, created_at + ) + VALUES ( + $1, $2, $3, $4, $5, $6, + $7, $8, $9, $10, $11, + $12, $13, $14, $15, $16, $17, + $18, $19, $20, $21, + $22, $23, $24 + ) + ON CONFLICT (id) DO UPDATE SET + status = EXCLUDED.status, + finished_at = EXCLUDED.finished_at, + parameters_hash = EXCLUDED.parameters_hash, + environment_hash = EXCLUDED.environment_hash, + source_identity = EXCLUDED.source_identity, + outputs = EXCLUDED.outputs + `, build.ID, build.TenantID, build.ProjectID, build.ReleaseID, nullableString(build.CollectorID), build.Provider, + build.CommitSHA, nullableString(build.Repository), nullableString(build.WorkflowRef), nullableString(build.RunID), nullableInt(build.RunAttempt), + nullableString(build.JobID), nullableString(build.Actor), nullableString(build.Ref), nullableString(build.OIDCSubject), build.Status, nonZeroTime(build.StartedAt), + nullableTime(build.FinishedAt), nullableString(build.ParametersHash), nullableString(build.EnvironmentHash), sourceIdentity, + outputs, build.SchemaVersion, nonZeroTime(build.CreatedAt)); err != nil { + return fmt.Errorf("upsert build run row: %w", err) + } + } + for _, attestation := range state.BuildAttestations { + if attestation.ID == "" || attestation.TenantID == "" || attestation.BuildID == "" || attestation.EvidenceID == "" { + continue + } + subjectDigests, err := json.Marshal(attestation.SubjectDigests) + if err != nil { + return fmt.Errorf("encode attestation subject digests: %w", err) + } + if _, err := tx.Exec(ctx, ` + INSERT INTO build_attestations ( + id, tenant_id, build_id, evidence_id, payload_ref, payload_hash, + payload_size, payload_type, predicate_type, subject_digests, + builder_id, build_type, materials_count, signature_count, + verification_status, schema_version, created_at + ) + VALUES ( + $1, $2, $3, $4, $5, $6, + $7, $8, $9, $10, + $11, $12, $13, $14, + $15, $16, $17 + ) + ON CONFLICT (id) DO UPDATE SET + payload_ref = EXCLUDED.payload_ref, + payload_hash = EXCLUDED.payload_hash, + payload_size = EXCLUDED.payload_size, + predicate_type = EXCLUDED.predicate_type, + subject_digests = EXCLUDED.subject_digests, + builder_id = EXCLUDED.builder_id, + build_type = EXCLUDED.build_type, + materials_count = EXCLUDED.materials_count, + signature_count = EXCLUDED.signature_count, + verification_status = EXCLUDED.verification_status + `, attestation.ID, attestation.TenantID, attestation.BuildID, attestation.EvidenceID, nullableString(attestation.PayloadRef), attestation.PayloadHash, + attestation.PayloadSize, attestation.PayloadType, attestation.PredicateType, subjectDigests, + nullableString(attestation.BuilderID), nullableString(attestation.BuildType), attestation.MaterialsCount, attestation.SignatureCount, + attestation.VerificationStatus, attestation.SchemaVersion, nonZeroTime(attestation.CreatedAt)); err != nil { + return fmt.Errorf("upsert build attestation row: %w", err) + } + } + for _, document := range state.VEXDocuments { + if document.ID == "" || document.TenantID == "" || document.EvidenceID == "" { + continue + } + statusSummary, err := json.Marshal(document.StatusSummary) + if err != nil { + return fmt.Errorf("encode vex status summary: %w", err) + } + if _, err := tx.Exec(ctx, ` + INSERT INTO vex_documents ( + id, tenant_id, evidence_id, release_id, artifact_id, format, + author, version, statement_count, status_summary, + schema_version, created_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + ON CONFLICT (id) DO UPDATE SET + statement_count = EXCLUDED.statement_count, + status_summary = EXCLUDED.status_summary + `, document.ID, document.TenantID, document.EvidenceID, nullableString(document.ReleaseID), nullableString(document.ArtifactID), document.Format, + document.Author, nullableString(document.Version), document.StatementCount, statusSummary, + document.SchemaVersion, nonZeroTime(document.CreatedAt)); err != nil { + return fmt.Errorf("upsert vex document row: %w", err) + } + } + for _, report := range state.VEXImportReports { + if report.ID == "" || report.TenantID == "" || report.VEXDocumentID == "" || report.EvidenceID == "" { + continue + } + warnings, err := json.Marshal(report.Warnings) + if err != nil { + return fmt.Errorf("encode vex import warnings: %w", err) + } + invalidStatements, err := json.Marshal(report.InvalidStatements) + if err != nil { + return fmt.Errorf("encode vex import invalid statements: %w", err) + } + mappingFailures, err := json.Marshal(report.MappingFailures) + if err != nil { + return fmt.Errorf("encode vex import mapping failures: %w", err) + } + if _, err := tx.Exec(ctx, ` + INSERT INTO vex_import_reports ( + id, tenant_id, vex_document_id, evidence_id, release_id, artifact_id, + parser_version, status, statement_count, decisions_created, + decisions_superseded, unsupported_fields, warnings, invalid_statements, + mapping_failures, failure_code, failure_detail, schema_version, created_at, updated_at + ) + VALUES ( + $1, $2, $3, $4, $5, $6, + $7, $8, $9, $10, + $11, $12, $13, $14, + $15, $16, $17, $18, $19, $20 + ) + ON CONFLICT (id) DO UPDATE SET + status = EXCLUDED.status, + statement_count = EXCLUDED.statement_count, + decisions_created = EXCLUDED.decisions_created, + decisions_superseded = EXCLUDED.decisions_superseded, + unsupported_fields = EXCLUDED.unsupported_fields, + warnings = EXCLUDED.warnings, + invalid_statements = EXCLUDED.invalid_statements, + mapping_failures = EXCLUDED.mapping_failures, + failure_code = EXCLUDED.failure_code, + failure_detail = EXCLUDED.failure_detail, + updated_at = EXCLUDED.updated_at + `, report.ID, report.TenantID, report.VEXDocumentID, report.EvidenceID, nullableString(report.ReleaseID), nullableString(report.ArtifactID), + report.ParserVersion, report.Status, report.StatementCount, report.DecisionsCreated, + report.DecisionsSuperseded, textArray(report.UnsupportedFields), warnings, invalidStatements, + mappingFailures, report.FailureCode, report.FailureDetail, report.SchemaVersion, nonZeroTime(report.CreatedAt), nonZeroTime(report.UpdatedAt)); err != nil { + return fmt.Errorf("upsert vex import report row: %w", err) + } + } + for _, decision := range state.Decisions { + if decision.ID == "" || decision.TenantID == "" || decision.FindingID == "" || decision.ScanID == "" { + continue + } + supportingRefs := decision.SupportingRefs + if supportingRefs == nil { + supportingRefs = []domain.SubjectRef{} + } + supportingRefsJSON, err := json.Marshal(supportingRefs) + if err != nil { + return fmt.Errorf("encode vulnerability decision supporting refs: %w", err) + } + if _, err := tx.Exec(ctx, ` + INSERT INTO vulnerability_decisions ( + id, tenant_id, finding_id, scan_id, release_id, vulnerability, + component, sbom_id, sbom_component_purl, sbom_component_name, + status, justification, impact_statement, action_statement, + customer_visible, internal_notes, source, evidence_id, evidence_ids, supporting_refs, vex_document_id, + supersedes, superseded_by, approved_by, reviewed_at, review_due_at, schema_version, created_at + ) + VALUES ( + $1, $2, $3, $4, $5, $6, + $7, $8, $9, $10, + $11, $12, $13, $14, + $15, $16, $17, $18, $19, $20, + $21, $22, $23, $24, $25, $26, $27, $28 + ) + ON CONFLICT (id) DO UPDATE SET + superseded_by = EXCLUDED.superseded_by, + approved_by = EXCLUDED.approved_by, + reviewed_at = COALESCE(vulnerability_decisions.reviewed_at, EXCLUDED.reviewed_at), + review_due_at = COALESCE(vulnerability_decisions.review_due_at, EXCLUDED.review_due_at), + sbom_id = COALESCE(NULLIF(vulnerability_decisions.sbom_id, ''), EXCLUDED.sbom_id), + sbom_component_purl = COALESCE(NULLIF(vulnerability_decisions.sbom_component_purl, ''), EXCLUDED.sbom_component_purl), + sbom_component_name = COALESCE(NULLIF(vulnerability_decisions.sbom_component_name, ''), EXCLUDED.sbom_component_name), + customer_visible = vulnerability_decisions.customer_visible OR EXCLUDED.customer_visible, + internal_notes = COALESCE(NULLIF(vulnerability_decisions.internal_notes, ''), EXCLUDED.internal_notes), + evidence_ids = CASE + WHEN cardinality(vulnerability_decisions.evidence_ids) = 0 THEN EXCLUDED.evidence_ids + ELSE vulnerability_decisions.evidence_ids + END, + supporting_refs = CASE + WHEN vulnerability_decisions.supporting_refs = '[]'::jsonb THEN EXCLUDED.supporting_refs + ELSE vulnerability_decisions.supporting_refs + END + `, decision.ID, decision.TenantID, decision.FindingID, decision.ScanID, nullableString(decision.ReleaseID), decision.Vulnerability, + nullableString(decision.Component), nullableString(decision.SBOMID), nullableString(decision.SBOMComponentPURL), nullableString(decision.SBOMComponentName), + decision.Status, decision.Justification, nullableString(decision.ImpactStatement), nullableString(decision.ActionStatement), + decision.CustomerVisible, nullableString(decision.InternalNotes), decision.Source, nullableString(decision.EvidenceID), textArray(decision.EvidenceIDs), supportingRefsJSON, nullableString(decision.VEXDocumentID), nullableString(decision.Supersedes), nullableString(decision.SupersededBy), + nullableString(decision.ApprovedBy), nullableTime(decision.ReviewedAt), nullableTime(decision.ReviewDueAt), decision.SchemaVersion, nonZeroTime(decision.CreatedAt)); err != nil { + return fmt.Errorf("upsert vulnerability decision row: %w", err) + } + } + for _, exception := range state.Exceptions { + if exception.ID == "" || exception.TenantID == "" || exception.ReleaseID == "" { + continue + } + if _, err := tx.Exec(ctx, ` + INSERT INTO exceptions ( + id, tenant_id, release_id, finding_id, control_id, reason, + owner, expires_at, approved, approved_by, approved_at, created_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + ON CONFLICT (id) DO UPDATE SET + approved = EXCLUDED.approved, + approved_by = EXCLUDED.approved_by, + approved_at = EXCLUDED.approved_at + `, exception.ID, exception.TenantID, exception.ReleaseID, nullableString(exception.FindingID), nullableString(exception.ControlID), exception.Reason, + exception.Owner, exception.ExpiresAt, exception.Approved, nullableString(exception.ApprovedBy), nullableTime(exception.ApprovedAt), nonZeroTime(exception.CreatedAt)); err != nil { + return fmt.Errorf("upsert exception row: %w", err) + } + } + for _, framework := range state.ControlFrameworks { + if framework.ID == "" || framework.TenantID == "" { + continue + } + if _, err := tx.Exec(ctx, ` + INSERT INTO control_frameworks ( + id, tenant_id, name, slug, version, description, + status, schema_version, created_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + ON CONFLICT (id) DO UPDATE SET + description = EXCLUDED.description, + status = EXCLUDED.status, + schema_version = EXCLUDED.schema_version + `, framework.ID, framework.TenantID, framework.Name, framework.Slug, framework.Version, nullableString(framework.Description), + framework.Status, framework.SchemaVersion, nonZeroTime(framework.CreatedAt)); err != nil { + return fmt.Errorf("upsert control framework row: %w", err) + } + } + for _, control := range state.SecurityControls { + if control.ID == "" || control.TenantID == "" || control.FrameworkID == "" { + continue + } + requirements, err := json.Marshal(control.EvidenceRequirements) + if err != nil { + return fmt.Errorf("encode control requirements: %w", err) + } + applicability, err := json.Marshal(control.Applicability) + if err != nil { + return fmt.Errorf("encode control applicability: %w", err) + } + limitations, err := json.Marshal(control.Limitations) + if err != nil { + return fmt.Errorf("encode control limitations: %w", err) + } + if _, err := tx.Exec(ctx, ` + INSERT INTO security_controls ( + id, tenant_id, framework_id, code, title, objective, + evidence_requirements, applicability, limitations, + schema_version, created_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + ON CONFLICT (id) DO UPDATE SET + title = EXCLUDED.title, + objective = EXCLUDED.objective, + evidence_requirements = EXCLUDED.evidence_requirements, + applicability = EXCLUDED.applicability, + limitations = EXCLUDED.limitations, + schema_version = EXCLUDED.schema_version + `, control.ID, control.TenantID, control.FrameworkID, control.Code, control.Title, control.Objective, + requirements, applicability, limitations, control.SchemaVersion, nonZeroTime(control.CreatedAt)); err != nil { + return fmt.Errorf("upsert security control row: %w", err) + } + } + for _, evidence := range state.ControlEvidence { + if evidence.ID == "" || evidence.TenantID == "" || evidence.ControlID == "" { + continue + } + if _, err := tx.Exec(ctx, ` + INSERT INTO control_evidence ( + id, tenant_id, control_id, evidence_type, subject_type, + subject_id, product_id, release_id, confidence, notes, + schema_version, created_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + ON CONFLICT (id) DO UPDATE SET + confidence = EXCLUDED.confidence, + notes = EXCLUDED.notes, + schema_version = EXCLUDED.schema_version + `, evidence.ID, evidence.TenantID, evidence.ControlID, evidence.EvidenceType, evidence.SubjectType, + evidence.SubjectID, nullableString(evidence.ProductID), nullableString(evidence.ReleaseID), evidence.Confidence, nullableString(evidence.Notes), + evidence.SchemaVersion, nonZeroTime(evidence.CreatedAt)); err != nil { + return fmt.Errorf("upsert control evidence row: %w", err) + } + } + return nil +} + +func syncSourceDeploymentLifecycleRows(ctx context.Context, tx pgx.Tx, state app.PersistedState) error { + for _, event := range state.EvidenceLifecycle { + if event.ID == "" || event.TenantID == "" || event.EvidenceID == "" { + continue + } + details, err := json.Marshal(event.Details) + if err != nil { + return fmt.Errorf("encode evidence lifecycle details: %w", err) + } + if _, err := tx.Exec(ctx, ` + INSERT INTO evidence_lifecycle_events ( + id, tenant_id, evidence_id, action, reason, details, + replacement_id, actor_id, schema_version, created_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + ON CONFLICT (id) DO NOTHING + `, event.ID, event.TenantID, event.EvidenceID, event.Action, event.Reason, details, + nullableString(event.ReplacementID), event.ActorID, event.SchemaVersion, nonZeroTime(event.CreatedAt)); err != nil { + return fmt.Errorf("insert evidence lifecycle row: %w", err) + } + } + for _, candidate := range state.ReleaseCandidates { + if candidate.ID == "" || candidate.TenantID == "" || candidate.ReleaseID == "" { + continue + } + document, err := json.Marshal(candidate) + if err != nil { + return fmt.Errorf("encode release candidate document: %w", err) + } + if _, err := tx.Exec(ctx, ` + INSERT INTO release_candidates ( + id, tenant_id, release_id, name, state, snapshot_hash, + document, schema_version, created_at, promoted_at, rejected_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + ON CONFLICT (id) DO UPDATE SET + state = EXCLUDED.state, + document = EXCLUDED.document, + promoted_at = EXCLUDED.promoted_at, + rejected_at = EXCLUDED.rejected_at + `, candidate.ID, candidate.TenantID, candidate.ReleaseID, candidate.Name, candidate.State, candidate.SnapshotHash, + document, candidate.SchemaVersion, nonZeroTime(candidate.CreatedAt), nullableTime(candidate.PromotedAt), nullableTime(candidate.RejectedAt)); err != nil { + return fmt.Errorf("upsert release candidate row: %w", err) + } + } + for _, image := range state.ContainerImages { + if image.ID == "" || image.TenantID == "" || image.Repository == "" { + continue + } + if _, err := tx.Exec(ctx, ` + INSERT INTO container_images ( + id, tenant_id, artifact_id, repository, tag, digest, + platform, schema_version, created_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + ON CONFLICT (id) DO UPDATE SET + tag = EXCLUDED.tag, + platform = EXCLUDED.platform + `, image.ID, image.TenantID, nullableString(image.ArtifactID), image.Repository, nullableString(image.Tag), image.Digest, + nullableString(image.Platform), image.SchemaVersion, nonZeroTime(image.CreatedAt)); err != nil { + return fmt.Errorf("upsert container image row: %w", err) + } + } + for _, signature := range state.ArtifactSignatures { + if signature.ID == "" || signature.TenantID == "" || signature.ArtifactID == "" { + continue + } + if _, err := tx.Exec(ctx, ` + INSERT INTO artifact_signatures ( + id, tenant_id, artifact_id, subject_digest, algorithm, key_id, + signature, payload_ref, payload_hash, verification_status, + schema_version, created_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + ON CONFLICT (id) DO UPDATE SET + payload_ref = EXCLUDED.payload_ref, + payload_hash = EXCLUDED.payload_hash, + verification_status = EXCLUDED.verification_status + `, signature.ID, signature.TenantID, signature.ArtifactID, signature.SubjectDigest, signature.Algorithm, nullableString(signature.KeyID), + signature.Signature, nullableString(signature.PayloadRef), nullableString(signature.PayloadHash), signature.VerificationStatus, + signature.SchemaVersion, nonZeroTime(signature.CreatedAt)); err != nil { + return fmt.Errorf("upsert artifact signature row: %w", err) + } + } + for _, repository := range state.Repositories { + if repository.ID == "" || repository.TenantID == "" || repository.FullName == "" { + continue + } + if _, err := tx.Exec(ctx, ` + INSERT INTO source_repositories ( + id, tenant_id, project_id, provider, full_name, clone_url, + default_branch, schema_version, created_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + ON CONFLICT (id) DO UPDATE SET + clone_url = EXCLUDED.clone_url, + default_branch = EXCLUDED.default_branch + `, repository.ID, repository.TenantID, nullableString(repository.ProjectID), repository.Provider, repository.FullName, nullableString(repository.CloneURL), + nullableString(repository.DefaultBranch), repository.SchemaVersion, nonZeroTime(repository.CreatedAt)); err != nil { + return fmt.Errorf("upsert source repository row: %w", err) + } + } + for _, commit := range state.Commits { + if commit.ID == "" || commit.TenantID == "" || commit.RepositoryID == "" || commit.SHA == "" { + continue + } + if _, err := tx.Exec(ctx, ` + INSERT INTO source_commits ( + id, tenant_id, repository_id, sha, author, message_hash, + committed_at, schema_version, created_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + ON CONFLICT (id) DO UPDATE SET message_hash = EXCLUDED.message_hash + `, commit.ID, commit.TenantID, commit.RepositoryID, commit.SHA, nullableString(commit.Author), nullableString(commit.MessageHash), + nonZeroTime(commit.CommittedAt), commit.SchemaVersion, nonZeroTime(commit.CreatedAt)); err != nil { + return fmt.Errorf("upsert source commit row: %w", err) + } + } + for _, branch := range state.Branches { + if branch.ID == "" || branch.TenantID == "" || branch.RepositoryID == "" || branch.Name == "" { + continue + } + if _, err := tx.Exec(ctx, ` + INSERT INTO source_branches ( + id, tenant_id, repository_id, name, head_commit_id, protected, + protection_hash, schema_version, created_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + ON CONFLICT (id) DO UPDATE SET + head_commit_id = EXCLUDED.head_commit_id, + protected = EXCLUDED.protected, + protection_hash = EXCLUDED.protection_hash + `, branch.ID, branch.TenantID, branch.RepositoryID, branch.Name, nullableString(branch.HeadCommitID), branch.Protected, + nullableString(branch.ProtectionHash), branch.SchemaVersion, nonZeroTime(branch.CreatedAt)); err != nil { + return fmt.Errorf("upsert source branch row: %w", err) + } + } + for _, pr := range state.PullRequests { + if pr.ID == "" || pr.TenantID == "" || pr.RepositoryID == "" { + continue + } + if _, err := tx.Exec(ctx, ` + INSERT INTO pull_requests ( + id, tenant_id, repository_id, provider, provider_id, title, + state, source_branch, target_branch, head_commit_id, + review_decision, schema_version, created_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) + ON CONFLICT (id) DO UPDATE SET + state = EXCLUDED.state, + head_commit_id = EXCLUDED.head_commit_id, + review_decision = EXCLUDED.review_decision + `, pr.ID, pr.TenantID, pr.RepositoryID, pr.Provider, pr.ProviderID, pr.Title, + pr.State, nullableString(pr.SourceBranch), nullableString(pr.TargetBranch), nullableString(pr.HeadCommitID), + nullableString(pr.ReviewDecision), pr.SchemaVersion, nonZeroTime(pr.CreatedAt)); err != nil { + return fmt.Errorf("upsert pull request row: %w", err) + } + } + for _, environment := range state.Environments { + if environment.ID == "" || environment.TenantID == "" || environment.ProductID == "" { + continue + } + if _, err := tx.Exec(ctx, ` + INSERT INTO deployment_environments ( + id, tenant_id, product_id, name, kind, schema_version, created_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7) + ON CONFLICT (id) DO UPDATE SET kind = EXCLUDED.kind + `, environment.ID, environment.TenantID, environment.ProductID, environment.Name, environment.Kind, environment.SchemaVersion, nonZeroTime(environment.CreatedAt)); err != nil { + return fmt.Errorf("upsert deployment environment row: %w", err) + } + } + for _, deployment := range state.Deployments { + if deployment.ID == "" || deployment.TenantID == "" || deployment.EnvironmentID == "" || deployment.ReleaseID == "" { + continue + } + if _, err := tx.Exec(ctx, ` + INSERT INTO deployment_events ( + id, tenant_id, environment_id, release_id, artifact_ids, + status, started_at, finished_at, rollback_of, evidence_id, + schema_version, created_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + ON CONFLICT (id) DO UPDATE SET + status = EXCLUDED.status, + finished_at = EXCLUDED.finished_at + `, deployment.ID, deployment.TenantID, deployment.EnvironmentID, deployment.ReleaseID, deployment.ArtifactIDs, + deployment.Status, nonZeroTime(deployment.StartedAt), nullableTime(deployment.FinishedAt), nullableString(deployment.RollbackOf), nullableString(deployment.EvidenceID), + deployment.SchemaVersion, nonZeroTime(deployment.CreatedAt)); err != nil { + return fmt.Errorf("upsert deployment event row: %w", err) + } + } + return nil +} + +func syncIncidentSecurityGovernanceRows(ctx context.Context, tx pgx.Tx, state app.PersistedState) error { + for _, incident := range state.Incidents { + if incident.ID == "" || incident.TenantID == "" || incident.ProductID == "" { + continue + } + if _, err := tx.Exec(ctx, ` + INSERT INTO incidents ( + id, tenant_id, product_id, release_id, title, severity, status, + opened_at, closed_at, schema_version, created_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + ON CONFLICT (id) DO UPDATE SET + status = EXCLUDED.status, + closed_at = EXCLUDED.closed_at + `, incident.ID, incident.TenantID, incident.ProductID, nullableString(incident.ReleaseID), incident.Title, incident.Severity, incident.Status, + nonZeroTime(incident.OpenedAt), nullableTime(incident.ClosedAt), incident.SchemaVersion, nonZeroTime(incident.CreatedAt)); err != nil { + return fmt.Errorf("upsert incident row: %w", err) + } + } + for _, event := range state.TimelineEvents { + if event.ID == "" || event.TenantID == "" || event.IncidentID == "" { + continue + } + if _, err := tx.Exec(ctx, ` + INSERT INTO incident_timeline_events ( + id, tenant_id, incident_id, event_type, summary, evidence_id, + occurred_at, schema_version, created_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + ON CONFLICT (id) DO NOTHING + `, event.ID, event.TenantID, event.IncidentID, event.EventType, event.Summary, nullableString(event.EvidenceID), + nonZeroTime(event.OccurredAt), event.SchemaVersion, nonZeroTime(event.CreatedAt)); err != nil { + return fmt.Errorf("insert incident timeline row: %w", err) + } + } + for _, receiver := range state.IncidentWebhookReceivers { + if receiver.ID == "" || receiver.TenantID == "" || receiver.IncidentID == "" { + continue + } + if _, err := tx.Exec(ctx, ` + INSERT INTO incident_webhook_receivers ( + id, tenant_id, incident_id, name, provider, public_key, status, + schema_version, created_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + ON CONFLICT (id) DO UPDATE SET status = EXCLUDED.status + `, receiver.ID, receiver.TenantID, receiver.IncidentID, receiver.Name, receiver.Provider, receiver.PublicKey, receiver.Status, receiver.SchemaVersion, nonZeroTime(receiver.CreatedAt)); err != nil { + return fmt.Errorf("upsert incident webhook receiver row: %w", err) + } + } + for _, event := range state.IncidentWebhookEvents { + if event.ID == "" || event.TenantID == "" || event.ReceiverID == "" { + continue + } + if _, err := tx.Exec(ctx, ` + INSERT INTO incident_webhook_events ( + id, tenant_id, receiver_id, incident_id, provider, event_id, + payload_hash, signature_hash, timeline_event_id, result, + schema_version, created_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + ON CONFLICT (id) DO UPDATE SET result = EXCLUDED.result, timeline_event_id = EXCLUDED.timeline_event_id + `, event.ID, event.TenantID, event.ReceiverID, event.IncidentID, event.Provider, event.EventID, + event.PayloadHash, event.SignatureHash, nullableString(event.TimelineEventID), event.Result, event.SchemaVersion, nonZeroTime(event.CreatedAt)); err != nil { + return fmt.Errorf("upsert incident webhook event row: %w", err) + } + } + for _, task := range state.RemediationTasks { + if task.ID == "" || task.TenantID == "" { + continue + } + if _, err := tx.Exec(ctx, ` + INSERT INTO remediation_tasks ( + id, tenant_id, incident_id, release_id, title, owner, status, + due_at, evidence_id, schema_version, created_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + ON CONFLICT (id) DO UPDATE SET status = EXCLUDED.status, evidence_id = EXCLUDED.evidence_id + `, task.ID, task.TenantID, nullableString(task.IncidentID), nullableString(task.ReleaseID), task.Title, task.Owner, task.Status, + nullableTime(task.DueAt), nullableString(task.EvidenceID), task.SchemaVersion, nonZeroTime(task.CreatedAt)); err != nil { + return fmt.Errorf("upsert remediation task row: %w", err) + } + } + for _, scan := range state.SecurityScans { + if scan.ID == "" || scan.TenantID == "" || scan.EvidenceID == "" { + continue + } + summary, err := json.Marshal(scan.Summary) + if err != nil { + return fmt.Errorf("encode security scan summary: %w", err) + } + if _, err := tx.Exec(ctx, ` + INSERT INTO security_scans ( + id, tenant_id, product_id, release_id, artifact_id, category, + format, scanner, target_ref, evidence_id, payload_ref, + payload_hash, finding_count, summary, redacted, quarantined, + schema_version, created_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18) + ON CONFLICT (id) DO UPDATE SET + finding_count = EXCLUDED.finding_count, + summary = EXCLUDED.summary, + redacted = EXCLUDED.redacted, + quarantined = EXCLUDED.quarantined + `, scan.ID, scan.TenantID, nullableString(scan.ProductID), nullableString(scan.ReleaseID), nullableString(scan.ArtifactID), scan.Category, + scan.Format, scan.Scanner, scan.TargetRef, scan.EvidenceID, nullableString(scan.PayloadRef), + scan.PayloadHash, scan.FindingCount, summary, scan.Redacted, scan.Quarantined, + scan.SchemaVersion, nonZeroTime(scan.CreatedAt)); err != nil { + return fmt.Errorf("upsert security scan row: %w", err) + } + } + for _, document := range state.ManualSecurityDocs { + if document.ID == "" || document.TenantID == "" || document.EvidenceID == "" { + continue + } + if _, err := tx.Exec(ctx, ` + INSERT INTO manual_security_documents ( + id, tenant_id, product_id, release_id, document_type, title, + sensitivity, evidence_id, payload_ref, payload_hash, + schema_version, created_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + ON CONFLICT (id) DO UPDATE SET sensitivity = EXCLUDED.sensitivity + `, document.ID, document.TenantID, nullableString(document.ProductID), nullableString(document.ReleaseID), document.DocumentType, document.Title, + document.Sensitivity, document.EvidenceID, nullableString(document.PayloadRef), document.PayloadHash, document.SchemaVersion, nonZeroTime(document.CreatedAt)); err != nil { + return fmt.Errorf("upsert manual security document row: %w", err) + } + } + for _, diff := range state.SBOMDiffs { + if diff.ID == "" || diff.TenantID == "" || diff.BaseSBOMID == "" || diff.TargetSBOMID == "" { + continue + } + document, err := json.Marshal(diff) + if err != nil { + return fmt.Errorf("encode sbom diff document: %w", err) + } + if _, err := tx.Exec(ctx, ` + INSERT INTO sbom_diffs ( + id, tenant_id, base_sbom_id, target_sbom_id, release_id, + document, schema_version, created_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + ON CONFLICT (id) DO UPDATE SET document = EXCLUDED.document + `, diff.ID, diff.TenantID, diff.BaseSBOMID, diff.TargetSBOMID, nullableString(diff.ReleaseID), + document, diff.SchemaVersion, nonZeroTime(diff.CreatedAt)); err != nil { + return fmt.Errorf("upsert sbom diff row: %w", err) + } + } + for _, change := range state.DependencyChanges { + if change.ID == "" || change.TenantID == "" || change.SBOMDiffID == "" { + continue + } + component, err := json.Marshal(change.Component) + if err != nil { + return fmt.Errorf("encode dependency component: %w", err) + } + if _, err := tx.Exec(ctx, ` + INSERT INTO dependency_changes ( + id, tenant_id, sbom_diff_id, change_type, component, + schema_version, created_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7) + ON CONFLICT (id) DO UPDATE SET component = EXCLUDED.component + `, change.ID, change.TenantID, change.SBOMDiffID, change.ChangeType, component, change.SchemaVersion, nonZeroTime(change.CreatedAt)); err != nil { + return fmt.Errorf("upsert dependency change row: %w", err) + } + } + for _, record := range state.VulnerabilityWorkflow { + if record.ID == "" || record.TenantID == "" || record.FindingID == "" { + continue + } + if _, err := tx.Exec(ctx, ` + INSERT INTO vulnerability_workflow_records ( + id, tenant_id, finding_id, release_id, action, reason, + actor_id, schema_version, created_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + ON CONFLICT (id) DO NOTHING + `, record.ID, record.TenantID, record.FindingID, nullableString(record.ReleaseID), record.Action, record.Reason, record.ActorID, record.SchemaVersion, nonZeroTime(record.CreatedAt)); err != nil { + return fmt.Errorf("insert vulnerability workflow row: %w", err) + } + } + for _, diff := range state.ContractDiffs { + if diff.ID == "" || diff.TenantID == "" || diff.BaseContractID == "" || diff.TargetContractID == "" { + continue + } + document, err := json.Marshal(diff) + if err != nil { + return fmt.Errorf("encode contract diff document: %w", err) + } + if _, err := tx.Exec(ctx, ` + INSERT INTO contract_diffs ( + id, tenant_id, base_contract_id, target_contract_id, product_id, + release_id, result, document, schema_version, created_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + ON CONFLICT (id) DO UPDATE SET result = EXCLUDED.result, document = EXCLUDED.document + `, diff.ID, diff.TenantID, diff.BaseContractID, diff.TargetContractID, diff.ProductID, + nullableString(diff.ReleaseID), diff.Result, document, diff.SchemaVersion, nonZeroTime(diff.CreatedAt)); err != nil { + return fmt.Errorf("upsert contract diff row: %w", err) + } + } + for _, policy := range state.CustomPolicies { + if policy.ID == "" || policy.TenantID == "" { + continue + } + rules, err := json.Marshal(policy.Rules) + if err != nil { + return fmt.Errorf("encode custom policy rules: %w", err) + } + if _, err := tx.Exec(ctx, ` + INSERT INTO custom_policies ( + id, tenant_id, name, version, description, rules, + schema_version, created_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + ON CONFLICT (id) DO UPDATE SET description = EXCLUDED.description, rules = EXCLUDED.rules + `, policy.ID, policy.TenantID, policy.Name, policy.Version, nullableString(policy.Description), rules, policy.SchemaVersion, nonZeroTime(policy.CreatedAt)); err != nil { + return fmt.Errorf("upsert custom policy row: %w", err) + } + } + for _, evaluation := range state.CustomPolicyEvaluations { + if evaluation.ID == "" || evaluation.TenantID == "" || evaluation.PolicyID == "" { + continue + } + checks, err := json.Marshal(evaluation.Checks) + if err != nil { + return fmt.Errorf("encode custom policy checks: %w", err) + } + if _, err := tx.Exec(ctx, ` + INSERT INTO custom_policy_evaluations ( + id, tenant_id, policy_id, release_id, result, checks, + input_hash, schema_version, created_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + ON CONFLICT (id) DO UPDATE SET result = EXCLUDED.result, checks = EXCLUDED.checks + `, evaluation.ID, evaluation.TenantID, evaluation.PolicyID, evaluation.ReleaseID, evaluation.Result, checks, evaluation.InputHash, evaluation.SchemaVersion, nonZeroTime(evaluation.CreatedAt)); err != nil { + return fmt.Errorf("upsert custom policy evaluation row: %w", err) + } + } + for _, waiver := range state.Waivers { + if waiver.ID == "" || waiver.TenantID == "" || waiver.ScopeID == "" { + continue + } + if _, err := tx.Exec(ctx, ` + INSERT INTO waivers ( + id, tenant_id, scope_type, scope_id, control_id, policy_id, + owner, risk, reason, expires_at, approved, approved_by, + approved_at, supersedes, superseded_by, schema_version, created_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17) + ON CONFLICT (id) DO UPDATE SET + approved = EXCLUDED.approved, + approved_by = EXCLUDED.approved_by, + approved_at = EXCLUDED.approved_at, + superseded_by = EXCLUDED.superseded_by + `, waiver.ID, waiver.TenantID, waiver.ScopeType, waiver.ScopeID, nullableString(waiver.ControlID), nullableString(waiver.PolicyID), + waiver.Owner, waiver.Risk, waiver.Reason, waiver.ExpiresAt, waiver.Approved, nullableString(waiver.ApprovedBy), + nullableTime(waiver.ApprovedAt), nullableString(waiver.Supersedes), nullableString(waiver.SupersededBy), waiver.SchemaVersion, nonZeroTime(waiver.CreatedAt)); err != nil { + return fmt.Errorf("upsert waiver row: %w", err) + } + } + for _, approval := range state.Approvals { + if approval.ID == "" || approval.TenantID == "" || approval.SubjectID == "" { + continue + } + if _, err := tx.Exec(ctx, ` + INSERT INTO approval_records ( + id, tenant_id, subject_type, subject_id, decision, reason, + approver_id, evidence_id, schema_version, created_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + ON CONFLICT (id) DO NOTHING + `, approval.ID, approval.TenantID, approval.SubjectType, approval.SubjectID, approval.Decision, approval.Reason, + approval.ApproverID, nullableString(approval.EvidenceID), approval.SchemaVersion, nonZeroTime(approval.CreatedAt)); err != nil { + return fmt.Errorf("insert approval row: %w", err) + } + } + for _, root := range state.DSSETrustRoots { + if root.ID == "" || root.TenantID == "" || root.KeyID == "" { + continue + } + if _, err := tx.Exec(ctx, ` + INSERT INTO dsse_trust_roots ( + id, tenant_id, name, key_id, algorithm, public_key, status, + schema_version, created_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + ON CONFLICT (id) DO UPDATE SET status = EXCLUDED.status, public_key = EXCLUDED.public_key + `, root.ID, root.TenantID, root.Name, root.KeyID, root.Algorithm, root.PublicKey, root.Status, root.SchemaVersion, nonZeroTime(root.CreatedAt)); err != nil { + return fmt.Errorf("upsert dsse trust root row: %w", err) + } + } + return nil +} + +func syncIntegrityProviderRows(ctx context.Context, tx pgx.Tx, state app.PersistedState) error { + for _, release := range state.CollectorReleases { + if release.ID == "" || release.TenantID == "" || release.CollectorID == "" { + continue + } + if _, err := tx.Exec(ctx, ` + INSERT INTO collector_releases ( + id, tenant_id, collector_id, version, artifact_digest, + signature_id, sbom_id, scan_id, pinned, verification_status, + health_status, limitations, schema_version, created_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) + ON CONFLICT (id) DO UPDATE SET + pinned = EXCLUDED.pinned, + verification_status = EXCLUDED.verification_status, + health_status = EXCLUDED.health_status, + limitations = EXCLUDED.limitations, + schema_version = EXCLUDED.schema_version + `, release.ID, release.TenantID, release.CollectorID, release.Version, release.ArtifactDigest, + nullableString(release.SignatureID), nullableString(release.SBOMID), nullableString(release.ScanID), + release.Pinned, release.VerificationStatus, release.HealthStatus, textArray(release.Limitations), + release.SchemaVersion, nonZeroTime(release.CreatedAt)); err != nil { + return fmt.Errorf("upsert collector release row: %w", err) + } + } + for _, verification := range state.CosignVerifications { + if verification.ID == "" || verification.TenantID == "" || verification.ArtifactSignatureID == "" { + continue + } + checks, err := json.Marshal(verification.Checks) + if err != nil { + return fmt.Errorf("encode cosign checks: %w", err) + } + if _, err := tx.Exec(ctx, ` + INSERT INTO cosign_verifications ( + id, tenant_id, artifact_id, container_image_id, + artifact_signature_id, subject_digest, rekor_uuid, rekor_log_index, + certificate_identity, certificate_issuer, result, checks, + schema_version, created_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) + ON CONFLICT (id) DO UPDATE SET result = EXCLUDED.result, checks = EXCLUDED.checks, schema_version = EXCLUDED.schema_version + `, verification.ID, verification.TenantID, nullableString(verification.ArtifactID), nullableString(verification.ContainerImageID), + verification.ArtifactSignatureID, verification.SubjectDigest, nullableString(verification.RekorUUID), + nullableString(verification.RekorLogIndex), nullableString(verification.CertificateIdentity), + nullableString(verification.CertificateIssuer), verification.Result, checks, verification.SchemaVersion, + nonZeroTime(verification.CreatedAt)); err != nil { + return fmt.Errorf("upsert cosign verification row: %w", err) + } + } + for _, provider := range state.SigningProviders { + if provider.ID == "" || provider.TenantID == "" { + continue + } + if _, err := tx.Exec(ctx, ` + INSERT INTO signing_providers ( + id, tenant_id, name, type, status, key_ref, encrypted, + schema_version, created_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + ON CONFLICT (id) DO UPDATE SET status = EXCLUDED.status, key_ref = EXCLUDED.key_ref, encrypted = EXCLUDED.encrypted, schema_version = EXCLUDED.schema_version + `, provider.ID, provider.TenantID, provider.Name, provider.Type, provider.Status, provider.KeyRef, provider.Encrypted, provider.SchemaVersion, nonZeroTime(provider.CreatedAt)); err != nil { + return fmt.Errorf("upsert signing provider row: %w", err) + } + } + for _, batch := range state.MerkleBatches { + if batch.ID == "" || batch.TenantID == "" { + continue + } + if _, err := tx.Exec(ctx, ` + INSERT INTO merkle_batches ( + id, tenant_id, from_sequence, to_sequence, entry_count, + leaf_hashes, root_hash, signature_refs, schema_version, created_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + ON CONFLICT (id) DO UPDATE SET signature_refs = EXCLUDED.signature_refs, schema_version = EXCLUDED.schema_version + `, batch.ID, batch.TenantID, batch.FromSequence, batch.ToSequence, batch.EntryCount, textArray(batch.LeafHashes), batch.RootHash, textArray(batch.SignatureRefs), batch.SchemaVersion, nonZeroTime(batch.CreatedAt)); err != nil { + return fmt.Errorf("upsert merkle batch row: %w", err) + } + } + for _, checkpoint := range state.TransparencyCheckpoints { + if checkpoint.ID == "" || checkpoint.TenantID == "" || checkpoint.BatchID == "" { + continue + } + if _, err := tx.Exec(ctx, ` + INSERT INTO transparency_checkpoints ( + id, tenant_id, batch_id, provider, external_url, external_id, + timestamp_hash, state, schema_version, created_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + ON CONFLICT (id) DO UPDATE SET state = EXCLUDED.state, external_url = EXCLUDED.external_url, external_id = EXCLUDED.external_id, schema_version = EXCLUDED.schema_version + `, checkpoint.ID, checkpoint.TenantID, checkpoint.BatchID, checkpoint.Provider, nullableString(checkpoint.ExternalURL), nullableString(checkpoint.ExternalID), checkpoint.TimestampHash, checkpoint.State, checkpoint.SchemaVersion, nonZeroTime(checkpoint.CreatedAt)); err != nil { + return fmt.Errorf("upsert transparency checkpoint row: %w", err) + } + } + return nil +} + +func syncPackageReportRetentionRows(ctx context.Context, tx pgx.Tx, state app.PersistedState) error { + for _, profile := range state.RedactionProfiles { + if profile.ID == "" || profile.TenantID == "" { + continue + } + if _, err := tx.Exec(ctx, ` + INSERT INTO redaction_profiles ( + id, tenant_id, name, description, allowed_types, excluded_fields, + schema_version, created_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + ON CONFLICT (id) DO UPDATE SET + name = EXCLUDED.name, + description = EXCLUDED.description, + allowed_types = EXCLUDED.allowed_types, + excluded_fields = EXCLUDED.excluded_fields, + schema_version = EXCLUDED.schema_version + `, profile.ID, profile.TenantID, profile.Name, nullableString(profile.Description), textArray(profile.AllowedTypes), textArray(profile.ExcludedFields), profile.SchemaVersion, nonZeroTime(profile.CreatedAt)); err != nil { + return fmt.Errorf("upsert redaction profile row: %w", err) + } + } + for _, pkg := range state.CustomerPackages { + if pkg.ID == "" || pkg.TenantID == "" { + continue + } + manifest, err := json.Marshal(pkg.Manifest) + if err != nil { + return fmt.Errorf("encode customer package manifest: %w", err) + } + if _, err := tx.Exec(ctx, ` + INSERT INTO customer_security_packages ( + id, tenant_id, product_id, release_id, redaction_profile_id, + title, state, manifest, manifest_hash, expires_at, access_count, + schema_version, created_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) + ON CONFLICT (id) DO UPDATE SET + state = EXCLUDED.state, + manifest = EXCLUDED.manifest, + manifest_hash = EXCLUDED.manifest_hash, + expires_at = EXCLUDED.expires_at, + access_count = EXCLUDED.access_count, + schema_version = EXCLUDED.schema_version + `, pkg.ID, pkg.TenantID, pkg.ProductID, nullableString(pkg.ReleaseID), pkg.RedactionProfileID, pkg.Title, pkg.State, manifest, pkg.ManifestHash, pkg.ExpiresAt, pkg.AccessCount, pkg.SchemaVersion, nonZeroTime(pkg.CreatedAt)); err != nil { + return fmt.Errorf("upsert customer package row: %w", err) + } + } + for _, report := range state.HTMLReports { + if report.ID == "" || report.TenantID == "" { + continue + } + if _, err := tx.Exec(ctx, ` + INSERT INTO html_report_packages ( + id, tenant_id, report_type, product_id, release_id, html, hash, + schema_version, created_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + ON CONFLICT (id) DO UPDATE SET html = EXCLUDED.html, hash = EXCLUDED.hash, schema_version = EXCLUDED.schema_version + `, report.ID, report.TenantID, report.ReportType, report.ProductID, nullableString(report.ReleaseID), report.HTML, report.Hash, report.SchemaVersion, nonZeroTime(report.CreatedAt)); err != nil { + return fmt.Errorf("upsert html report row: %w", err) + } + } + for _, template := range state.ReportTemplates { + if template.ID == "" || template.TenantID == "" { + continue + } + if _, err := tx.Exec(ctx, ` + INSERT INTO report_templates ( + id, tenant_id, name, version, report_type, allowed_fields, + template, schema_version, created_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + ON CONFLICT (id) DO UPDATE SET + report_type = EXCLUDED.report_type, + allowed_fields = EXCLUDED.allowed_fields, + template = EXCLUDED.template, + schema_version = EXCLUDED.schema_version + `, template.ID, template.TenantID, template.Name, template.Version, template.ReportType, textArray(template.AllowedFields), template.Template, template.SchemaVersion, nonZeroTime(template.CreatedAt)); err != nil { + return fmt.Errorf("upsert report template row: %w", err) + } + } + for _, report := range state.RenderedReports { + if report.ID == "" || report.TenantID == "" { + continue + } + output, err := json.Marshal(report.Output) + if err != nil { + return fmt.Errorf("encode rendered report output: %w", err) + } + if _, err := tx.Exec(ctx, ` + INSERT INTO rendered_reports ( + id, tenant_id, template_id, subject_type, subject_id, output, + hash, schema_version, created_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + ON CONFLICT (id) DO UPDATE SET output = EXCLUDED.output, hash = EXCLUDED.hash, schema_version = EXCLUDED.schema_version + `, report.ID, report.TenantID, report.TemplateID, report.SubjectType, report.SubjectID, output, report.Hash, report.SchemaVersion, nonZeroTime(report.CreatedAt)); err != nil { + return fmt.Errorf("upsert rendered report row: %w", err) + } + } + for _, bundle := range state.EvidenceBundles { + if bundle.ID == "" || bundle.TenantID == "" { + continue + } + manifest, err := json.Marshal(bundle.Manifest) + if err != nil { + return fmt.Errorf("encode evidence bundle manifest: %w", err) + } + if _, err := tx.Exec(ctx, ` + INSERT INTO evidence_bundles ( + id, tenant_id, release_id, evidence_ids, manifest, manifest_hash, + signature_refs, verification_text, schema_version, created_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + ON CONFLICT (id) DO UPDATE SET + manifest = EXCLUDED.manifest, + manifest_hash = EXCLUDED.manifest_hash, + signature_refs = EXCLUDED.signature_refs, + verification_text = EXCLUDED.verification_text, + schema_version = EXCLUDED.schema_version + `, bundle.ID, bundle.TenantID, nullableString(bundle.ReleaseID), textArray(bundle.EvidenceIDs), manifest, bundle.ManifestHash, textArray(bundle.SignatureRefs), bundle.VerificationText, bundle.SchemaVersion, nonZeroTime(bundle.CreatedAt)); err != nil { + return fmt.Errorf("upsert evidence bundle row: %w", err) + } + } + for _, imported := range state.BundleImports { + if imported.ID == "" || imported.TenantID == "" { + continue + } + if _, err := tx.Exec(ctx, ` + INSERT INTO evidence_bundle_imports ( + id, tenant_id, bundle_hash, result, imported_count, + schema_version, created_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7) + ON CONFLICT (id) DO UPDATE SET result = EXCLUDED.result, imported_count = EXCLUDED.imported_count, schema_version = EXCLUDED.schema_version + `, imported.ID, imported.TenantID, imported.BundleHash, imported.Result, imported.ImportedCount, imported.SchemaVersion, nonZeroTime(imported.CreatedAt)); err != nil { + return fmt.Errorf("upsert evidence bundle import row: %w", err) + } + } + for _, policy := range state.ObjectRetentionPolicies { + if policy.ID == "" || policy.TenantID == "" { + continue + } + checks, err := json.Marshal(policy.VerificationChecks) + if err != nil { + return fmt.Errorf("encode object retention checks: %w", err) + } + if _, err := tx.Exec(ctx, ` + INSERT INTO object_retention_policies ( + id, tenant_id, name, object_prefix, object_key, require_legal_hold, mode, retention_days, status, + verified_at, verification_hash, verification_checks, + verification_limitations, schema_version, created_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) + ON CONFLICT (id) DO UPDATE SET + object_key = EXCLUDED.object_key, + require_legal_hold = EXCLUDED.require_legal_hold, + status = EXCLUDED.status, + verified_at = EXCLUDED.verified_at, + verification_hash = EXCLUDED.verification_hash, + verification_checks = EXCLUDED.verification_checks, + verification_limitations = EXCLUDED.verification_limitations, + schema_version = EXCLUDED.schema_version + `, policy.ID, policy.TenantID, policy.Name, policy.ObjectPrefix, policy.ObjectKey, policy.RequireLegalHold, policy.Mode, policy.RetentionDays, policy.Status, nullableTime(policy.VerifiedAt), nullableString(policy.VerificationHash), checks, textArray(policy.VerificationLimitations), policy.SchemaVersion, nonZeroTime(policy.CreatedAt)); err != nil { + return fmt.Errorf("upsert object retention policy row: %w", err) + } + } + for _, manifest := range state.BackupManifests { + if manifest.ID == "" || manifest.TenantID == "" { + continue + } + counts, err := json.Marshal(manifest.ResourceCounts) + if err != nil { + return fmt.Errorf("encode backup manifest counts: %w", err) + } + checks, err := json.Marshal(manifest.ConsistencyChecks) + if err != nil { + return fmt.Errorf("encode backup manifest checks: %w", err) + } + if _, err := tx.Exec(ctx, ` + INSERT INTO backup_manifests ( + id, tenant_id, state_hash, resource_counts, consistency_checks, + limitations, schema_version, created_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + ON CONFLICT (id) DO UPDATE SET + resource_counts = EXCLUDED.resource_counts, + consistency_checks = EXCLUDED.consistency_checks, + limitations = EXCLUDED.limitations, + schema_version = EXCLUDED.schema_version + `, manifest.ID, manifest.TenantID, manifest.StateHash, counts, checks, textArray(manifest.Limitations), manifest.SchemaVersion, nonZeroTime(manifest.CreatedAt)); err != nil { + return fmt.Errorf("upsert backup manifest row: %w", err) + } + } + for _, hold := range state.LegalHolds { + if hold.ID == "" || hold.TenantID == "" { + continue + } + if _, err := tx.Exec(ctx, ` + INSERT INTO legal_holds ( + id, tenant_id, scope_type, scope_id, reason, owner, released_at, + schema_version, created_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + ON CONFLICT (id) DO UPDATE SET + reason = EXCLUDED.reason, + owner = EXCLUDED.owner, + released_at = EXCLUDED.released_at, + schema_version = EXCLUDED.schema_version + `, hold.ID, hold.TenantID, hold.ScopeType, hold.ScopeID, hold.Reason, hold.Owner, nullableTime(hold.ReleasedAt), hold.SchemaVersion, nonZeroTime(hold.CreatedAt)); err != nil { + return fmt.Errorf("upsert legal hold row: %w", err) + } + } + for _, override := range state.RetentionOverrides { + if override.ID == "" || override.TenantID == "" { + continue + } + if _, err := tx.Exec(ctx, ` + INSERT INTO retention_overrides ( + id, tenant_id, scope_type, scope_id, retention_until, reason, + owner, schema_version, created_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + ON CONFLICT (id) DO UPDATE SET + retention_until = EXCLUDED.retention_until, + reason = EXCLUDED.reason, + owner = EXCLUDED.owner, + schema_version = EXCLUDED.schema_version + `, override.ID, override.TenantID, override.ScopeType, override.ScopeID, override.RetentionUntil, override.Reason, override.Owner, override.SchemaVersion, nonZeroTime(override.CreatedAt)); err != nil { + return fmt.Errorf("upsert retention override row: %w", err) + } + } + for _, template := range state.QuestionnaireTemplates { + if template.ID == "" || template.TenantID == "" { + continue + } + questions, err := json.Marshal(template.Questions) + if err != nil { + return fmt.Errorf("encode questionnaire template questions: %w", err) + } + if _, err := tx.Exec(ctx, ` + INSERT INTO questionnaire_templates ( + id, tenant_id, name, version, questions, schema_version, created_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7) + ON CONFLICT (id) DO UPDATE SET questions = EXCLUDED.questions, schema_version = EXCLUDED.schema_version + `, template.ID, template.TenantID, template.Name, template.Version, questions, template.SchemaVersion, nonZeroTime(template.CreatedAt)); err != nil { + return fmt.Errorf("upsert questionnaire template row: %w", err) + } + } + for _, pkg := range state.QuestionnairePackages { + if pkg.ID == "" || pkg.TenantID == "" { + continue + } + responses, err := json.Marshal(pkg.Responses) + if err != nil { + return fmt.Errorf("encode questionnaire package responses: %w", err) + } + if _, err := tx.Exec(ctx, ` + INSERT INTO questionnaire_packages ( + id, tenant_id, template_id, package_id, product_id, release_id, + responses, manifest_hash, schema_version, created_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + ON CONFLICT (id) DO UPDATE SET responses = EXCLUDED.responses, manifest_hash = EXCLUDED.manifest_hash, schema_version = EXCLUDED.schema_version + `, pkg.ID, pkg.TenantID, pkg.TemplateID, nullableString(pkg.PackageID), nullableString(pkg.ProductID), nullableString(pkg.ReleaseID), responses, pkg.ManifestHash, pkg.SchemaVersion, nonZeroTime(pkg.CreatedAt)); err != nil { + return fmt.Errorf("upsert questionnaire package row: %w", err) + } + } + for _, entry := range state.QuestionnaireAnswerLibrary { + if entry.ID == "" || entry.TenantID == "" { + continue + } + if _, err := tx.Exec(ctx, ` + INSERT INTO questionnaire_answer_library ( + id, tenant_id, question_id, evidence_type, control_id, product_id, + release_id, answer, evidence_ids, limitations, schema_version, created_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + ON CONFLICT (id) DO UPDATE SET + answer = EXCLUDED.answer, + evidence_ids = EXCLUDED.evidence_ids, + limitations = EXCLUDED.limitations, + schema_version = EXCLUDED.schema_version + `, entry.ID, entry.TenantID, nullableString(entry.QuestionID), nullableString(entry.EvidenceType), nullableString(entry.ControlID), nullableString(entry.ProductID), nullableString(entry.ReleaseID), entry.Answer, textArray(entry.EvidenceIDs), textArray(entry.Limitations), entry.SchemaVersion, nonZeroTime(entry.CreatedAt)); err != nil { + return fmt.Errorf("upsert questionnaire answer library row: %w", err) + } + } + for _, report := range state.PDFReports { + if report.ID == "" || report.TenantID == "" { + continue + } + if _, err := tx.Exec(ctx, ` + INSERT INTO pdf_report_packages ( + id, tenant_id, report_type, product_id, release_id, title, + payload_ref, payload_hash, payload_size, limitations, + schema_version, created_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + ON CONFLICT (id) DO UPDATE SET + payload_ref = EXCLUDED.payload_ref, + payload_hash = EXCLUDED.payload_hash, + payload_size = EXCLUDED.payload_size, + limitations = EXCLUDED.limitations, + schema_version = EXCLUDED.schema_version + `, report.ID, report.TenantID, report.ReportType, nullableString(report.ProductID), nullableString(report.ReleaseID), report.Title, nullableString(report.PayloadRef), report.PayloadHash, report.PayloadSize, textArray(report.Limitations), report.SchemaVersion, nonZeroTime(report.CreatedAt)); err != nil { + return fmt.Errorf("upsert pdf report row: %w", err) + } + } + for _, report := range state.AnomalyReports { + if report.ID == "" || report.TenantID == "" { + continue + } + signals, err := json.Marshal(report.Signals) + if err != nil { + return fmt.Errorf("encode anomaly report signals: %w", err) + } + if _, err := tx.Exec(ctx, ` + INSERT INTO anomaly_reports ( + id, tenant_id, subject_type, subject_id, result, signals, + assumptions, limitations, schema_version, created_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + ON CONFLICT (id) DO UPDATE SET + result = EXCLUDED.result, + signals = EXCLUDED.signals, + assumptions = EXCLUDED.assumptions, + limitations = EXCLUDED.limitations, + schema_version = EXCLUDED.schema_version + `, report.ID, report.TenantID, report.SubjectType, report.SubjectID, report.Result, signals, textArray(report.Assumptions), textArray(report.Limitations), report.SchemaVersion, nonZeroTime(report.CreatedAt)); err != nil { + return fmt.Errorf("upsert anomaly report row: %w", err) + } } - return &Store{pool: pool}, nil + return nil } -func (s *Store) Close() { - if s != nil && s.pool != nil { - s.pool.Close() +func syncFutureExtensionRows(ctx context.Context, tx pgx.Tx, state app.PersistedState) error { + for _, collector := range state.CommercialCollectors { + if collector.ID == "" || collector.TenantID == "" { + continue + } + if _, err := tx.Exec(ctx, ` + INSERT INTO commercial_collectors ( + id, tenant_id, name, provider, version, manifest_hash, + allowed_scopes, status, schema_version, created_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + ON CONFLICT (id) DO UPDATE SET + allowed_scopes = EXCLUDED.allowed_scopes, + status = EXCLUDED.status, + schema_version = EXCLUDED.schema_version + `, collector.ID, collector.TenantID, collector.Name, collector.Provider, collector.Version, collector.ManifestHash, textArray(collector.AllowedScopes), collector.Status, collector.SchemaVersion, nonZeroTime(collector.CreatedAt)); err != nil { + return fmt.Errorf("upsert commercial collector row: %w", err) + } + } + for _, summary := range state.EvidenceSummaries { + if summary.ID == "" || summary.TenantID == "" { + continue + } + citations, err := json.Marshal(summary.Citations) + if err != nil { + return fmt.Errorf("encode evidence summary citations: %w", err) + } + if _, err := tx.Exec(ctx, ` + INSERT INTO evidence_summaries ( + id, tenant_id, subject_type, subject_id, evidence_ids, summary, + citations, assumptions, limitations, schema_version, created_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + ON CONFLICT (id) DO UPDATE SET summary = EXCLUDED.summary, citations = EXCLUDED.citations, assumptions = EXCLUDED.assumptions, limitations = EXCLUDED.limitations, schema_version = EXCLUDED.schema_version + `, summary.ID, summary.TenantID, summary.SubjectType, summary.SubjectID, textArray(summary.EvidenceIDs), summary.Summary, citations, textArray(summary.Assumptions), textArray(summary.Limitations), summary.SchemaVersion, nonZeroTime(summary.CreatedAt)); err != nil { + return fmt.Errorf("upsert evidence summary row: %w", err) + } + } + for _, draft := range state.QuestionnaireDrafts { + if draft.ID == "" || draft.TenantID == "" { + continue + } + responses, err := json.Marshal(draft.Responses) + if err != nil { + return fmt.Errorf("encode questionnaire draft responses: %w", err) + } + if _, err := tx.Exec(ctx, ` + INSERT INTO questionnaire_drafts ( + id, tenant_id, template_id, product_id, release_id, responses, + manifest_hash, limitations, schema_version, created_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + ON CONFLICT (id) DO UPDATE SET responses = EXCLUDED.responses, manifest_hash = EXCLUDED.manifest_hash, limitations = EXCLUDED.limitations, schema_version = EXCLUDED.schema_version + `, draft.ID, draft.TenantID, draft.TemplateID, nullableString(draft.ProductID), nullableString(draft.ReleaseID), responses, draft.ManifestHash, textArray(draft.Limitations), draft.SchemaVersion, nonZeroTime(draft.CreatedAt)); err != nil { + return fmt.Errorf("upsert questionnaire draft row: %w", err) + } + } + for _, graph := range state.GraphSnapshots { + if graph.ID == "" || graph.TenantID == "" { + continue + } + nodes, err := json.Marshal(graph.Nodes) + if err != nil { + return fmt.Errorf("encode graph nodes: %w", err) + } + edges, err := json.Marshal(graph.Edges) + if err != nil { + return fmt.Errorf("encode graph edges: %w", err) + } + if _, err := tx.Exec(ctx, ` + INSERT INTO evidence_graph_snapshots ( + id, tenant_id, product_id, release_id, nodes, edges, + graph_hash, limitations, schema_version, created_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + ON CONFLICT (id) DO UPDATE SET nodes = EXCLUDED.nodes, edges = EXCLUDED.edges, graph_hash = EXCLUDED.graph_hash, limitations = EXCLUDED.limitations, schema_version = EXCLUDED.schema_version + `, graph.ID, graph.TenantID, nullableString(graph.ProductID), nullableString(graph.ReleaseID), nodes, edges, graph.GraphHash, textArray(graph.Limitations), graph.SchemaVersion, nonZeroTime(graph.CreatedAt)); err != nil { + return fmt.Errorf("upsert graph snapshot row: %w", err) + } + } + for _, profile := range state.SaaSProfiles { + if profile.ID == "" || profile.TenantID == "" { + continue + } + if _, err := tx.Exec(ctx, ` + INSERT INTO saas_edition_profiles ( + id, tenant_id, name, region, admin_tenant_id, isolation_model, + status, config_hash, limitations, schema_version, created_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + ON CONFLICT (id) DO UPDATE SET status = EXCLUDED.status, config_hash = EXCLUDED.config_hash, limitations = EXCLUDED.limitations, schema_version = EXCLUDED.schema_version + `, profile.ID, profile.TenantID, profile.Name, profile.Region, profile.AdminTenantID, profile.IsolationModel, profile.Status, profile.ConfigHash, textArray(profile.Limitations), profile.SchemaVersion, nonZeroTime(profile.CreatedAt)); err != nil { + return fmt.Errorf("upsert saas profile row: %w", err) + } + } + for _, log := range state.PublicTransparencyLogs { + if log.ID == "" || log.TenantID == "" { + continue + } + if _, err := tx.Exec(ctx, ` + INSERT INTO public_transparency_logs ( + id, tenant_id, name, endpoint, public_key, state, + schema_version, created_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + ON CONFLICT (id) DO UPDATE SET state = EXCLUDED.state, schema_version = EXCLUDED.schema_version + `, log.ID, log.TenantID, log.Name, log.Endpoint, log.PublicKey, log.State, log.SchemaVersion, nonZeroTime(log.CreatedAt)); err != nil { + return fmt.Errorf("upsert public transparency log row: %w", err) + } + } + for _, entry := range state.PublicTransparencyItems { + if entry.ID == "" || entry.TenantID == "" { + continue + } + checks, err := json.Marshal(entry.VerificationChecks) + if err != nil { + return fmt.Errorf("encode public transparency checks: %w", err) + } + if _, err := tx.Exec(ctx, ` + INSERT INTO public_transparency_log_entries ( + id, tenant_id, log_id, checkpoint_id, merkle_batch_id, + external_id, entry_hash, inclusion_root_hash, + inclusion_proof_hash, inclusion_verified_at, verification_checks, + verification_limitations, state, schema_version, created_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) + ON CONFLICT (id) DO UPDATE SET + inclusion_root_hash = EXCLUDED.inclusion_root_hash, + inclusion_proof_hash = EXCLUDED.inclusion_proof_hash, + inclusion_verified_at = EXCLUDED.inclusion_verified_at, + verification_checks = EXCLUDED.verification_checks, + verification_limitations = EXCLUDED.verification_limitations, + state = EXCLUDED.state, + schema_version = EXCLUDED.schema_version + `, entry.ID, entry.TenantID, entry.LogID, entry.CheckpointID, entry.MerkleBatchID, entry.ExternalID, entry.EntryHash, + nullableString(entry.InclusionRootHash), nullableString(entry.InclusionProofHash), nullableTime(entry.InclusionVerifiedAt), + checks, textArray(entry.VerificationLimitations), entry.State, entry.SchemaVersion, nonZeroTime(entry.CreatedAt)); err != nil { + return fmt.Errorf("upsert public transparency entry row: %w", err) + } + } + for _, collector := range state.MarketplaceCollectors { + if collector.ID == "" || collector.TenantID == "" { + continue + } + if _, err := tx.Exec(ctx, ` + INSERT INTO marketplace_collectors ( + id, tenant_id, name, provider, version, publisher, + manifest_hash, signature_id, sbom_id, scan_id, state, + limitations, schema_version, created_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) + ON CONFLICT (id) DO UPDATE SET state = EXCLUDED.state, limitations = EXCLUDED.limitations, schema_version = EXCLUDED.schema_version + `, collector.ID, collector.TenantID, collector.Name, collector.Provider, collector.Version, collector.Publisher, collector.ManifestHash, + nullableString(collector.SignatureID), nullableString(collector.SBOMID), nullableString(collector.ScanID), collector.State, + textArray(collector.Limitations), collector.SchemaVersion, nonZeroTime(collector.CreatedAt)); err != nil { + return fmt.Errorf("upsert marketplace collector row: %w", err) + } + } + for _, verification := range state.ProviderVerifications { + if verification.ID == "" || verification.TenantID == "" { + continue + } + checks, err := json.Marshal(verification.Checks) + if err != nil { + return fmt.Errorf("encode provider verification checks: %w", err) + } + if _, err := tx.Exec(ctx, ` + INSERT INTO provider_verifications ( + id, tenant_id, provider_type, provider_id, subject, result, + checks, limitations, schema_version, created_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + ON CONFLICT (id) DO UPDATE SET result = EXCLUDED.result, checks = EXCLUDED.checks, limitations = EXCLUDED.limitations, schema_version = EXCLUDED.schema_version + `, verification.ID, verification.TenantID, verification.ProviderType, verification.ProviderID, verification.Subject, verification.Result, checks, textArray(verification.Limitations), verification.SchemaVersion, nonZeroTime(verification.CreatedAt)); err != nil { + return fmt.Errorf("upsert provider verification row: %w", err) + } + } + for _, operation := range state.SigningOperations { + if operation.ID == "" || operation.TenantID == "" { + continue + } + checks, err := json.Marshal(operation.Checks) + if err != nil { + return fmt.Errorf("encode signing operation checks: %w", err) + } + if _, err := tx.Exec(ctx, ` + INSERT INTO signing_operations ( + id, tenant_id, provider_id, subject_type, subject_id, + payload_hash, signature_ref, result, checks, + schema_version, created_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + ON CONFLICT (id) DO UPDATE SET signature_ref = EXCLUDED.signature_ref, result = EXCLUDED.result, checks = EXCLUDED.checks, schema_version = EXCLUDED.schema_version + `, operation.ID, operation.TenantID, operation.ProviderID, operation.SubjectType, operation.SubjectID, operation.PayloadHash, nullableString(operation.SignatureRef), operation.Result, checks, operation.SchemaVersion, nonZeroTime(operation.CreatedAt)); err != nil { + return fmt.Errorf("upsert signing operation row: %w", err) + } } + return nil } -func (s *Store) LoadState(ctx context.Context) (app.PersistedState, bool, error) { - var body []byte - err := s.pool.QueryRow(ctx, `SELECT state FROM ledger_state WHERE id = 'default'`).Scan(&body) - if errors.Is(err, pgx.ErrNoRows) { - return app.PersistedState{}, false, nil +func syncIdentityAndIdempotency(ctx context.Context, tx pgx.Tx, state app.PersistedState) error { + for _, tenant := range state.Tenants { + if tenant.ID == "" { + continue + } + if _, err := tx.Exec(ctx, ` + INSERT INTO tenants (id, name, created_at) + VALUES ($1, $2, $3) + ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name + `, tenant.ID, tenant.Name, nonZeroTime(tenant.CreatedAt)); err != nil { + return fmt.Errorf("upsert tenant row: %w", err) + } } - if err != nil { - return app.PersistedState{}, false, fmt.Errorf("load ledger state: %w", err) + for id, key := range state.APIKeys { + if key.ID == "" || key.TenantID == "" { + continue + } + hash := state.APIKeyHashes[id] + if hash == "" { + hash = key.Hash + } + scopes, err := json.Marshal(key.Scopes) + if err != nil { + return fmt.Errorf("encode api key scopes: %w", err) + } + if _, err := tx.Exec(ctx, ` + INSERT INTO api_keys ( + id, tenant_id, name, prefix, hash, scopes, expires_at, + revoked_at, last_used_at, created_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + ON CONFLICT (id) DO UPDATE SET + name = EXCLUDED.name, + prefix = EXCLUDED.prefix, + hash = EXCLUDED.hash, + scopes = EXCLUDED.scopes, + expires_at = EXCLUDED.expires_at, + revoked_at = EXCLUDED.revoked_at, + last_used_at = EXCLUDED.last_used_at + `, key.ID, key.TenantID, key.Name, key.Prefix, hash, scopes, nullableTime(key.ExpiresAt), nullableTime(key.RevokedAt), nullableTime(key.LastUsedAt), nonZeroTime(key.CreatedAt)); err != nil { + return fmt.Errorf("upsert api key row: %w", err) + } } - var state app.PersistedState - if err := json.Unmarshal(body, &state); err != nil { - return app.PersistedState{}, false, fmt.Errorf("decode ledger state: %w", err) + for _, org := range state.Organizations { + if org.ID == "" || org.TenantID == "" { + continue + } + if _, err := tx.Exec(ctx, ` + INSERT INTO organizations (id, tenant_id, name, slug, status, schema_version, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7) + ON CONFLICT (id) DO UPDATE SET + name = EXCLUDED.name, + slug = EXCLUDED.slug, + status = EXCLUDED.status, + schema_version = EXCLUDED.schema_version + `, org.ID, org.TenantID, org.Name, org.Slug, org.Status, org.SchemaVersion, nonZeroTime(org.CreatedAt)); err != nil { + return fmt.Errorf("upsert organization row: %w", err) + } } - return state, true, nil + for _, user := range state.Users { + if user.ID == "" || user.TenantID == "" { + continue + } + if _, err := tx.Exec(ctx, ` + INSERT INTO human_users ( + id, tenant_id, organization_id, email, display_name, status, + deactivated_at, schema_version, created_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + ON CONFLICT (id) DO UPDATE SET + organization_id = EXCLUDED.organization_id, + email = EXCLUDED.email, + display_name = EXCLUDED.display_name, + status = EXCLUDED.status, + deactivated_at = EXCLUDED.deactivated_at, + schema_version = EXCLUDED.schema_version + `, user.ID, user.TenantID, nullableString(user.OrganizationID), user.Email, user.DisplayName, user.Status, nullableTime(user.DeactivatedAt), user.SchemaVersion, nonZeroTime(user.CreatedAt)); err != nil { + return fmt.Errorf("upsert human user row: %w", err) + } + } + for _, binding := range state.RoleBindings { + if binding.ID == "" || binding.TenantID == "" { + continue + } + if _, err := tx.Exec(ctx, ` + INSERT INTO role_bindings ( + id, tenant_id, subject_type, subject_id, role, resource_type, + resource_id, schema_version, created_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + ON CONFLICT (id) DO UPDATE SET + subject_type = EXCLUDED.subject_type, + subject_id = EXCLUDED.subject_id, + role = EXCLUDED.role, + resource_type = EXCLUDED.resource_type, + resource_id = EXCLUDED.resource_id, + schema_version = EXCLUDED.schema_version + `, binding.ID, binding.TenantID, binding.SubjectType, binding.SubjectID, binding.Role, nullableString(binding.ResourceType), nullableString(binding.ResourceID), binding.SchemaVersion, nonZeroTime(binding.CreatedAt)); err != nil { + return fmt.Errorf("upsert role binding row: %w", err) + } + } + for _, provider := range state.SSOProviders { + if provider.ID == "" || provider.TenantID == "" { + continue + } + roleMapping, err := json.Marshal(provider.RoleMapping) + if err != nil { + return fmt.Errorf("encode sso role mapping: %w", err) + } + jwks, err := json.Marshal(provider.JWKS) + if err != nil { + return fmt.Errorf("encode sso jwks: %w", err) + } + samlCerts, err := json.Marshal(provider.SAMLSigningCertificates) + if err != nil { + return fmt.Errorf("encode sso signing certificates: %w", err) + } + if _, err := tx.Exec(ctx, ` + INSERT INTO sso_providers ( + id, tenant_id, name, type, issuer, client_id, groups_claim, + role_mapping, status, schema_version, created_at, jwks, + saml_signing_certificates, trust_material_updated_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) + ON CONFLICT (id) DO UPDATE SET + name = EXCLUDED.name, + type = EXCLUDED.type, + issuer = EXCLUDED.issuer, + client_id = EXCLUDED.client_id, + groups_claim = EXCLUDED.groups_claim, + role_mapping = EXCLUDED.role_mapping, + status = EXCLUDED.status, + schema_version = EXCLUDED.schema_version, + jwks = EXCLUDED.jwks, + saml_signing_certificates = EXCLUDED.saml_signing_certificates, + trust_material_updated_at = EXCLUDED.trust_material_updated_at + `, provider.ID, provider.TenantID, provider.Name, provider.Type, provider.Issuer, provider.ClientID, nullableString(provider.GroupsClaim), roleMapping, provider.Status, provider.SchemaVersion, nonZeroTime(provider.CreatedAt), jwks, samlCerts, nullableTime(provider.TrustMaterialUpdatedAt)); err != nil { + return fmt.Errorf("upsert sso provider row: %w", err) + } + } + for _, link := range state.IdentityLinks { + if link.ID == "" || link.TenantID == "" { + continue + } + if _, err := tx.Exec(ctx, ` + INSERT INTO user_identity_links ( + id, tenant_id, user_id, provider_id, subject, email, verified, + schema_version, created_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + ON CONFLICT (id) DO UPDATE SET + user_id = EXCLUDED.user_id, + provider_id = EXCLUDED.provider_id, + subject = EXCLUDED.subject, + email = EXCLUDED.email, + verified = EXCLUDED.verified, + schema_version = EXCLUDED.schema_version + `, link.ID, link.TenantID, link.UserID, link.ProviderID, link.Subject, link.Email, link.Verified, link.SchemaVersion, nonZeroTime(link.CreatedAt)); err != nil { + return fmt.Errorf("upsert user identity link row: %w", err) + } + } + for id, session := range state.SSOSessions { + if session.ID == "" || session.TenantID == "" { + continue + } + hash := state.SSOSessionHashes[id] + if hash == "" { + hash = session.Hash + } + groups, err := json.Marshal(session.Groups) + if err != nil { + return fmt.Errorf("encode sso session groups: %w", err) + } + if _, err := tx.Exec(ctx, ` + INSERT INTO sso_sessions ( + id, tenant_id, user_id, provider_id, prefix, hash, groups, expires_at, + revoked_at, schema_version, created_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8, $9, $10, $11) + ON CONFLICT (id) DO UPDATE SET + prefix = EXCLUDED.prefix, + hash = EXCLUDED.hash, + groups = EXCLUDED.groups, + expires_at = EXCLUDED.expires_at, + revoked_at = EXCLUDED.revoked_at, + schema_version = EXCLUDED.schema_version + `, session.ID, session.TenantID, session.UserID, session.ProviderID, session.Prefix, hash, string(groups), session.ExpiresAt, nullableTime(session.RevokedAt), session.SchemaVersion, nonZeroTime(session.CreatedAt)); err != nil { + return fmt.Errorf("upsert sso session row: %w", err) + } + } + for id, access := range state.CustomerPortalAccess { + if access.ID == "" || access.TenantID == "" { + continue + } + hash := state.CustomerPortalHashes[id] + if hash == "" { + hash = access.Hash + } + if _, err := tx.Exec(ctx, ` + INSERT INTO customer_portal_access ( + id, tenant_id, package_id, customer_name, reviewer_name, reviewer_email, prefix, hash, + expires_at, revoked_at, access_count, failed_access_count, + last_accessed_at, last_failed_at, require_nda, nda_accepted_at, + nda_accepted_by, watermark, schema_version, created_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20) + ON CONFLICT (id) DO UPDATE SET + customer_name = EXCLUDED.customer_name, + reviewer_name = EXCLUDED.reviewer_name, + reviewer_email = EXCLUDED.reviewer_email, + prefix = EXCLUDED.prefix, + hash = EXCLUDED.hash, + expires_at = EXCLUDED.expires_at, + revoked_at = EXCLUDED.revoked_at, + access_count = EXCLUDED.access_count, + failed_access_count = EXCLUDED.failed_access_count, + last_accessed_at = EXCLUDED.last_accessed_at, + last_failed_at = EXCLUDED.last_failed_at, + require_nda = EXCLUDED.require_nda, + nda_accepted_at = EXCLUDED.nda_accepted_at, + nda_accepted_by = EXCLUDED.nda_accepted_by, + watermark = EXCLUDED.watermark, + schema_version = EXCLUDED.schema_version + `, access.ID, access.TenantID, access.PackageID, access.CustomerName, nullableString(access.ReviewerName), nullableString(access.ReviewerEmail), access.Prefix, hash, access.ExpiresAt, nullableTime(access.RevokedAt), access.AccessCount, access.FailedAccessCount, nullableTime(access.LastAccessedAt), nullableTime(access.LastFailedAt), access.RequireNDA, nullableTime(access.NDAAcceptedAt), nullableString(access.NDAAcceptedBy), nullableString(access.Watermark), access.SchemaVersion, nonZeroTime(access.CreatedAt)); err != nil { + return fmt.Errorf("upsert customer portal access row: %w", err) + } + } + if _, err := tx.Exec(ctx, `DELETE FROM idempotency_records`); err != nil { + return fmt.Errorf("clear idempotency records: %w", err) + } + for key, record := range state.Idempotency { + parts, ok := app.ParseIdempotencyRecordKey(key) + if !ok { + continue + } + response, err := json.Marshal(record.Response) + if err != nil { + return fmt.Errorf("encode idempotency response: %w", err) + } + if _, err := tx.Exec(ctx, ` + INSERT INTO idempotency_records ( + tenant_id, actor_key_id, method, path, idempotency_key, + request_hash, status, response, created_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + `, parts.TenantID, parts.ActorID, parts.Method, parts.Path, parts.IdempotencyKey, record.RequestHash, record.Status, response, nonZeroTime(record.CreatedAt)); err != nil { + return fmt.Errorf("insert idempotency record row: %w", err) + } + } + return nil } -func (s *Store) SaveState(ctx context.Context, state app.PersistedState) error { - body, err := json.Marshal(state) - if err != nil { - return fmt.Errorf("encode ledger state: %w", err) +func syncCriticalIdentityAndIdempotency(ctx context.Context, tx pgx.Tx, mutation app.CriticalMutation) error { + for _, tenant := range mutation.Tenants { + if tenant.ID == "" { + continue + } + if _, err := tx.Exec(ctx, ` + INSERT INTO tenants (id, name, created_at) + VALUES ($1, $2, $3) + ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name + `, tenant.ID, tenant.Name, nonZeroTime(tenant.CreatedAt)); err != nil { + return fmt.Errorf("upsert critical tenant row: %w", err) + } } - tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{}) - if err != nil { - return fmt.Errorf("begin save ledger state transaction: %w", err) + for _, key := range mutation.APIKeys { + if key.ID == "" || key.TenantID == "" { + continue + } + hash := mutation.APIKeyHashes[key.ID] + if hash == "" { + hash = key.Hash + } + scopes, err := json.Marshal(key.Scopes) + if err != nil { + return fmt.Errorf("encode critical api key scopes: %w", err) + } + if _, err := tx.Exec(ctx, ` + INSERT INTO api_keys ( + id, tenant_id, name, prefix, hash, scopes, expires_at, + revoked_at, last_used_at, created_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + ON CONFLICT (id) DO UPDATE SET + name = EXCLUDED.name, + prefix = EXCLUDED.prefix, + hash = EXCLUDED.hash, + scopes = EXCLUDED.scopes, + expires_at = EXCLUDED.expires_at, + revoked_at = EXCLUDED.revoked_at, + last_used_at = EXCLUDED.last_used_at + `, key.ID, key.TenantID, key.Name, key.Prefix, hash, scopes, nullableTime(key.ExpiresAt), nullableTime(key.RevokedAt), nullableTime(key.LastUsedAt), nonZeroTime(key.CreatedAt)); err != nil { + return fmt.Errorf("upsert critical api key row: %w", err) + } } - defer func() { _ = tx.Rollback(ctx) }() - _, err = tx.Exec(ctx, ` - INSERT INTO ledger_state (id, state, updated_at) - VALUES ('default', $1, now()) - ON CONFLICT (id) DO UPDATE SET state = EXCLUDED.state, updated_at = EXCLUDED.updated_at - `, body) - if err != nil { - return fmt.Errorf("save ledger state: %w", err) + for _, session := range mutation.SSOSessions { + if session.ID == "" || session.TenantID == "" { + continue + } + hash := mutation.SSOSessionHashes[session.ID] + if hash == "" { + hash = session.Hash + } + groups, err := json.Marshal(session.Groups) + if err != nil { + return fmt.Errorf("encode critical sso session groups: %w", err) + } + if _, err := tx.Exec(ctx, ` + INSERT INTO sso_sessions ( + id, tenant_id, user_id, provider_id, prefix, hash, groups, expires_at, + revoked_at, schema_version, created_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8, $9, $10, $11) + ON CONFLICT (id) DO UPDATE SET + prefix = EXCLUDED.prefix, + hash = EXCLUDED.hash, + groups = EXCLUDED.groups, + expires_at = EXCLUDED.expires_at, + revoked_at = EXCLUDED.revoked_at, + schema_version = EXCLUDED.schema_version + `, session.ID, session.TenantID, session.UserID, session.ProviderID, session.Prefix, hash, string(groups), session.ExpiresAt, nullableTime(session.RevokedAt), session.SchemaVersion, nonZeroTime(session.CreatedAt)); err != nil { + return fmt.Errorf("upsert critical sso session row: %w", err) + } } - if err := syncResourceIndex(ctx, tx, state); err != nil { - return err + for _, access := range mutation.CustomerPortalAccess { + if access.ID == "" || access.TenantID == "" { + continue + } + hash := mutation.CustomerPortalHashes[access.ID] + if hash == "" { + hash = access.Hash + } + if _, err := tx.Exec(ctx, ` + INSERT INTO customer_portal_access ( + id, tenant_id, package_id, customer_name, reviewer_name, reviewer_email, prefix, hash, + expires_at, revoked_at, access_count, failed_access_count, + last_accessed_at, last_failed_at, require_nda, nda_accepted_at, + nda_accepted_by, watermark, schema_version, created_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20) + ON CONFLICT (id) DO UPDATE SET + customer_name = EXCLUDED.customer_name, + reviewer_name = EXCLUDED.reviewer_name, + reviewer_email = EXCLUDED.reviewer_email, + prefix = EXCLUDED.prefix, + hash = EXCLUDED.hash, + expires_at = EXCLUDED.expires_at, + revoked_at = EXCLUDED.revoked_at, + access_count = EXCLUDED.access_count, + failed_access_count = EXCLUDED.failed_access_count, + last_accessed_at = EXCLUDED.last_accessed_at, + last_failed_at = EXCLUDED.last_failed_at, + require_nda = EXCLUDED.require_nda, + nda_accepted_at = EXCLUDED.nda_accepted_at, + nda_accepted_by = EXCLUDED.nda_accepted_by, + watermark = EXCLUDED.watermark, + schema_version = EXCLUDED.schema_version + `, access.ID, access.TenantID, access.PackageID, access.CustomerName, nullableString(access.ReviewerName), nullableString(access.ReviewerEmail), access.Prefix, hash, access.ExpiresAt, nullableTime(access.RevokedAt), access.AccessCount, access.FailedAccessCount, nullableTime(access.LastAccessedAt), nullableTime(access.LastFailedAt), access.RequireNDA, nullableTime(access.NDAAcceptedAt), nullableString(access.NDAAcceptedBy), nullableString(access.Watermark), access.SchemaVersion, nonZeroTime(access.CreatedAt)); err != nil { + return fmt.Errorf("upsert critical customer portal access row: %w", err) + } } - if err := tx.Commit(ctx); err != nil { - return fmt.Errorf("commit save ledger state transaction: %w", err) + for key, record := range mutation.Idempotency { + parts, ok := app.ParseIdempotencyRecordKey(key) + if !ok { + continue + } + response, err := json.Marshal(record.Response) + if err != nil { + return fmt.Errorf("encode critical idempotency response: %w", err) + } + if _, err := tx.Exec(ctx, ` + INSERT INTO idempotency_records ( + tenant_id, actor_key_id, method, path, idempotency_key, + request_hash, status, response, created_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + ON CONFLICT (tenant_id, actor_key_id, method, path, idempotency_key) + DO UPDATE SET request_hash = EXCLUDED.request_hash, + status = EXCLUDED.status, + response = EXCLUDED.response + `, parts.TenantID, parts.ActorID, parts.Method, parts.Path, parts.IdempotencyKey, record.RequestHash, record.Status, response, nonZeroTime(record.CreatedAt)); err != nil { + return fmt.Errorf("upsert critical idempotency record row: %w", err) + } } return nil } @@ -129,6 +5010,33 @@ func syncResourceIndex(ctx context.Context, tx pgx.Tx, state app.PersistedState) return nil } +func syncCriticalResourceIndex(ctx context.Context, tx pgx.Tx, state app.PersistedState) error { + for _, projection := range resourceProjections(state) { + if projection.TenantID == "" || projection.ResourceID == "" || projection.ResourceType == "" { + continue + } + if projection.CreatedAt.IsZero() { + projection.CreatedAt = time.Now().UTC() + } + if _, err := tx.Exec(ctx, ` + INSERT INTO resource_index ( + tenant_id, resource_type, resource_id, product_id, project_id, + release_id, created_at, updated_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, now()) + ON CONFLICT (tenant_id, resource_type, resource_id) + DO UPDATE SET product_id = EXCLUDED.product_id, + project_id = EXCLUDED.project_id, + release_id = EXCLUDED.release_id, + created_at = EXCLUDED.created_at, + updated_at = EXCLUDED.updated_at + `, projection.TenantID, projection.ResourceType, projection.ResourceID, nullableString(projection.ProductID), nullableString(projection.ProjectID), nullableString(projection.ReleaseID), projection.CreatedAt); err != nil { + return fmt.Errorf("upsert critical resource index: %w", err) + } + } + return nil +} + func resourceProjections(state app.PersistedState) []resourceProjection { out := []resourceProjection{} for _, v := range state.Tenants { @@ -323,131 +5231,46 @@ func resourceProjections(state app.PersistedState) []resourceProjection { for _, v := range state.QuestionnairePackages { out = append(out, resourceProjection{TenantID: v.TenantID, ResourceType: "questionnaire_package", ResourceID: v.ID, ProductID: v.ProductID, ReleaseID: v.ReleaseID, CreatedAt: v.CreatedAt}) } + for _, v := range state.QuestionnaireAnswerLibrary { + out = append(out, resourceProjection{TenantID: v.TenantID, ResourceType: "questionnaire_answer_library", ResourceID: v.ID, ProductID: v.ProductID, ReleaseID: v.ReleaseID, CreatedAt: v.CreatedAt}) + } for _, v := range state.CommercialCollectors { out = append(out, resourceProjection{TenantID: v.TenantID, ResourceType: "commercial_collector", ResourceID: v.ID, CreatedAt: v.CreatedAt}) } - return out -} - -func nullableString(value string) any { - if value == "" { - return nil - } - return value -} - -func (s *Store) Enqueue(ctx context.Context, job app.OutboxJob) error { - payload, err := json.Marshal(job.Payload) - if err != nil { - return fmt.Errorf("encode outbox payload: %w", err) - } - _, err = s.pool.Exec(ctx, ` - INSERT INTO outbox_jobs ( - id, tenant_id, kind, subject_type, subject_id, payload, status, - attempts, max_attempts, run_after, created_at, updated_at - ) - VALUES ($1, $2, $3, $4, $5, $6, 'queued', 0, 5, now(), $7, now()) - ON CONFLICT (id) DO NOTHING - `, job.ID, job.TenantID, job.Kind, job.SubjectType, job.SubjectID, payload, job.CreatedAt) - if err != nil { - return fmt.Errorf("enqueue outbox job: %w", err) + for _, v := range state.EvidenceSummaries { + out = append(out, resourceProjection{TenantID: v.TenantID, ResourceType: "evidence_summary", ResourceID: v.ID, CreatedAt: v.CreatedAt}) } - return nil -} - -func (s *Store) ClaimJobs(ctx context.Context, limit int) ([]ClaimedJob, error) { - if limit <= 0 { - limit = 10 + for _, v := range state.QuestionnaireDrafts { + out = append(out, resourceProjection{TenantID: v.TenantID, ResourceType: "questionnaire_draft", ResourceID: v.ID, ProductID: v.ProductID, ReleaseID: v.ReleaseID, CreatedAt: v.CreatedAt}) } - tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{}) - if err != nil { - return nil, fmt.Errorf("begin claim jobs transaction: %w", err) + for _, v := range state.GraphSnapshots { + out = append(out, resourceProjection{TenantID: v.TenantID, ResourceType: "evidence_graph_snapshot", ResourceID: v.ID, ProductID: v.ProductID, ReleaseID: v.ReleaseID, CreatedAt: v.CreatedAt}) } - defer func() { _ = tx.Rollback(ctx) }() - rows, err := tx.Query(ctx, ` - WITH claimed AS ( - SELECT id - FROM outbox_jobs - WHERE status IN ('queued', 'retrying') - AND run_after <= now() - ORDER BY run_after, created_at - LIMIT $1 - FOR UPDATE SKIP LOCKED - ) - UPDATE outbox_jobs j - SET status = 'running', - attempts = attempts + 1, - locked_at = now(), - updated_at = now() - FROM claimed - WHERE j.id = claimed.id - RETURNING j.id, j.tenant_id, j.kind, j.subject_type, j.subject_id, j.attempts, j.payload - `, limit) - if err != nil { - return nil, fmt.Errorf("claim jobs: %w", err) + for _, v := range state.SaaSProfiles { + out = append(out, resourceProjection{TenantID: v.TenantID, ResourceType: "saas_profile", ResourceID: v.ID, CreatedAt: v.CreatedAt}) } - defer rows.Close() - jobs := []ClaimedJob{} - for rows.Next() { - var job ClaimedJob - var payload []byte - if err := rows.Scan(&job.ID, &job.TenantID, &job.Kind, &job.SubjectType, &job.SubjectID, &job.Attempts, &payload); err != nil { - return nil, fmt.Errorf("scan claimed job: %w", err) - } - if len(payload) > 0 { - if err := json.Unmarshal(payload, &job.Payload); err != nil { - return nil, fmt.Errorf("decode claimed job payload: %w", err) - } - } - jobs = append(jobs, job) + for _, v := range state.PublicTransparencyLogs { + out = append(out, resourceProjection{TenantID: v.TenantID, ResourceType: "public_transparency_log", ResourceID: v.ID, CreatedAt: v.CreatedAt}) } - if err := rows.Err(); err != nil { - return nil, fmt.Errorf("read claimed jobs: %w", err) + for _, v := range state.PublicTransparencyItems { + out = append(out, resourceProjection{TenantID: v.TenantID, ResourceType: "public_transparency_log_entry", ResourceID: v.ID, CreatedAt: v.CreatedAt}) } - if err := tx.Commit(ctx); err != nil { - return nil, fmt.Errorf("commit claim jobs transaction: %w", err) + for _, v := range state.MarketplaceCollectors { + out = append(out, resourceProjection{TenantID: v.TenantID, ResourceType: "marketplace_collector", ResourceID: v.ID, CreatedAt: v.CreatedAt}) } - return jobs, nil -} - -func (s *Store) CompleteJob(ctx context.Context, id string) error { - _, err := s.pool.Exec(ctx, ` - UPDATE outbox_jobs - SET status = 'succeeded', locked_at = NULL, last_error = NULL, updated_at = now() - WHERE id = $1 - `, id) - if err != nil { - return fmt.Errorf("complete outbox job: %w", err) + for _, v := range state.PDFReports { + out = append(out, resourceProjection{TenantID: v.TenantID, ResourceType: "pdf_report", ResourceID: v.ID, ProductID: v.ProductID, ReleaseID: v.ReleaseID, CreatedAt: v.CreatedAt}) } - return nil -} - -func (s *Store) FailJob(ctx context.Context, id string, cause error) error { - message := "job failed" - if cause != nil { - message = cause.Error() + for _, v := range state.AnomalyReports { + out = append(out, resourceProjection{TenantID: v.TenantID, ResourceType: "anomaly_report", ResourceID: v.ID, CreatedAt: v.CreatedAt}) } - _, err := s.pool.Exec(ctx, ` - UPDATE outbox_jobs - SET status = CASE WHEN attempts >= max_attempts THEN 'failed' ELSE 'retrying' END, - run_after = now() + make_interval(secs => LEAST(300, POWER(2, attempts)::int)), - locked_at = NULL, - last_error = $2, - updated_at = now() - WHERE id = $1 - `, id, message) - if err != nil { - return fmt.Errorf("fail outbox job: %w", err) + for _, v := range state.ProviderVerifications { + out = append(out, resourceProjection{TenantID: v.TenantID, ResourceType: "provider_verification", ResourceID: v.ID, CreatedAt: v.CreatedAt}) } - return nil -} - -func (s *Store) CountPendingJobs(ctx context.Context) (int, error) { - var count int - if err := s.pool.QueryRow(ctx, `SELECT count(*) FROM outbox_jobs WHERE status IN ('queued', 'retrying', 'running')`).Scan(&count); err != nil { - return 0, fmt.Errorf("count outbox jobs: %w", err) + for _, v := range state.SigningOperations { + out = append(out, resourceProjection{TenantID: v.TenantID, ResourceType: "signing_operation", ResourceID: v.ID, CreatedAt: v.CreatedAt}) } - return count, nil + return out } func (s *Store) Now(ctx context.Context) (time.Time, error) { diff --git a/internal/adapters/postgres/store_test.go b/internal/adapters/postgres/store_test.go index ae2f15c..e45172b 100644 --- a/internal/adapters/postgres/store_test.go +++ b/internal/adapters/postgres/store_test.go @@ -3,13 +3,213 @@ package postgres import ( "context" "os" + "strings" "testing" "time" + "github.com/jackc/pgx/v5" + + fsobject "github.com/aatuh/evydence/internal/adapters/objectstore/filesystem" "github.com/aatuh/evydence/internal/app" "github.com/aatuh/evydence/internal/domain" ) +func TestResolveLoadMode(t *testing.T) { + tests := []struct { + name string + raw string + production bool + want LoadMode + wantErr bool + }{ + {name: "local default", want: LoadModeSnapshotPreferred}, + {name: "production default", production: true, want: LoadModeRelationalOnly}, + {name: "snapshot alias", raw: "snapshot", production: true, want: LoadModeSnapshotPreferred}, + {name: "relational alias", raw: "relational", want: LoadModeRelationalPreferred}, + {name: "relational only", raw: "relational-only", want: LoadModeRelationalOnly}, + {name: "invalid", raw: "unsafe", wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ResolveLoadMode(tt.raw, tt.production) + if tt.wantErr { + if err == nil { + t.Fatal("expected error") + } + return + } + if err != nil { + t.Fatalf("ResolveLoadMode: %v", err) + } + if got != tt.want { + t.Fatalf("mode = %q, want %q", got, tt.want) + } + }) + } +} + +func TestValidateProductionLoadModeRequiresRelationalOnly(t *testing.T) { + if err := ValidateProductionLoadMode(LoadModeRelationalOnly); err != nil { + t.Fatalf("relational-only production mode: %v", err) + } + for _, mode := range []LoadMode{LoadModeSnapshotPreferred, LoadModeRelationalPreferred} { + err := ValidateProductionLoadMode(mode) + if err == nil || !strings.Contains(err.Error(), "EVYDENCE_POSTGRES_LOAD_MODE") { + t.Fatalf("%s production err=%v", mode, err) + } + } +} + +func TestStoreAPIWriterLeaseIsExclusive(t *testing.T) { + databaseURL := os.Getenv("EVYDENCE_TEST_DATABASE_URL") + if databaseURL == "" { + t.Skip("EVYDENCE_TEST_DATABASE_URL is not set") + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + first, err := Open(ctx, databaseURL) + if err != nil { + t.Fatal(err) + } + defer first.Close() + second, err := Open(ctx, databaseURL) + if err != nil { + t.Fatal(err) + } + defer second.Close() + releaseFirst, err := first.AcquireAPIWriterLease(ctx) + if err != nil { + t.Fatalf("first lease: %v", err) + } + if _, err := second.AcquireAPIWriterLease(ctx); err == nil || !strings.Contains(err.Error(), "already active") { + releaseFirst() + t.Fatalf("second lease err=%v, want active writer rejection", err) + } + releaseFirst() + releaseSecond, err := second.AcquireAPIWriterLease(ctx) + if err != nil { + t.Fatalf("second lease after release: %v", err) + } + releaseSecond() +} + +func TestStoreCanDisableSnapshotWritesAndLoadRelationalState(t *testing.T) { + databaseURL := os.Getenv("EVYDENCE_TEST_DATABASE_URL") + if databaseURL == "" { + t.Skip("EVYDENCE_TEST_DATABASE_URL is not set") + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + admin, err := Open(ctx, databaseURL) + if err != nil { + t.Fatal(err) + } + defer admin.Close() + schema := "evydence_snapshot_disabled_" + strings.ReplaceAll(strings.ToLower(time.Now().Format("20060102150405.000000000")), ".", "_") + quotedSchema := pgx.Identifier{schema}.Sanitize() + if _, err := admin.pool.Exec(ctx, "CREATE SCHEMA "+quotedSchema); err != nil { + t.Fatal(err) + } + defer func(cleanupCtx context.Context) { + _, _ = admin.pool.Exec(cleanupCtx, "DROP SCHEMA "+quotedSchema+" CASCADE") + }(context.WithoutCancel(ctx)) + + store, err := OpenWithOptions(ctx, databaseURLWithSearchPath(t, databaseURL, schema), StoreOptions{LoadMode: LoadModeRelationalPreferred, DisableSnapshotWrites: true}) + if err != nil { + t.Fatal(err) + } + defer store.Close() + if _, err := store.ApplyMigrations(ctx, "../../../migrations"); err != nil { + t.Fatal(err) + } + state := app.PersistedState{ + Tenants: map[string]domain.Tenant{ + "ten_snapshot_disabled": {ID: "ten_snapshot_disabled", Name: "No Snapshot", CreatedAt: time.Now().UTC()}, + }, + Products: map[string]domain.Product{ + "prod_snapshot_disabled": {ID: "prod_snapshot_disabled", TenantID: "ten_snapshot_disabled", Name: "Relational Product", Slug: "relational-product", CreatedAt: time.Now().UTC()}, + }, + } + if err := store.SaveState(ctx, state); err != nil { + t.Fatal(err) + } + var snapshotRows int + if err := store.pool.QueryRow(ctx, `SELECT count(*) FROM ledger_state`).Scan(&snapshotRows); err != nil { + t.Fatal(err) + } + if snapshotRows != 0 { + t.Fatalf("ledger_state rows = %d, want 0", snapshotRows) + } + loaded, ok, err := store.LoadState(ctx) + if err != nil || !ok { + t.Fatalf("load relational state ok=%v err=%v", ok, err) + } + if loaded.Products["prod_snapshot_disabled"].Name != "Relational Product" { + t.Fatalf("loaded products = %#v", loaded.Products) + } +} + +func TestStoreSaveRelationalStateSkipsLedgerSnapshot(t *testing.T) { + databaseURL := os.Getenv("EVYDENCE_TEST_DATABASE_URL") + if databaseURL == "" { + t.Skip("EVYDENCE_TEST_DATABASE_URL is not set") + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + admin, err := Open(ctx, databaseURL) + if err != nil { + t.Fatal(err) + } + defer admin.Close() + schema := "evydence_relational_state_" + strings.ReplaceAll(strings.ToLower(time.Now().Format("20060102150405.000000000")), ".", "_") + quotedSchema := pgx.Identifier{schema}.Sanitize() + if _, err := admin.pool.Exec(ctx, "CREATE SCHEMA "+quotedSchema); err != nil { + t.Fatal(err) + } + defer func(cleanupCtx context.Context) { + _, _ = admin.pool.Exec(cleanupCtx, "DROP SCHEMA "+quotedSchema+" CASCADE") + }(context.WithoutCancel(ctx)) + + store, err := OpenWithOptions(ctx, databaseURLWithSearchPath(t, databaseURL, schema), StoreOptions{LoadMode: LoadModeRelationalPreferred}) + if err != nil { + t.Fatal(err) + } + defer store.Close() + if _, err := store.ApplyMigrations(ctx, "../../../migrations"); err != nil { + t.Fatal(err) + } + now := time.Now().UTC() + state := app.PersistedState{ + Tenants: map[string]domain.Tenant{ + "ten_relational_state": {ID: "ten_relational_state", Name: "Relational State", CreatedAt: now}, + }, + ControlFrameworks: map[string]domain.ControlFramework{ + "fw_relational_state": { + ID: "fw_relational_state", TenantID: "ten_relational_state", Name: "Framework", + Slug: "framework", Version: "1.0.0", Status: "active", + SchemaVersion: domain.ControlFrameworkSchemaVersion, CreatedAt: now, + }, + }, + } + if err := store.SaveRelationalState(ctx, state); err != nil { + t.Fatal(err) + } + var snapshotRows int + if err := store.pool.QueryRow(ctx, `SELECT count(*) FROM ledger_state`).Scan(&snapshotRows); err != nil { + t.Fatal(err) + } + if snapshotRows != 0 { + t.Fatalf("ledger_state rows = %d, want 0", snapshotRows) + } + loaded, ok, err := store.LoadState(ctx) + if err != nil || !ok { + t.Fatalf("load relational state ok=%v err=%v", ok, err) + } + if loaded.ControlFrameworks["fw_relational_state"].Slug != "framework" { + t.Fatalf("loaded frameworks = %#v", loaded.ControlFrameworks) + } +} + func TestStoreLoadSaveAndOutboxWithPostgres(t *testing.T) { databaseURL := os.Getenv("EVYDENCE_TEST_DATABASE_URL") if databaseURL == "" { @@ -29,6 +229,290 @@ func TestStoreLoadSaveAndOutboxWithPostgres(t *testing.T) { Tenants: map[string]domain.Tenant{ "ten_test": {ID: "ten_test", Name: "Test", CreatedAt: time.Now().UTC()}, }, + Organizations: map[string]domain.Organization{ + "org_test": {ID: "org_test", TenantID: "ten_test", Name: "Org", Slug: "org", Status: "active", SchemaVersion: domain.OrganizationSchemaVersion, CreatedAt: time.Now().UTC()}, + }, + Users: map[string]domain.HumanUser{ + "user_test": {ID: "user_test", TenantID: "ten_test", OrganizationID: "org_test", Email: "user@example.test", DisplayName: "User", Status: "active", SchemaVersion: domain.HumanUserSchemaVersion, CreatedAt: time.Now().UTC()}, + }, + RoleBindings: map[string]domain.RoleBinding{ + "rb_test": {ID: "rb_test", TenantID: "ten_test", SubjectType: "user", SubjectID: "user_test", Role: "security_engineer", SchemaVersion: domain.RoleBindingSchemaVersion, CreatedAt: time.Now().UTC()}, + }, + APIKeys: map[string]domain.APIKey{ + "key_test": {ID: "key_test", TenantID: "ten_test", Name: "api", Prefix: "evy_test", Scopes: []string{"evidence:write"}, CreatedAt: time.Now().UTC()}, + }, + APIKeyHashes: map[string]string{"key_test": "hmac-test-hash"}, + Collectors: map[string]domain.Collector{ + "collector_test": {ID: "collector_test", TenantID: "ten_test", Name: "github", Type: "github_actions", Version: "1.0.0", APIKeyID: "key_test", Status: "active", AllowedScopes: []string{"build:write"}, LastSeenAt: ptrTime(time.Now().UTC()), SchemaVersion: domain.CollectorSchemaVersion, CreatedAt: time.Now().UTC()}, + }, + CollectorReleases: map[string]domain.CollectorRelease{ + "collector_release_test": {ID: "collector_release_test", TenantID: "ten_test", CollectorID: "collector_test", Version: "1.0.0", ArtifactDigest: "sha256:" + strings.Repeat("a", 64), SignatureID: "artsig_test", SBOMID: "sbom_test", ScanID: "scan_test", Pinned: true, VerificationStatus: "verified", HealthStatus: "healthy", Limitations: []string{"test"}, SchemaVersion: domain.CollectorReleaseSchemaVersion, CreatedAt: time.Now().UTC()}, + }, + SSOProviders: map[string]domain.SSOProvider{ + "sso_test": {ID: "sso_test", TenantID: "ten_test", Name: "OIDC", Type: "oidc", Issuer: "https://idp.example.test", ClientID: "client", Status: "active", JWKS: map[string]any{"keys": []any{map[string]any{"kty": "OKP", "kid": "kid-1", "crv": "Ed25519", "x": "abc"}}}, SchemaVersion: domain.SSOProviderSchemaVersion, CreatedAt: time.Now().UTC(), TrustMaterialUpdatedAt: ptrTime(time.Now().UTC())}, + }, + IdentityLinks: map[string]domain.UserIdentityLink{ + "link_test": {ID: "link_test", TenantID: "ten_test", UserID: "user_test", ProviderID: "sso_test", Subject: "sub", Email: "user@example.test", Verified: true, SchemaVersion: "user-identity-link.v1.0.0", CreatedAt: time.Now().UTC()}, + }, + SSOSessions: map[string]domain.SSOSession{ + "sess_test": {ID: "sess_test", TenantID: "ten_test", UserID: "user_test", ProviderID: "sso_test", Prefix: "sess", Groups: []string{"security"}, ExpiresAt: time.Now().UTC().Add(time.Hour), SchemaVersion: domain.SSOSessionSchemaVersion, CreatedAt: time.Now().UTC()}, + }, + SSOSessionHashes: map[string]string{"sess_test": "session-hash"}, + CustomerPortalAccess: map[string]domain.CustomerPortalAccess{ + "cpa_test": {ID: "cpa_test", TenantID: "ten_test", PackageID: "pkg_test", CustomerName: "Customer", ReviewerName: "Reviewer", ReviewerEmail: "reviewer@example.test", Prefix: "evycp_test", ExpiresAt: time.Now().UTC().Add(time.Hour), AccessCount: 2, FailedAccessCount: 1, LastAccessedAt: ptrTime(time.Now().UTC()), LastFailedAt: ptrTime(time.Now().UTC()), SchemaVersion: domain.CustomerPortalAccessVersion, CreatedAt: time.Now().UTC()}, + }, + CustomerPortalHashes: map[string]string{"cpa_test": "portal-token-hash"}, + Products: map[string]domain.Product{ + "prod_test": {ID: "prod_test", TenantID: "ten_test", Name: "Product", Slug: "product", CreatedAt: time.Now().UTC()}, + }, + Projects: map[string]domain.Project{ + "proj_test": {ID: "proj_test", TenantID: "ten_test", ProductID: "prod_test", Name: "API", CreatedAt: time.Now().UTC()}, + }, + Releases: map[string]domain.Release{ + "rel_test": {ID: "rel_test", TenantID: "ten_test", ProductID: "prod_test", Version: "1.0.0", State: "open", CreatedAt: time.Now().UTC()}, + }, + Artifacts: map[string]domain.Artifact{ + "art_test": {ID: "art_test", TenantID: "ten_test", Name: "artifact.tar.gz", MediaType: "application/gzip", Size: 42, Digest: "sha256:" + strings.Repeat("a", 64), CreatedAt: time.Now().UTC()}, + }, + BuildRuns: map[string]domain.BuildRun{ + "build_test": {ID: "build_test", TenantID: "ten_test", ProjectID: "proj_test", ReleaseID: "rel_test", CollectorID: "collector_test", Provider: "github_actions", CommitSHA: strings.Repeat("1", 40), Repository: "org/repo", WorkflowRef: "org/repo/.github/workflows/ci.yml@refs/heads/main", RunID: "123", RunAttempt: 1, Status: "passed", StartedAt: time.Now().UTC(), FinishedAt: ptrTime(time.Now().UTC()), SourceIdentity: map[string]any{"provider": "github_actions"}, Outputs: []domain.BuildOutput{{ArtifactID: "art_test", Digest: "sha256:" + strings.Repeat("a", 64)}}, SchemaVersion: domain.BuildRunSchemaVersion, CreatedAt: time.Now().UTC()}, + }, + BuildAttestations: map[string]domain.BuildAttestation{ + "att_test": {ID: "att_test", TenantID: "ten_test", BuildID: "build_test", EvidenceID: "ev_test", PayloadRef: "object://tenants/ten_test/payloads/attestation/" + strings.Repeat("a", 64), PayloadHash: "sha256:" + strings.Repeat("a", 64), PayloadSize: 100, PayloadType: "application/vnd.dsse.envelope.v1+json", PredicateType: "https://slsa.dev/provenance/v1", SubjectDigests: []string{"sha256:" + strings.Repeat("a", 64)}, BuilderID: "builder", BuildType: "github_actions", MaterialsCount: 1, SignatureCount: 1, VerificationStatus: "structurally_valid", SchemaVersion: domain.BuildAttestationSchemaVersion, CreatedAt: time.Now().UTC()}, + }, + Evidence: map[string]domain.EvidenceItem{ + "ev_test": { + ID: "ev_test", TenantID: "ten_test", ProductID: "prod_test", ProjectID: "proj_test", ReleaseID: "rel_test", + Type: "sbom", Subtype: "cyclonedx", Title: "SBOM", SourceSystem: "test", ObservedAt: time.Now().UTC(), + EvidenceVersion: 1, SchemaVersion: domain.EvidenceItemSchemaVersion, PayloadRef: "object://tenants/ten_test/payloads/sbom/" + strings.Repeat("b", 64), + PayloadHash: "sha256:" + strings.Repeat("b", 64), PayloadMediaType: "application/json", PayloadSize: 123, + CanonicalHash: "sha256:" + strings.Repeat("c", 64), Canonicalization: domain.CanonicalizationProfileVersion, + SubjectRefs: []domain.SubjectRef{{Type: "release", ID: "rel_test"}}, TrustLevel: "uploaded", VerificationStatus: "verified", + Tags: []string{"release"}, Metadata: map[string]any{"parser": "test"}, CreatedAt: time.Now().UTC(), + }, + }, + EvidenceLifecycle: map[string]domain.EvidenceLifecycleEvent{ + "life_test": {ID: "life_test", TenantID: "ten_test", EvidenceID: "ev_test", Action: "amended", Reason: "test", Details: map[string]any{"field": "metadata"}, ActorID: "user_test", SchemaVersion: domain.EvidenceLifecycleSchemaVersion, CreatedAt: time.Now().UTC()}, + }, + ReleaseCandidates: map[string]domain.ReleaseCandidate{ + "rc_test": {ID: "rc_test", TenantID: "ten_test", ReleaseID: "rel_test", Name: "rc1", State: "open", BuildIDs: []string{"build_test"}, ArtifactIDs: []string{"art_test"}, SBOMIDs: []string{"sbom_test"}, ScanIDs: []string{"scan_test"}, VEXIDs: []string{"vex_test"}, ContractIDs: []string{"contract_test"}, BundleIDs: []string{"bundle_test"}, SnapshotHash: "sha256:" + strings.Repeat("0", 64), SchemaVersion: domain.ReleaseCandidateSchemaVersion, CreatedAt: time.Now().UTC()}, + }, + ContainerImages: map[string]domain.ContainerImage{ + "image_test": {ID: "image_test", TenantID: "ten_test", ArtifactID: "art_test", Repository: "registry.example.test/product", Tag: "1.0.0", Digest: "sha256:" + strings.Repeat("a", 64), Platform: "linux/amd64", SchemaVersion: domain.ContainerImageSchemaVersion, CreatedAt: time.Now().UTC()}, + }, + ArtifactSignatures: map[string]domain.ArtifactSignature{ + "artsig_test": {ID: "artsig_test", TenantID: "ten_test", ArtifactID: "art_test", SubjectDigest: "sha256:" + strings.Repeat("a", 64), Algorithm: "cosign", KeyID: "sigkey_test", Signature: "signature", PayloadRef: "object://tenants/ten_test/signatures/art", PayloadHash: "sha256:" + strings.Repeat("a", 64), VerificationStatus: "verified", SchemaVersion: domain.ArtifactSignatureSchemaVersion, CreatedAt: time.Now().UTC()}, + }, + Repositories: map[string]domain.SourceRepository{ + "repo_test": {ID: "repo_test", TenantID: "ten_test", ProjectID: "proj_test", Provider: "github", FullName: "org/repo", CloneURL: "https://github.com/org/repo.git", DefaultBranch: "main", SchemaVersion: domain.SourceRepositorySchemaVersion, CreatedAt: time.Now().UTC()}, + }, + Commits: map[string]domain.SourceCommit{ + "commit_test": {ID: "commit_test", TenantID: "ten_test", RepositoryID: "repo_test", SHA: strings.Repeat("1", 40), Author: "tester", MessageHash: "sha256:" + strings.Repeat("2", 64), CommittedAt: time.Now().UTC(), SchemaVersion: domain.SourceCommitSchemaVersion, CreatedAt: time.Now().UTC()}, + }, + Branches: map[string]domain.SourceBranch{ + "branch_test": {ID: "branch_test", TenantID: "ten_test", RepositoryID: "repo_test", Name: "main", HeadCommitID: "commit_test", Protected: true, ProtectionHash: "sha256:" + strings.Repeat("3", 64), SchemaVersion: domain.SourceBranchSchemaVersion, CreatedAt: time.Now().UTC()}, + }, + PullRequests: map[string]domain.PullRequest{ + "pr_test": {ID: "pr_test", TenantID: "ten_test", RepositoryID: "repo_test", Provider: "github", ProviderID: "42", Title: "Change", State: "merged", SourceBranch: "feature", TargetBranch: "main", HeadCommitID: "commit_test", ReviewDecision: "approved", SchemaVersion: domain.PullRequestSchemaVersion, CreatedAt: time.Now().UTC()}, + }, + Environments: map[string]domain.DeploymentEnvironment{ + "env_test": {ID: "env_test", TenantID: "ten_test", ProductID: "prod_test", Name: "production", Kind: "production", SchemaVersion: domain.DeploymentEnvironmentVersion, CreatedAt: time.Now().UTC()}, + }, + Deployments: map[string]domain.DeploymentEvent{ + "deploy_test": {ID: "deploy_test", TenantID: "ten_test", EnvironmentID: "env_test", ReleaseID: "rel_test", ArtifactIDs: []string{"art_test"}, Status: "succeeded", StartedAt: time.Now().UTC(), FinishedAt: ptrTime(time.Now().UTC()), EvidenceID: "ev_test", SchemaVersion: domain.DeploymentEventSchemaVersion, CreatedAt: time.Now().UTC()}, + }, + Incidents: map[string]domain.Incident{ + "incident_test": {ID: "incident_test", TenantID: "ten_test", ProductID: "prod_test", ReleaseID: "rel_test", Title: "Incident", Severity: "medium", Status: "resolved", OpenedAt: time.Now().UTC(), ClosedAt: ptrTime(time.Now().UTC()), SchemaVersion: domain.IncidentSchemaVersion, CreatedAt: time.Now().UTC()}, + }, + TimelineEvents: map[string]domain.IncidentTimelineEvent{ + "timeline_test": {ID: "timeline_test", TenantID: "ten_test", IncidentID: "incident_test", EventType: "detected", Summary: "detected", EvidenceID: "ev_test", OccurredAt: time.Now().UTC(), SchemaVersion: domain.IncidentTimelineSchemaVersion, CreatedAt: time.Now().UTC()}, + }, + IncidentWebhookReceivers: map[string]domain.IncidentWebhookReceiver{ + "receiver_test": {ID: "receiver_test", TenantID: "ten_test", IncidentID: "incident_test", Name: "pager", Provider: "generic", PublicKey: "pub", Status: "active", SchemaVersion: domain.IncidentWebhookReceiverVersion, CreatedAt: time.Now().UTC()}, + }, + IncidentWebhookEvents: map[string]domain.IncidentWebhookEvent{ + "webhook_event_test": {ID: "webhook_event_test", TenantID: "ten_test", ReceiverID: "receiver_test", IncidentID: "incident_test", Provider: "generic", EventID: "evt-1", PayloadHash: "sha256:" + strings.Repeat("a", 64), SignatureHash: "sha256:" + strings.Repeat("b", 64), TimelineEventID: "timeline_test", Result: "accepted", SchemaVersion: domain.IncidentWebhookEventVersion, CreatedAt: time.Now().UTC()}, + }, + RemediationTasks: map[string]domain.RemediationTask{ + "remediation_test": {ID: "remediation_test", TenantID: "ten_test", IncidentID: "incident_test", ReleaseID: "rel_test", Title: "Patch", Owner: "security", Status: "done", DueAt: ptrTime(time.Now().UTC().Add(24 * time.Hour)), EvidenceID: "ev_test", SchemaVersion: domain.RemediationTaskSchemaVersion, CreatedAt: time.Now().UTC()}, + }, + SecurityScans: map[string]domain.SecurityScan{ + "secscan_test": {ID: "secscan_test", TenantID: "ten_test", ProductID: "prod_test", ReleaseID: "rel_test", ArtifactID: "art_test", Category: "sast", Format: "sarif", Scanner: "scanner", TargetRef: "repo", EvidenceID: "ev_test", PayloadRef: "object://tenants/ten_test/security/sarif", PayloadHash: "sha256:" + strings.Repeat("c", 64), FindingCount: 1, Summary: map[string]int{"high": 1}, Redacted: true, SchemaVersion: domain.SecurityScanSchemaVersion, CreatedAt: time.Now().UTC()}, + }, + ManualSecurityDocs: map[string]domain.ManualSecurityDocument{ + "manual_doc_test": {ID: "manual_doc_test", TenantID: "ten_test", ProductID: "prod_test", ReleaseID: "rel_test", DocumentType: "security_review", Title: "Review", Sensitivity: "restricted", EvidenceID: "ev_test", PayloadRef: "object://tenants/ten_test/manual/review", PayloadHash: "sha256:" + strings.Repeat("d", 64), SchemaVersion: domain.ManualSecurityDocSchemaVersion, CreatedAt: time.Now().UTC()}, + }, + SBOMDiffs: map[string]domain.SBOMDiff{ + "sbomdiff_test": {ID: "sbomdiff_test", TenantID: "ten_test", BaseSBOMID: "sbom_test", TargetSBOMID: "sbom_test", ReleaseID: "rel_test", AddedComponents: []domain.SBOMComponent{{Name: "lib2"}}, UnchangedCount: 1, SchemaVersion: domain.SBOMDiffSchemaVersion, CreatedAt: time.Now().UTC()}, + }, + DependencyChanges: map[string]domain.DependencyChange{ + "depchange_test": {ID: "depchange_test", TenantID: "ten_test", SBOMDiffID: "sbomdiff_test", ChangeType: "added", Component: domain.SBOMComponent{Name: "lib2"}, SchemaVersion: domain.DependencyChangeSchemaVersion, CreatedAt: time.Now().UTC()}, + }, + VulnerabilityWorkflow: map[string]domain.VulnerabilityWorkflowRecord{ + "vulnwf_test": {ID: "vulnwf_test", TenantID: "ten_test", FindingID: "finding_test", ReleaseID: "rel_test", Action: "reopened", Reason: "new evidence", ActorID: "user_test", SchemaVersion: "vulnerability-workflow.v1.0.0", CreatedAt: time.Now().UTC()}, + }, + ContractDiffs: map[string]domain.ContractDiff{ + "contractdiff_test": {ID: "contractdiff_test", TenantID: "ten_test", BaseContractID: "contract_test", TargetContractID: "contract_test", ProductID: "prod_test", ReleaseID: "rel_test", Result: "non_breaking", NonBreakingChanges: []string{"metadata"}, SchemaVersion: domain.ContractDiffSchemaVersion, CreatedAt: time.Now().UTC()}, + }, + CustomPolicies: map[string]domain.CustomPolicy{ + "custom_policy_test": {ID: "custom_policy_test", TenantID: "ten_test", Name: "policy", Version: "1", Description: "test", Rules: []domain.PolicyRule{{Name: "sbom", EvidenceType: "sbom", Severity: "high", Required: true}}, SchemaVersion: domain.CustomPolicySchemaVersion, CreatedAt: time.Now().UTC()}, + }, + CustomPolicyEvaluations: map[string]domain.CustomPolicyEvaluation{ + "custom_policy_eval_test": {ID: "custom_policy_eval_test", TenantID: "ten_test", PolicyID: "custom_policy_test", ReleaseID: "rel_test", Result: "pass", Checks: []domain.PolicyCheck{{Name: "sbom", Result: "passed", Severity: "high"}}, InputHash: "sha256:" + strings.Repeat("e", 64), SchemaVersion: domain.CustomPolicyEvalSchemaVersion, CreatedAt: time.Now().UTC()}, + }, + Waivers: map[string]domain.Waiver{ + "waiver_test": {ID: "waiver_test", TenantID: "ten_test", ScopeType: "release", ScopeID: "rel_test", ControlID: "control_test", PolicyID: "custom_policy_test", Owner: "security", Risk: "accepted", Reason: "test", ExpiresAt: time.Now().UTC().Add(24 * time.Hour), Approved: true, ApprovedBy: "user_test", ApprovedAt: ptrTime(time.Now().UTC()), SchemaVersion: domain.WaiverSchemaVersion, CreatedAt: time.Now().UTC()}, + }, + Approvals: map[string]domain.ApprovalRecord{ + "approval_test": {ID: "approval_test", TenantID: "ten_test", SubjectType: "release", SubjectID: "rel_test", Decision: "approved", Reason: "test", ApproverID: "user_test", EvidenceID: "ev_test", SchemaVersion: domain.ApprovalRecordSchemaVersion, CreatedAt: time.Now().UTC()}, + }, + DSSETrustRoots: map[string]domain.DSSETrustRoot{ + "dsse_root_test": {ID: "dsse_root_test", TenantID: "ten_test", Name: "root", KeyID: "key-1", Algorithm: "Ed25519", PublicKey: "pub", Status: "active", SchemaVersion: domain.DSSETrustRootSchemaVersion, CreatedAt: time.Now().UTC()}, + }, + CosignVerifications: map[string]domain.CosignVerification{ + "cosign_test": {ID: "cosign_test", TenantID: "ten_test", ArtifactID: "art_test", ContainerImageID: "image_test", ArtifactSignatureID: "artsig_test", SubjectDigest: "sha256:" + strings.Repeat("a", 64), RekorUUID: "rekor", RekorLogIndex: "1", CertificateIdentity: "repo", CertificateIssuer: "issuer", Result: "pass", Checks: []domain.VerifyCheck{{Name: "digest", Result: "passed"}}, SchemaVersion: domain.CosignVerificationSchemaVersion, CreatedAt: time.Now().UTC()}, + }, + SigningProviders: map[string]domain.SigningProvider{ + "sign_provider_test": {ID: "sign_provider_test", TenantID: "ten_test", Name: "kms", Type: "aws_kms", Status: "active", KeyRef: "arn:aws:kms:test", Encrypted: true, SchemaVersion: domain.SigningProviderSchemaVersion, CreatedAt: time.Now().UTC()}, + }, + MerkleBatches: map[string]domain.MerkleBatch{ + "merkle_test": {ID: "merkle_test", TenantID: "ten_test", FromSequence: 1, ToSequence: 1, EntryCount: 1, LeafHashes: []string{"sha256:" + strings.Repeat("e", 64)}, RootHash: "sha256:" + strings.Repeat("f", 64), SignatureRefs: []string{"sig_test"}, SchemaVersion: domain.MerkleBatchSchemaVersion, CreatedAt: time.Now().UTC()}, + }, + TransparencyCheckpoints: map[string]domain.TransparencyCheckpoint{ + "transparency_test": {ID: "transparency_test", TenantID: "ten_test", BatchID: "merkle_test", Provider: "internal", ExternalURL: "https://transparency.example.test", ExternalID: "ts-1", TimestampHash: "sha256:" + strings.Repeat("0", 64), State: "recorded", SchemaVersion: domain.TransparencyCheckpointVersion, CreatedAt: time.Now().UTC()}, + }, + Chain: map[string][]domain.AuditChainEntry{ + "ten_test": {{ + ID: "chain_test", TenantID: "ten_test", Sequence: 1, EntryType: "evidence.created", SubjectType: "evidence_item", SubjectID: "ev_test", + ActorType: "user", ActorID: "user_test", OccurredAt: time.Now().UTC(), PayloadHash: "sha256:" + strings.Repeat("b", 64), + CanonicalEntryHash: "sha256:" + strings.Repeat("d", 64), PreviousEntryHash: "", EntryHash: "sha256:" + strings.Repeat("e", 64), + SchemaVersion: domain.AuditChainEntrySchemaVersion, + }}, + }, + SigningKeys: map[string]domain.SigningKey{ + "sigkey_test": {ID: "sigkey_test", TenantID: "ten_test", KID: "kid-test", Algorithm: "Ed25519", Status: "active", PublicKey: "public", CreatedAt: time.Now().UTC()}, + }, + SigningKeyPrivate: map[string][]byte{"sigkey_test": []byte("dev-private-key")}, + Signatures: map[string]domain.Signature{ + "sig_test": {ID: "sig_test", TenantID: "ten_test", SubjectType: "release_bundle", SubjectID: "bundle_test", KeyID: "sigkey_test", Algorithm: "Ed25519", Value: "signature", CreatedAt: time.Now().UTC()}, + }, + SBOMs: map[string]domain.SBOM{ + "sbom_test": {ID: "sbom_test", TenantID: "ten_test", EvidenceID: "ev_test", ReleaseID: "rel_test", ArtifactID: "art_test", Format: "cyclonedx", SpecVersion: "1.5", ComponentCount: 1, Components: []domain.SBOMComponent{{Name: "lib", Version: "1.0.0"}}, CreatedAt: time.Now().UTC()}, + }, + Scans: map[string]domain.VulnerabilityScan{ + "scan_test": {ID: "scan_test", TenantID: "ten_test", EvidenceID: "ev_test", ReleaseID: "rel_test", Scanner: "scanner", TargetRef: "artifact.tar.gz", Summary: map[string]int{"critical": 0}, Findings: []domain.VulnerabilityFinding{{ID: "finding_test", Vulnerability: "CVE-0000-0001", Severity: "low", State: "open"}}, CreatedAt: time.Now().UTC()}, + }, + VEXDocuments: map[string]domain.VEXDocument{ + "vex_test": {ID: "vex_test", TenantID: "ten_test", EvidenceID: "ev_test", ReleaseID: "rel_test", ArtifactID: "art_test", Format: "openvex", Author: "tester", Version: "1", StatementCount: 1, StatusSummary: map[string]int{"not_affected": 1}, SchemaVersion: domain.VEXDocumentSchemaVersion, CreatedAt: time.Now().UTC()}, + }, + VEXImportReports: map[string]domain.VEXImportReport{ + "vex_report_test": {ID: "vex_report_test", TenantID: "ten_test", VEXDocumentID: "vex_test", EvidenceID: "ev_test", ReleaseID: "rel_test", ArtifactID: "art_test", ParserVersion: app.ParserVersionOpenVEXJSON, Status: "parsed", StatementCount: 1, DecisionsCreated: 1, MappingFailures: []domain.VEXImportIssue{{StatementIndex: 2, Code: "finding_not_found", Detail: "No matching finding."}}, SchemaVersion: domain.VEXImportReportSchemaVersion, CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC()}, + }, + Decisions: map[string]domain.VulnerabilityDecision{ + "decision_test": {ID: "decision_test", TenantID: "ten_test", FindingID: "finding_test", ScanID: "scan_test", ReleaseID: "rel_test", Vulnerability: "CVE-0000-0001", Component: "lib", SBOMID: "sbom_test", SBOMComponentName: "lib", Status: "not_affected", Justification: "not_present", Source: "manual", EvidenceID: "ev_test", VEXDocumentID: "vex_test", SupportingRefs: []domain.SubjectRef{{Type: "release_bundle", ID: "bundle_test"}}, SchemaVersion: domain.VulnerabilityDecisionVersion, CreatedAt: time.Now().UTC()}, + }, + Contracts: map[string]domain.OpenAPIContract{ + "contract_test": {ID: "contract_test", TenantID: "ten_test", ProductID: "prod_test", ReleaseID: "rel_test", Version: "1.0.0", Hash: "sha256:" + strings.Repeat("f", 64), PathCount: 1, Operations: []domain.OpenAPIOperation{{Path: "/v1/test", Method: "get", OperationID: "getTest"}}, EvidenceID: "ev_test", CreatedAt: time.Now().UTC()}, + }, + Policies: map[string]domain.PolicyEvaluation{ + "policy_test": {ID: "policy_test", TenantID: "ten_test", ReleaseID: "rel_test", Result: "pass", PolicySet: domain.PolicySetVersion, Checks: []domain.PolicyCheck{{Name: "sbom", Result: "passed", Severity: "high", Explanation: "test"}}, CreatedAt: time.Now().UTC()}, + }, + Bundles: map[string]domain.ReleaseBundle{ + "bundle_test": {ID: "bundle_test", TenantID: "ten_test", ReleaseID: "rel_test", State: "generated", Manifest: map[string]any{"release_id": "rel_test"}, ManifestHash: "sha256:" + strings.Repeat("1", 64), SignatureRefs: []string{"sig_test"}, CreatedAt: time.Now().UTC()}, + }, + Verifications: map[string]domain.VerificationResult{ + "verify_test": {ID: "verify_test", TenantID: "ten_test", SubjectType: "release_bundle", SubjectID: "bundle_test", Result: "pass", Checks: []domain.VerifyCheck{{Name: "signature", Result: "passed"}}, VerifiedAt: time.Now().UTC()}, + }, + Exceptions: map[string]domain.Exception{ + "exception_test": {ID: "exception_test", TenantID: "ten_test", ReleaseID: "rel_test", FindingID: "finding_test", Reason: "accepted for test", Owner: "security", ExpiresAt: time.Now().UTC().Add(24 * time.Hour), Approved: true, ApprovedBy: "user_test", ApprovedAt: ptrTime(time.Now().UTC()), CreatedAt: time.Now().UTC()}, + }, + ControlFrameworks: map[string]domain.ControlFramework{ + "framework_test": {ID: "framework_test", TenantID: "ten_test", Name: "Framework", Slug: "framework", Version: "1", Description: "test", Status: "active", SchemaVersion: domain.ControlFrameworkSchemaVersion, CreatedAt: time.Now().UTC()}, + }, + SecurityControls: map[string]domain.SecurityControl{ + "control_test": {ID: "control_test", TenantID: "ten_test", FrameworkID: "framework_test", Code: "EVY-1", Title: "Evidence", Objective: "Collect evidence", EvidenceRequirements: []domain.ControlEvidenceRequirement{{Type: "sbom", Required: true}}, Applicability: []string{"release"}, Limitations: []string{"test"}, SchemaVersion: domain.SecurityControlSchemaVersion, CreatedAt: time.Now().UTC()}, + }, + ControlEvidence: map[string]domain.ControlEvidence{ + "control_evidence_test": {ID: "control_evidence_test", TenantID: "ten_test", ControlID: "control_test", EvidenceType: "sbom", SubjectType: "evidence", SubjectID: "ev_test", ProductID: "prod_test", ReleaseID: "rel_test", Confidence: "high", Notes: "linked", SchemaVersion: domain.ControlEvidenceSchemaVersion, CreatedAt: time.Now().UTC()}, + }, + RedactionProfiles: map[string]domain.RedactionProfile{ + "redact_test": {ID: "redact_test", TenantID: "ten_test", Name: "Default", Description: "profile", AllowedTypes: []string{"sbom"}, ExcludedFields: []string{"metadata.secret"}, SchemaVersion: domain.RedactionProfileSchemaVersion, CreatedAt: time.Now().UTC()}, + }, + CustomerPackages: map[string]domain.CustomerSecurityPackage{ + "pkg_test": {ID: "pkg_test", TenantID: "ten_test", ProductID: "prod_test", ReleaseID: "rel_test", RedactionProfileID: "redact_test", Title: "Package", State: "generated", Manifest: map[string]any{"release_id": "rel_test"}, ManifestHash: "sha256:" + strings.Repeat("2", 64), ExpiresAt: time.Now().UTC().Add(24 * time.Hour), AccessCount: 3, SchemaVersion: domain.CustomerPackageSchemaVersion, CreatedAt: time.Now().UTC()}, + }, + HTMLReports: map[string]domain.HTMLReportPackage{ + "html_test": {ID: "html_test", TenantID: "ten_test", ReportType: "cra_readiness", ProductID: "prod_test", ReleaseID: "rel_test", HTML: "", Hash: "sha256:" + strings.Repeat("3", 64), SchemaVersion: "html-report-package.v1.0.0", CreatedAt: time.Now().UTC()}, + }, + ReportTemplates: map[string]domain.CustomReportTemplate{ + "tpl_test": {ID: "tpl_test", TenantID: "ten_test", Name: "report", Version: "1", ReportType: "custom", AllowedFields: []string{"id"}, Template: "{{id}}", SchemaVersion: domain.ReportTemplateSchemaVersion, CreatedAt: time.Now().UTC()}, + }, + RenderedReports: map[string]domain.RenderedCustomReport{ + "render_test": {ID: "render_test", TenantID: "ten_test", TemplateID: "tpl_test", SubjectType: "release", SubjectID: "rel_test", Output: map[string]any{"id": "rel_test"}, Hash: "sha256:" + strings.Repeat("4", 64), SchemaVersion: "rendered-report.v1.0.0", CreatedAt: time.Now().UTC()}, + }, + EvidenceBundles: map[string]domain.EvidenceBundle{ + "eb_test": {ID: "eb_test", TenantID: "ten_test", ReleaseID: "rel_test", EvidenceIDs: []string{"ev_test"}, Manifest: map[string]any{"evidence_ids": []any{"ev_test"}}, ManifestHash: "sha256:" + strings.Repeat("5", 64), SignatureRefs: []string{"sig_test"}, VerificationText: "verify", SchemaVersion: domain.EvidenceBundleSchemaVersion, CreatedAt: time.Now().UTC()}, + }, + BundleImports: map[string]domain.EvidenceBundleImport{ + "ebi_test": {ID: "ebi_test", TenantID: "ten_test", BundleHash: "sha256:" + strings.Repeat("5", 64), Result: "imported", ImportedCount: 1, SchemaVersion: domain.EvidenceBundleImportVersion, CreatedAt: time.Now().UTC()}, + }, + ObjectRetentionPolicies: map[string]domain.ObjectRetentionPolicy{ + "orp_test": {ID: "orp_test", TenantID: "ten_test", Name: "retain", ObjectPrefix: "tenants/ten_test/", ObjectKey: "tenants/ten_test/raw/sample.json", RequireLegalHold: true, Mode: "governance", RetentionDays: 30, Status: "verified", VerifiedAt: ptrTime(time.Now().UTC()), VerificationHash: "sha256:" + strings.Repeat("6", 64), VerificationChecks: []domain.VerifyCheck{{Name: "versioning", Result: "passed"}}, VerificationLimitations: []string{"test"}, SchemaVersion: domain.ObjectRetentionPolicyVersion, CreatedAt: time.Now().UTC()}, + }, + BackupManifests: map[string]domain.BackupManifest{ + "bak_test": {ID: "bak_test", TenantID: "ten_test", StateHash: "sha256:" + strings.Repeat("7", 64), ResourceCounts: map[string]int{"evidence": 1}, ConsistencyChecks: []domain.VerifyCheck{{Name: "chain", Result: "passed"}}, Limitations: []string{"objects separately backed up"}, SchemaVersion: domain.BackupManifestSchemaVersion, CreatedAt: time.Now().UTC()}, + }, + LegalHolds: map[string]domain.LegalHold{ + "hold_test": {ID: "hold_test", TenantID: "ten_test", ScopeType: "release", ScopeID: "rel_test", Reason: "review", Owner: "security", ReleasedAt: ptrTime(time.Now().UTC()), SchemaVersion: domain.LegalHoldSchemaVersion, CreatedAt: time.Now().UTC()}, + }, + RetentionOverrides: map[string]domain.RetentionOverride{ + "ret_test": {ID: "ret_test", TenantID: "ten_test", ScopeType: "release", ScopeID: "rel_test", RetentionUntil: time.Now().UTC().Add(365 * 24 * time.Hour), Reason: "policy", Owner: "security", SchemaVersion: domain.RetentionOverrideSchemaVersion, CreatedAt: time.Now().UTC()}, + }, + QuestionnaireTemplates: map[string]domain.QuestionnaireTemplate{ + "qt_test": {ID: "qt_test", TenantID: "ten_test", Name: "questionnaire", Version: "1", Questions: []domain.QuestionnaireQuestion{{ID: "q1", Prompt: "Evidence?", EvidenceType: "sbom"}}, SchemaVersion: domain.QuestionnaireTemplateVersion, CreatedAt: time.Now().UTC()}, + }, + QuestionnairePackages: map[string]domain.QuestionnairePackage{ + "qp_test": {ID: "qp_test", TenantID: "ten_test", TemplateID: "qt_test", PackageID: "pkg_test", ProductID: "prod_test", ReleaseID: "rel_test", Responses: []domain.QuestionnaireResponse{{QuestionID: "q1", Answer: "See evidence", EvidenceIDs: []string{"ev_test"}}}, ManifestHash: "sha256:" + strings.Repeat("8", 64), SchemaVersion: domain.QuestionnairePackageVersion, CreatedAt: time.Now().UTC()}, + }, + CommercialCollectors: map[string]domain.CommercialCollectorDefinition{ + "commercial_collector_test": {ID: "commercial_collector_test", TenantID: "ten_test", Name: "scanner", Provider: "scannerco", Version: "1.0.0", ManifestHash: "sha256:" + strings.Repeat("a", 64), AllowedScopes: []string{"evidence:write"}, Status: "active", SchemaVersion: domain.CommercialCollectorVersion, CreatedAt: time.Now().UTC()}, + }, + EvidenceSummaries: map[string]domain.EvidenceSummary{ + "summary_test": {ID: "summary_test", TenantID: "ten_test", SubjectType: "release", SubjectID: "rel_test", EvidenceIDs: []string{"ev_test"}, Summary: "Evidence summary.", Citations: []domain.EvidenceCitation{{EvidenceID: "ev_test", Type: "sbom", Title: "SBOM", CanonicalHash: "sha256:" + strings.Repeat("c", 64)}}, Assumptions: []string{"stored evidence only"}, Limitations: []string{"not a compliance conclusion"}, SchemaVersion: domain.EvidenceSummaryVersion, CreatedAt: time.Now().UTC()}, + }, + QuestionnaireDrafts: map[string]domain.QuestionnaireDraft{ + "draft_test": {ID: "draft_test", TenantID: "ten_test", TemplateID: "qt_test", ProductID: "prod_test", ReleaseID: "rel_test", Responses: []domain.QuestionnaireResponse{{QuestionID: "q1", Answer: "Draft", EvidenceIDs: []string{"ev_test"}}}, ManifestHash: "sha256:" + strings.Repeat("b", 64), Limitations: []string{"draft"}, SchemaVersion: domain.QuestionnaireDraftVersion, CreatedAt: time.Now().UTC()}, + }, + GraphSnapshots: map[string]domain.EvidenceGraphSnapshot{ + "graph_test": {ID: "graph_test", TenantID: "ten_test", ProductID: "prod_test", ReleaseID: "rel_test", Nodes: []domain.GraphNode{{ID: "ev_test", Type: "evidence", Label: "SBOM"}}, Edges: []domain.GraphEdge{{From: "rel_test", To: "ev_test", Relationship: "has_evidence"}}, GraphHash: "sha256:" + strings.Repeat("c", 64), Limitations: []string{"snapshot"}, SchemaVersion: domain.EvidenceGraphSnapshotVersion, CreatedAt: time.Now().UTC()}, + }, + SaaSProfiles: map[string]domain.SaaSEditionProfile{ + "saas_test": {ID: "saas_test", TenantID: "ten_test", Name: "hosted", Region: "eu", AdminTenantID: "ten_test", IsolationModel: "shared-control-plane", Status: "draft", ConfigHash: "sha256:" + strings.Repeat("d", 64), Limitations: []string{"not production"}, SchemaVersion: domain.SaaSEditionProfileVersion, CreatedAt: time.Now().UTC()}, + }, + PublicTransparencyLogs: map[string]domain.PublicTransparencyLog{ + "public_log_test": {ID: "public_log_test", TenantID: "ten_test", Name: "log", Endpoint: "https://log.example.test", PublicKey: "pub", State: "active", SchemaVersion: domain.PublicTransparencyLogVersion, CreatedAt: time.Now().UTC()}, + }, + PublicTransparencyItems: map[string]domain.PublicTransparencyLogEntry{ + "public_entry_test": {ID: "public_entry_test", TenantID: "ten_test", LogID: "public_log_test", CheckpointID: "transparency_test", MerkleBatchID: "merkle_test", ExternalID: "entry-1", EntryHash: "sha256:" + strings.Repeat("e", 64), InclusionRootHash: "sha256:" + strings.Repeat("f", 64), InclusionProofHash: "sha256:" + strings.Repeat("1", 64), InclusionVerifiedAt: ptrTime(time.Now().UTC()), VerificationChecks: []domain.VerifyCheck{{Name: "inclusion", Result: "passed"}}, VerificationLimitations: []string{"operator proof"}, State: "verified", SchemaVersion: domain.PublicTransparencyEntryVersion, CreatedAt: time.Now().UTC()}, + }, + MarketplaceCollectors: map[string]domain.MarketplaceCollector{ + "market_collector_test": {ID: "market_collector_test", TenantID: "ten_test", Name: "scanner", Provider: "scannerco", Version: "1.0.0", Publisher: "scannerco", ManifestHash: "sha256:" + strings.Repeat("a", 64), SignatureID: "artsig_test", SBOMID: "sbom_test", ScanID: "scan_test", State: "published", Limitations: []string{"external distribution"}, SchemaVersion: domain.MarketplaceCollectorVersion, CreatedAt: time.Now().UTC()}, + }, + PDFReports: map[string]domain.PDFReportPackage{ + "pdf_test": {ID: "pdf_test", TenantID: "ten_test", ReportType: "cra_readiness", ProductID: "prod_test", ReleaseID: "rel_test", Title: "PDF", PayloadRef: "object://tenants/ten_test/reports/pdf", PayloadHash: "sha256:" + strings.Repeat("9", 64), PayloadSize: 10, Limitations: []string{"test"}, SchemaVersion: domain.PDFReportPackageVersion, CreatedAt: time.Now().UTC()}, + }, + AnomalyReports: map[string]domain.AnomalyReport{ + "anom_test": {ID: "anom_test", TenantID: "ten_test", SubjectType: "release", SubjectID: "rel_test", Result: "review", Signals: []domain.AnomalySignal{{Name: "gap", Severity: "medium", Detail: "test"}}, Assumptions: []string{"heuristic"}, Limitations: []string{"not ML"}, SchemaVersion: domain.AnomalyReportVersion, CreatedAt: time.Now().UTC()}, + }, + ProviderVerifications: map[string]domain.ProviderVerification{ + "provider_verification_test": {ID: "provider_verification_test", TenantID: "ten_test", ProviderType: "oidc", ProviderID: "sso_test", Subject: "sub", Result: "verified", Checks: []domain.VerifyCheck{{Name: "subject", Result: "passed"}}, Limitations: []string{"static trust material"}, SchemaVersion: domain.ProviderVerificationVersion, CreatedAt: time.Now().UTC()}, + }, + SigningOperations: map[string]domain.SigningOperation{ + "signing_operation_test": {ID: "signing_operation_test", TenantID: "ten_test", ProviderID: "sign_provider_test", SubjectType: "release", SubjectID: "rel_test", PayloadHash: "sha256:" + strings.Repeat("2", 64), SignatureRef: "sig_test", Result: "signed", Checks: []domain.VerifyCheck{{Name: "provider", Result: "passed"}}, SchemaVersion: domain.SigningOperationVersion, CreatedAt: time.Now().UTC()}, + }, + Idempotency: map[string]app.IdempotencyRecord{ + app.NewIdempotencyRecordKey("ten_test", "user:user_test", "POST", "/v1/products", "idem"): {RequestHash: "sha256:request", Status: 201, Response: map[string]any{"ok": true}, CreatedAt: time.Now().UTC()}, + }, } if err := store.SaveState(ctx, state); err != nil { t.Fatal(err) @@ -40,6 +524,25 @@ func TestStoreLoadSaveAndOutboxWithPostgres(t *testing.T) { if !ok || got.Tenants["ten_test"].ID != "ten_test" { t.Fatalf("unexpected loaded state: ok=%v state=%#v", ok, got.Tenants) } + if _, err := store.pool.Exec(ctx, `UPDATE products SET name = $1 WHERE tenant_id = 'ten_test' AND id = 'prod_test'`, "Relational Product"); err != nil { + t.Fatal(err) + } + store.loadMode = LoadModeRelationalPreferred + relationalPreferred, ok, err := store.LoadState(ctx) + if err != nil || !ok { + t.Fatalf("load relational-preferred state ok=%v err=%v", ok, err) + } + if relationalPreferred.Products["prod_test"].Name != "Relational Product" { + t.Fatalf("relational-preferred product name = %q", relationalPreferred.Products["prod_test"].Name) + } + store.loadMode = LoadModeSnapshotPreferred + snapshotPreferred, ok, err := store.LoadState(ctx) + if err != nil || !ok { + t.Fatalf("load snapshot-preferred state ok=%v err=%v", ok, err) + } + if snapshotPreferred.Products["prod_test"].Name != "Product" { + t.Fatalf("snapshot-preferred product name = %q", snapshotPreferred.Products["prod_test"].Name) + } var indexed int if err := store.pool.QueryRow(ctx, `SELECT count(*) FROM resource_index WHERE tenant_id = 'ten_test' AND resource_type = 'tenant'`).Scan(&indexed); err != nil { t.Fatal(err) @@ -47,6 +550,268 @@ func TestStoreLoadSaveAndOutboxWithPostgres(t *testing.T) { if indexed != 1 { t.Fatalf("resource index rows = %d, want 1", indexed) } + var apiKeyHash string + if err := store.pool.QueryRow(ctx, `SELECT hash FROM api_keys WHERE id = 'key_test' AND tenant_id = 'ten_test'`).Scan(&apiKeyHash); err != nil { + t.Fatal(err) + } + if apiKeyHash != "hmac-test-hash" { + t.Fatalf("api key hash = %q", apiKeyHash) + } + var userRows int + if err := store.pool.QueryRow(ctx, `SELECT count(*) FROM human_users WHERE tenant_id = 'ten_test' AND email = 'user@example.test'`).Scan(&userRows); err != nil { + t.Fatal(err) + } + if userRows != 1 { + t.Fatalf("human user rows = %d, want 1", userRows) + } + var ssoTrustRows int + if err := store.pool.QueryRow(ctx, `SELECT count(*) FROM sso_providers WHERE id = 'sso_test' AND trust_material_updated_at IS NOT NULL AND jwks <> '{}'::jsonb`).Scan(&ssoTrustRows); err != nil { + t.Fatal(err) + } + if ssoTrustRows != 1 { + t.Fatalf("sso trust rows = %d, want 1", ssoTrustRows) + } + var idemActor string + if err := store.pool.QueryRow(ctx, `SELECT actor_key_id FROM idempotency_records WHERE tenant_id = 'ten_test' AND idempotency_key = 'idem'`).Scan(&idemActor); err != nil { + t.Fatal(err) + } + if idemActor != "user:user_test" { + t.Fatalf("idempotency actor = %q", idemActor) + } + var portalHash string + if err := store.pool.QueryRow(ctx, `SELECT hash FROM customer_portal_access WHERE id = 'cpa_test' AND reviewer_email = 'reviewer@example.test' AND failed_access_count = 1 AND last_accessed_at IS NOT NULL`).Scan(&portalHash); err != nil { + t.Fatal(err) + } + if portalHash != "portal-token-hash" { + t.Fatalf("portal hash = %q", portalHash) + } + coreChecks := []struct { + name string + query string + }{ + {name: "product", query: `SELECT count(*) FROM products WHERE tenant_id = 'ten_test' AND id = 'prod_test'`}, + {name: "project", query: `SELECT count(*) FROM projects WHERE tenant_id = 'ten_test' AND product_id = 'prod_test'`}, + {name: "release", query: `SELECT count(*) FROM releases WHERE tenant_id = 'ten_test' AND id = 'rel_test'`}, + {name: "artifact", query: `SELECT count(*) FROM artifacts WHERE tenant_id = 'ten_test' AND digest LIKE 'sha256:%'`}, + {name: "collector", query: `SELECT count(*) FROM collectors WHERE tenant_id = 'ten_test' AND id = 'collector_test' AND allowed_scopes <> '[]'::jsonb`}, + {name: "collector release", query: `SELECT count(*) FROM collector_releases WHERE tenant_id = 'ten_test' AND id = 'collector_release_test' AND pinned = true`}, + {name: "build run", query: `SELECT count(*) FROM build_runs WHERE tenant_id = 'ten_test' AND id = 'build_test' AND outputs <> '[]'::jsonb`}, + {name: "build attestation", query: `SELECT count(*) FROM build_attestations WHERE tenant_id = 'ten_test' AND id = 'att_test' AND subject_digests <> '[]'::jsonb`}, + {name: "evidence", query: `SELECT count(*) FROM evidence_items WHERE tenant_id = 'ten_test' AND id = 'ev_test' AND evidence_version = 1 AND product_id = 'prod_test'`}, + {name: "evidence lifecycle", query: `SELECT count(*) FROM evidence_lifecycle_events WHERE tenant_id = 'ten_test' AND id = 'life_test' AND details <> '{}'::jsonb`}, + {name: "release candidate", query: `SELECT count(*) FROM release_candidates WHERE tenant_id = 'ten_test' AND id = 'rc_test' AND document <> '{}'::jsonb`}, + {name: "container image", query: `SELECT count(*) FROM container_images WHERE tenant_id = 'ten_test' AND id = 'image_test'`}, + {name: "artifact signature", query: `SELECT count(*) FROM artifact_signatures WHERE tenant_id = 'ten_test' AND id = 'artsig_test'`}, + {name: "source repository", query: `SELECT count(*) FROM source_repositories WHERE tenant_id = 'ten_test' AND id = 'repo_test'`}, + {name: "source commit", query: `SELECT count(*) FROM source_commits WHERE tenant_id = 'ten_test' AND id = 'commit_test'`}, + {name: "source branch", query: `SELECT count(*) FROM source_branches WHERE tenant_id = 'ten_test' AND id = 'branch_test' AND protected = true`}, + {name: "pull request", query: `SELECT count(*) FROM pull_requests WHERE tenant_id = 'ten_test' AND id = 'pr_test' AND review_decision = 'approved'`}, + {name: "deployment environment", query: `SELECT count(*) FROM deployment_environments WHERE tenant_id = 'ten_test' AND id = 'env_test'`}, + {name: "deployment event", query: `SELECT count(*) FROM deployment_events WHERE tenant_id = 'ten_test' AND id = 'deploy_test' AND artifact_ids = ARRAY['art_test']`}, + {name: "incident", query: `SELECT count(*) FROM incidents WHERE tenant_id = 'ten_test' AND id = 'incident_test' AND status = 'resolved'`}, + {name: "incident timeline", query: `SELECT count(*) FROM incident_timeline_events WHERE tenant_id = 'ten_test' AND id = 'timeline_test' AND evidence_id = 'ev_test'`}, + {name: "incident webhook receiver", query: `SELECT count(*) FROM incident_webhook_receivers WHERE tenant_id = 'ten_test' AND id = 'receiver_test' AND status = 'active'`}, + {name: "incident webhook event", query: `SELECT count(*) FROM incident_webhook_events WHERE tenant_id = 'ten_test' AND id = 'webhook_event_test' AND timeline_event_id = 'timeline_test'`}, + {name: "remediation task", query: `SELECT count(*) FROM remediation_tasks WHERE tenant_id = 'ten_test' AND id = 'remediation_test' AND status = 'done'`}, + {name: "security scan", query: `SELECT count(*) FROM security_scans WHERE tenant_id = 'ten_test' AND id = 'secscan_test' AND summary <> '{}'::jsonb`}, + {name: "manual security doc", query: `SELECT count(*) FROM manual_security_documents WHERE tenant_id = 'ten_test' AND id = 'manual_doc_test' AND sensitivity = 'restricted'`}, + {name: "sbom diff", query: `SELECT count(*) FROM sbom_diffs WHERE tenant_id = 'ten_test' AND id = 'sbomdiff_test' AND document <> '{}'::jsonb`}, + {name: "dependency change", query: `SELECT count(*) FROM dependency_changes WHERE tenant_id = 'ten_test' AND id = 'depchange_test' AND component <> '{}'::jsonb`}, + {name: "vulnerability workflow", query: `SELECT count(*) FROM vulnerability_workflow_records WHERE tenant_id = 'ten_test' AND id = 'vulnwf_test' AND action = 'reopened'`}, + {name: "contract diff", query: `SELECT count(*) FROM contract_diffs WHERE tenant_id = 'ten_test' AND id = 'contractdiff_test' AND document <> '{}'::jsonb`}, + {name: "custom policy", query: `SELECT count(*) FROM custom_policies WHERE tenant_id = 'ten_test' AND id = 'custom_policy_test' AND rules <> '[]'::jsonb`}, + {name: "custom policy evaluation", query: `SELECT count(*) FROM custom_policy_evaluations WHERE tenant_id = 'ten_test' AND id = 'custom_policy_eval_test' AND checks <> '[]'::jsonb`}, + {name: "waiver", query: `SELECT count(*) FROM waivers WHERE tenant_id = 'ten_test' AND id = 'waiver_test' AND approved = true`}, + {name: "approval", query: `SELECT count(*) FROM approval_records WHERE tenant_id = 'ten_test' AND id = 'approval_test' AND evidence_id = 'ev_test'`}, + {name: "dsse trust root", query: `SELECT count(*) FROM dsse_trust_roots WHERE tenant_id = 'ten_test' AND id = 'dsse_root_test' AND status = 'active'`}, + {name: "cosign verification", query: `SELECT count(*) FROM cosign_verifications WHERE tenant_id = 'ten_test' AND id = 'cosign_test' AND checks <> '[]'::jsonb`}, + {name: "signing provider", query: `SELECT count(*) FROM signing_providers WHERE tenant_id = 'ten_test' AND id = 'sign_provider_test' AND encrypted = true`}, + {name: "merkle batch", query: `SELECT count(*) FROM merkle_batches WHERE tenant_id = 'ten_test' AND id = 'merkle_test' AND signature_refs = ARRAY['sig_test']`}, + {name: "transparency checkpoint", query: `SELECT count(*) FROM transparency_checkpoints WHERE tenant_id = 'ten_test' AND id = 'transparency_test' AND external_id = 'ts-1'`}, + {name: "audit chain", query: `SELECT count(*) FROM audit_chain_entries WHERE tenant_id = 'ten_test' AND sequence = 1`}, + {name: "signing key", query: `SELECT count(*) FROM signing_keys WHERE tenant_id = 'ten_test' AND id = 'sigkey_test' AND encrypted_private_key IS NOT NULL`}, + {name: "signature", query: `SELECT count(*) FROM signatures WHERE tenant_id = 'ten_test' AND id = 'sig_test'`}, + {name: "sbom", query: `SELECT count(*) FROM sboms WHERE tenant_id = 'ten_test' AND release_id = 'rel_test' AND component_count = 1`}, + {name: "scan", query: `SELECT count(*) FROM vulnerability_scans WHERE tenant_id = 'ten_test' AND release_id = 'rel_test'`}, + {name: "vex", query: `SELECT count(*) FROM vex_documents WHERE tenant_id = 'ten_test' AND id = 'vex_test' AND statement_count = 1`}, + {name: "vex report", query: `SELECT count(*) FROM vex_import_reports WHERE tenant_id = 'ten_test' AND id = 'vex_report_test' AND decisions_created = 1`}, + {name: "decision", query: `SELECT count(*) FROM vulnerability_decisions WHERE tenant_id = 'ten_test' AND id = 'decision_test' AND status = 'not_affected' AND sbom_id = 'sbom_test'`}, + {name: "exception", query: `SELECT count(*) FROM exceptions WHERE tenant_id = 'ten_test' AND id = 'exception_test' AND approved = true`}, + {name: "contract", query: `SELECT count(*) FROM openapi_contracts WHERE tenant_id = 'ten_test' AND id = 'contract_test' AND operations <> '[]'::jsonb`}, + {name: "policy", query: `SELECT count(*) FROM policy_evaluations WHERE tenant_id = 'ten_test' AND id = 'policy_test'`}, + {name: "bundle", query: `SELECT count(*) FROM release_bundles WHERE tenant_id = 'ten_test' AND id = 'bundle_test'`}, + {name: "verification", query: `SELECT count(*) FROM verification_results WHERE tenant_id = 'ten_test' AND id = 'verify_test'`}, + {name: "control framework", query: `SELECT count(*) FROM control_frameworks WHERE tenant_id = 'ten_test' AND id = 'framework_test'`}, + {name: "security control", query: `SELECT count(*) FROM security_controls WHERE tenant_id = 'ten_test' AND id = 'control_test' AND evidence_requirements <> '[]'::jsonb`}, + {name: "control evidence", query: `SELECT count(*) FROM control_evidence WHERE tenant_id = 'ten_test' AND id = 'control_evidence_test'`}, + {name: "redaction profile", query: `SELECT count(*) FROM redaction_profiles WHERE tenant_id = 'ten_test' AND id = 'redact_test' AND allowed_types = ARRAY['sbom']`}, + {name: "customer package", query: `SELECT count(*) FROM customer_security_packages WHERE tenant_id = 'ten_test' AND id = 'pkg_test' AND access_count = 3`}, + {name: "html report", query: `SELECT count(*) FROM html_report_packages WHERE tenant_id = 'ten_test' AND id = 'html_test'`}, + {name: "report template", query: `SELECT count(*) FROM report_templates WHERE tenant_id = 'ten_test' AND id = 'tpl_test'`}, + {name: "rendered report", query: `SELECT count(*) FROM rendered_reports WHERE tenant_id = 'ten_test' AND id = 'render_test'`}, + {name: "evidence bundle", query: `SELECT count(*) FROM evidence_bundles WHERE tenant_id = 'ten_test' AND id = 'eb_test'`}, + {name: "evidence bundle import", query: `SELECT count(*) FROM evidence_bundle_imports WHERE tenant_id = 'ten_test' AND id = 'ebi_test'`}, + {name: "object retention", query: `SELECT count(*) FROM object_retention_policies WHERE tenant_id = 'ten_test' AND id = 'orp_test' AND require_legal_hold = true AND verification_checks <> '[]'::jsonb`}, + {name: "backup manifest", query: `SELECT count(*) FROM backup_manifests WHERE tenant_id = 'ten_test' AND id = 'bak_test'`}, + {name: "legal hold", query: `SELECT count(*) FROM legal_holds WHERE tenant_id = 'ten_test' AND id = 'hold_test' AND released_at IS NOT NULL`}, + {name: "retention override", query: `SELECT count(*) FROM retention_overrides WHERE tenant_id = 'ten_test' AND id = 'ret_test'`}, + {name: "questionnaire template", query: `SELECT count(*) FROM questionnaire_templates WHERE tenant_id = 'ten_test' AND id = 'qt_test'`}, + {name: "questionnaire package", query: `SELECT count(*) FROM questionnaire_packages WHERE tenant_id = 'ten_test' AND id = 'qp_test'`}, + {name: "commercial collector", query: `SELECT count(*) FROM commercial_collectors WHERE tenant_id = 'ten_test' AND id = 'commercial_collector_test' AND allowed_scopes = ARRAY['evidence:write']`}, + {name: "evidence summary", query: `SELECT count(*) FROM evidence_summaries WHERE tenant_id = 'ten_test' AND id = 'summary_test' AND citations <> '[]'::jsonb`}, + {name: "questionnaire draft", query: `SELECT count(*) FROM questionnaire_drafts WHERE tenant_id = 'ten_test' AND id = 'draft_test' AND responses <> '[]'::jsonb`}, + {name: "graph snapshot", query: `SELECT count(*) FROM evidence_graph_snapshots WHERE tenant_id = 'ten_test' AND id = 'graph_test' AND nodes <> '[]'::jsonb`}, + {name: "saas profile", query: `SELECT count(*) FROM saas_edition_profiles WHERE tenant_id = 'ten_test' AND id = 'saas_test' AND status = 'draft'`}, + {name: "public transparency log", query: `SELECT count(*) FROM public_transparency_logs WHERE tenant_id = 'ten_test' AND id = 'public_log_test' AND state = 'active'`}, + {name: "public transparency entry", query: `SELECT count(*) FROM public_transparency_log_entries WHERE tenant_id = 'ten_test' AND id = 'public_entry_test' AND inclusion_verified_at IS NOT NULL AND verification_checks <> '[]'::jsonb`}, + {name: "marketplace collector", query: `SELECT count(*) FROM marketplace_collectors WHERE tenant_id = 'ten_test' AND id = 'market_collector_test' AND state = 'published'`}, + {name: "pdf report", query: `SELECT count(*) FROM pdf_report_packages WHERE tenant_id = 'ten_test' AND id = 'pdf_test'`}, + {name: "anomaly report", query: `SELECT count(*) FROM anomaly_reports WHERE tenant_id = 'ten_test' AND id = 'anom_test'`}, + {name: "provider verification", query: `SELECT count(*) FROM provider_verifications WHERE tenant_id = 'ten_test' AND id = 'provider_verification_test' AND checks <> '[]'::jsonb`}, + {name: "signing operation", query: `SELECT count(*) FROM signing_operations WHERE tenant_id = 'ten_test' AND id = 'signing_operation_test' AND signature_ref = 'sig_test'`}, + } + for _, check := range coreChecks { + var rows int + if err := store.pool.QueryRow(ctx, check.query).Scan(&rows); err != nil { + t.Fatalf("%s relational row query: %v", check.name, err) + } + if rows != 1 { + t.Fatalf("%s relational rows = %d, want 1", check.name, rows) + } + } + if _, err := store.pool.Exec(ctx, `DELETE FROM ledger_state WHERE id = 'default'`); err != nil { + t.Fatal(err) + } + relational, ok, err := store.LoadState(ctx) + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatal("expected relational state fallback after removing snapshot") + } + if relational.APIKeyHashes["key_test"] != "hmac-test-hash" { + t.Fatalf("relational api key hash = %q", relational.APIKeyHashes["key_test"]) + } + if relational.Organizations["org_test"].Slug != "org" || relational.Users["user_test"].OrganizationID != "org_test" || relational.RoleBindings["rb_test"].Role != "security_engineer" { + t.Fatalf("relational identity rows missing: org=%#v user=%#v role=%#v", relational.Organizations["org_test"], relational.Users["user_test"], relational.RoleBindings["rb_test"]) + } + if relational.SSOProviders["sso_test"].TrustMaterialUpdatedAt == nil || len(relational.SSOProviders["sso_test"].JWKS) == 0 || !relational.IdentityLinks["link_test"].Verified { + t.Fatalf("relational sso rows missing: provider=%#v link=%#v", relational.SSOProviders["sso_test"], relational.IdentityLinks["link_test"]) + } + if relational.SSOSessionHashes["sess_test"] != "session-hash" || relational.SSOSessions["sess_test"].Prefix != "sess" || len(relational.SSOSessions["sess_test"].Groups) != 1 || relational.SSOSessions["sess_test"].Groups[0] != "security" { + t.Fatalf("relational sso session = %#v hash=%q", relational.SSOSessions["sess_test"], relational.SSOSessionHashes["sess_test"]) + } + if relational.Collectors["collector_test"].APIKeyID != "key_test" || relational.BuildRuns["build_test"].Status != "passed" || len(relational.BuildAttestations["att_test"].SubjectDigests) != 1 { + t.Fatalf("relational build rows missing: collector=%#v build=%#v attestation=%#v", relational.Collectors["collector_test"], relational.BuildRuns["build_test"], relational.BuildAttestations["att_test"]) + } + if !relational.CollectorReleases["collector_release_test"].Pinned || relational.CollectorReleases["collector_release_test"].HealthStatus != "healthy" { + t.Fatalf("relational collector release missing: release=%#v", relational.CollectorReleases["collector_release_test"]) + } + if relational.EvidenceLifecycle["life_test"].Details["field"] != "metadata" || len(relational.ReleaseCandidates["rc_test"].BuildIDs) != 1 { + t.Fatalf("relational lifecycle/candidate rows missing: lifecycle=%#v candidate=%#v", relational.EvidenceLifecycle["life_test"], relational.ReleaseCandidates["rc_test"]) + } + if relational.ContainerImages["image_test"].Repository == "" || relational.ArtifactSignatures["artsig_test"].VerificationStatus != "verified" { + t.Fatalf("relational artifact image rows missing: image=%#v signature=%#v", relational.ContainerImages["image_test"], relational.ArtifactSignatures["artsig_test"]) + } + if relational.Repositories["repo_test"].FullName != "org/repo" || relational.Commits["commit_test"].SHA == "" || !relational.Branches["branch_test"].Protected || relational.PullRequests["pr_test"].ReviewDecision != "approved" { + t.Fatalf("relational source rows missing: repo=%#v commit=%#v branch=%#v pr=%#v", relational.Repositories["repo_test"], relational.Commits["commit_test"], relational.Branches["branch_test"], relational.PullRequests["pr_test"]) + } + if relational.Environments["env_test"].Name != "production" || relational.Deployments["deploy_test"].Status != "succeeded" { + t.Fatalf("relational deployment rows missing: env=%#v deployment=%#v", relational.Environments["env_test"], relational.Deployments["deploy_test"]) + } + if relational.Incidents["incident_test"].Status != "resolved" || relational.TimelineEvents["timeline_test"].EvidenceID != "ev_test" { + t.Fatalf("relational incident rows missing: incident=%#v timeline=%#v", relational.Incidents["incident_test"], relational.TimelineEvents["timeline_test"]) + } + if relational.IncidentWebhookReceivers["receiver_test"].Status != "active" || relational.IncidentWebhookEvents["webhook_event_test"].TimelineEventID != "timeline_test" { + t.Fatalf("relational incident webhook rows missing: receiver=%#v event=%#v", relational.IncidentWebhookReceivers["receiver_test"], relational.IncidentWebhookEvents["webhook_event_test"]) + } + if relational.RemediationTasks["remediation_test"].Status != "done" { + t.Fatalf("relational remediation task missing: task=%#v", relational.RemediationTasks["remediation_test"]) + } + if relational.SecurityScans["secscan_test"].Summary["high"] != 1 || relational.ManualSecurityDocs["manual_doc_test"].Sensitivity != "restricted" { + t.Fatalf("relational security evidence rows missing: scan=%#v doc=%#v", relational.SecurityScans["secscan_test"], relational.ManualSecurityDocs["manual_doc_test"]) + } + if len(relational.SBOMDiffs["sbomdiff_test"].AddedComponents) != 1 || relational.DependencyChanges["depchange_test"].Component.Name != "lib2" { + t.Fatalf("relational sbom diff rows missing: diff=%#v change=%#v", relational.SBOMDiffs["sbomdiff_test"], relational.DependencyChanges["depchange_test"]) + } + if relational.VulnerabilityWorkflow["vulnwf_test"].Action != "reopened" || relational.ContractDiffs["contractdiff_test"].Result != "non_breaking" { + t.Fatalf("relational workflow/diff rows missing: workflow=%#v diff=%#v", relational.VulnerabilityWorkflow["vulnwf_test"], relational.ContractDiffs["contractdiff_test"]) + } + if len(relational.CustomPolicies["custom_policy_test"].Rules) != 1 || relational.CustomPolicyEvaluations["custom_policy_eval_test"].Result != "pass" { + t.Fatalf("relational custom policy rows missing: policy=%#v eval=%#v", relational.CustomPolicies["custom_policy_test"], relational.CustomPolicyEvaluations["custom_policy_eval_test"]) + } + if !relational.Waivers["waiver_test"].Approved || relational.Approvals["approval_test"].EvidenceID != "ev_test" || relational.DSSETrustRoots["dsse_root_test"].Status != "active" { + t.Fatalf("relational governance/trust rows missing: waiver=%#v approval=%#v trust=%#v", relational.Waivers["waiver_test"], relational.Approvals["approval_test"], relational.DSSETrustRoots["dsse_root_test"]) + } + if len(relational.CosignVerifications["cosign_test"].Checks) != 1 || !relational.SigningProviders["sign_provider_test"].Encrypted { + t.Fatalf("relational signing provider rows missing: cosign=%#v provider=%#v", relational.CosignVerifications["cosign_test"], relational.SigningProviders["sign_provider_test"]) + } + if relational.MerkleBatches["merkle_test"].RootHash == "" || relational.TransparencyCheckpoints["transparency_test"].ExternalID != "ts-1" { + t.Fatalf("relational integrity checkpoint rows missing: batch=%#v checkpoint=%#v", relational.MerkleBatches["merkle_test"], relational.TransparencyCheckpoints["transparency_test"]) + } + if relational.Products["prod_test"].Slug != "product" || relational.Evidence["ev_test"].ReleaseID != "rel_test" || relational.SBOMs["sbom_test"].ComponentCount != 1 { + t.Fatalf("relational fallback missing core rows: product=%#v evidence=%#v sbom=%#v", relational.Products["prod_test"], relational.Evidence["ev_test"], relational.SBOMs["sbom_test"]) + } + if relational.VEXDocuments["vex_test"].StatusSummary["not_affected"] != 1 || relational.VEXImportReports["vex_report_test"].DecisionsCreated != 1 || relational.Decisions["decision_test"].Status != "not_affected" || !relational.Exceptions["exception_test"].Approved { + t.Fatalf("relational risk rows missing: vex=%#v report=%#v decision=%#v exception=%#v", relational.VEXDocuments["vex_test"], relational.VEXImportReports["vex_report_test"], relational.Decisions["decision_test"], relational.Exceptions["exception_test"]) + } + if relational.ControlFrameworks["framework_test"].Slug != "framework" || len(relational.SecurityControls["control_test"].EvidenceRequirements) != 1 || relational.ControlEvidence["control_evidence_test"].Confidence != "high" { + t.Fatalf("relational control rows missing: framework=%#v control=%#v evidence=%#v", relational.ControlFrameworks["framework_test"], relational.SecurityControls["control_test"], relational.ControlEvidence["control_evidence_test"]) + } + if relational.Contracts["contract_test"].PathCount != 1 || len(relational.Contracts["contract_test"].Operations) != 1 { + t.Fatalf("relational fallback contract = %#v", relational.Contracts["contract_test"]) + } + if len(relational.Chain["ten_test"]) != 1 || relational.Bundles["bundle_test"].ManifestHash == "" || relational.Verifications["verify_test"].Result != "pass" { + t.Fatalf("relational fallback integrity rows missing: chain=%#v bundle=%#v verification=%#v", relational.Chain["ten_test"], relational.Bundles["bundle_test"], relational.Verifications["verify_test"]) + } + if len(relational.SigningKeyPrivate["sigkey_test"]) == 0 { + t.Fatal("relational fallback missing local dev signing key bytes") + } + if len(relational.Idempotency) != 1 { + t.Fatalf("relational idempotency records = %d, want 1", len(relational.Idempotency)) + } + if relational.CustomerPortalHashes["cpa_test"] != "portal-token-hash" || relational.CustomerPortalAccess["cpa_test"].FailedAccessCount != 1 || relational.CustomerPortalAccess["cpa_test"].ReviewerEmail != "reviewer@example.test" { + t.Fatalf("relational portal access = %#v hash=%q", relational.CustomerPortalAccess["cpa_test"], relational.CustomerPortalHashes["cpa_test"]) + } + if relational.RedactionProfiles["redact_test"].Name != "Default" || relational.CustomerPackages["pkg_test"].AccessCount != 3 { + t.Fatalf("relational package rows missing: redaction=%#v package=%#v", relational.RedactionProfiles["redact_test"], relational.CustomerPackages["pkg_test"]) + } + if relational.HTMLReports["html_test"].Hash == "" || relational.ReportTemplates["tpl_test"].Template == "" || relational.RenderedReports["render_test"].Hash == "" { + t.Fatalf("relational report rows missing: html=%#v template=%#v rendered=%#v", relational.HTMLReports["html_test"], relational.ReportTemplates["tpl_test"], relational.RenderedReports["render_test"]) + } + if relational.EvidenceBundles["eb_test"].ManifestHash == "" || relational.BundleImports["ebi_test"].ImportedCount != 1 { + t.Fatalf("relational evidence bundle rows missing: bundle=%#v import=%#v", relational.EvidenceBundles["eb_test"], relational.BundleImports["ebi_test"]) + } + if relational.ObjectRetentionPolicies["orp_test"].Status != "verified" || !relational.ObjectRetentionPolicies["orp_test"].RequireLegalHold || len(relational.ObjectRetentionPolicies["orp_test"].VerificationChecks) != 1 { + t.Fatalf("relational object retention policy = %#v", relational.ObjectRetentionPolicies["orp_test"]) + } + if relational.BackupManifests["bak_test"].ResourceCounts["evidence"] != 1 || relational.LegalHolds["hold_test"].ReleasedAt == nil || relational.RetentionOverrides["ret_test"].Owner != "security" { + t.Fatalf("relational retention rows missing: backup=%#v hold=%#v override=%#v", relational.BackupManifests["bak_test"], relational.LegalHolds["hold_test"], relational.RetentionOverrides["ret_test"]) + } + if len(relational.QuestionnaireTemplates["qt_test"].Questions) != 1 || len(relational.QuestionnairePackages["qp_test"].Responses) != 1 { + t.Fatalf("relational questionnaire rows missing: template=%#v package=%#v", relational.QuestionnaireTemplates["qt_test"], relational.QuestionnairePackages["qp_test"]) + } + if relational.CommercialCollectors["commercial_collector_test"].Status != "active" || len(relational.EvidenceSummaries["summary_test"].Citations) != 1 { + t.Fatalf("relational commercial/summary rows missing: collector=%#v summary=%#v", relational.CommercialCollectors["commercial_collector_test"], relational.EvidenceSummaries["summary_test"]) + } + if len(relational.QuestionnaireDrafts["draft_test"].Responses) != 1 || len(relational.GraphSnapshots["graph_test"].Nodes) != 1 { + t.Fatalf("relational draft/graph rows missing: draft=%#v graph=%#v", relational.QuestionnaireDrafts["draft_test"], relational.GraphSnapshots["graph_test"]) + } + if relational.SaaSProfiles["saas_test"].Status != "draft" || relational.PublicTransparencyLogs["public_log_test"].State != "active" { + t.Fatalf("relational saas/public log rows missing: saas=%#v log=%#v", relational.SaaSProfiles["saas_test"], relational.PublicTransparencyLogs["public_log_test"]) + } + if relational.PublicTransparencyItems["public_entry_test"].InclusionVerifiedAt == nil || len(relational.PublicTransparencyItems["public_entry_test"].VerificationChecks) != 1 { + t.Fatalf("relational public transparency entry missing: entry=%#v", relational.PublicTransparencyItems["public_entry_test"]) + } + if relational.MarketplaceCollectors["market_collector_test"].State != "published" || relational.ProviderVerifications["provider_verification_test"].Result != "verified" { + t.Fatalf("relational marketplace/provider rows missing: market=%#v provider=%#v", relational.MarketplaceCollectors["market_collector_test"], relational.ProviderVerifications["provider_verification_test"]) + } + if relational.SigningOperations["signing_operation_test"].SignatureRef != "sig_test" || len(relational.SigningOperations["signing_operation_test"].Checks) != 1 { + t.Fatalf("relational signing operation missing: operation=%#v", relational.SigningOperations["signing_operation_test"]) + } + if relational.PDFReports["pdf_test"].PayloadHash == "" || relational.AnomalyReports["anom_test"].Result != "review" { + t.Fatalf("relational generated report rows missing: pdf=%#v anomaly=%#v", relational.PDFReports["pdf_test"], relational.AnomalyReports["anom_test"]) + } job := app.OutboxJob{ID: "job_test_" + time.Now().Format("150405.000000000"), TenantID: "ten_test", Kind: "verify_subject", SubjectType: "audit_chain", SubjectID: "audit_chain", CreatedAt: time.Now().UTC()} if err := store.Enqueue(ctx, job); err != nil { t.Fatal(err) @@ -61,4 +826,687 @@ func TestStoreLoadSaveAndOutboxWithPostgres(t *testing.T) { if err := store.CompleteJob(ctx, jobs[0].ID); err != nil { t.Fatal(err) } + + retryJob := app.OutboxJob{ID: "job_retry_" + time.Now().Format("150405.000000000"), TenantID: "ten_test", Kind: "parse_sbom", SubjectType: "sbom", SubjectID: "sbom_test", CreatedAt: time.Now().UTC()} + if err := store.Enqueue(ctx, retryJob); err != nil { + t.Fatal(err) + } + if pending, err := store.CountPendingJobs(ctx); err != nil { + t.Fatal(err) + } else if pending == 0 { + t.Fatal("expected pending outbox job") + } + claimed, err := store.ClaimJobs(ctx, 0) + if err != nil { + t.Fatal(err) + } + if len(claimed) == 0 { + t.Fatal("expected retry job claim") + } + if err := store.FailJob(ctx, claimed[0].ID, context.Canceled); err != nil { + t.Fatal(err) + } + if _, err := store.Now(ctx); err != nil { + t.Fatal(err) + } +} + +func ptrTime(t time.Time) *time.Time { + return &t +} + +func TestApplyCriticalMutationWithPostgres(t *testing.T) { + databaseURL := os.Getenv("EVYDENCE_TEST_DATABASE_URL") + if databaseURL == "" { + t.Skip("EVYDENCE_TEST_DATABASE_URL is not set") + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + admin, err := Open(ctx, databaseURL) + if err != nil { + t.Fatal(err) + } + defer admin.Close() + schema := "evydence_critical_mutation_" + strings.ReplaceAll(time.Now().Format("150405.000000000"), ".", "_") + quotedSchema := pgx.Identifier{schema}.Sanitize() + if _, err := admin.pool.Exec(ctx, "CREATE SCHEMA "+quotedSchema); err != nil { + t.Fatal(err) + } + defer func(cleanupCtx context.Context) { + _, _ = admin.pool.Exec(cleanupCtx, "DROP SCHEMA "+quotedSchema+" CASCADE") + }(context.WithoutCancel(ctx)) + + store, err := OpenWithOptions(ctx, databaseURLWithSearchPath(t, databaseURL, schema), StoreOptions{LoadMode: LoadModeRelationalOnly, DisableSnapshotWrites: true}) + if err != nil { + t.Fatal(err) + } + defer store.Close() + if _, err := store.ApplyMigrations(ctx, "../../../migrations"); err != nil { + t.Fatal(err) + } + + now := time.Now().UTC() + mutation := app.CriticalMutation{ + Tenants: []domain.Tenant{{ID: "ten_focus", Name: "Focused", CreatedAt: now}}, + APIKeys: []domain.APIKey{{ + ID: "key_focus", TenantID: "ten_focus", Name: "api", Prefix: "evy_focus", + Scopes: []string{"*"}, LastUsedAt: ptrTime(now), CreatedAt: now, + }}, + APIKeyHashes: map[string]string{"key_focus": "api-key-hmac-hash"}, + SSOSessions: []domain.SSOSession{{ + ID: "sess_focus", TenantID: "ten_focus", UserID: "user_focus", ProviderID: "sso_focus", + Prefix: "sess_focus", Groups: []string{"security"}, ExpiresAt: now.Add(time.Hour), + SchemaVersion: domain.SSOSessionSchemaVersion, CreatedAt: now, + }}, + SSOSessionHashes: map[string]string{"sess_focus": "sso-session-hash"}, + CustomerPortalAccess: []domain.CustomerPortalAccess{{ + ID: "cpa_focus", TenantID: "ten_focus", PackageID: "pkg_focus", CustomerName: "Customer", + ReviewerName: "Reviewer", ReviewerEmail: "reviewer@example.test", + Prefix: "evycp_focus", ExpiresAt: now.Add(time.Hour), AccessCount: 2, FailedAccessCount: 1, + LastAccessedAt: ptrTime(now), LastFailedAt: ptrTime(now), SchemaVersion: domain.CustomerPortalAccessVersion, + CreatedAt: now, + }}, + CustomerPortalHashes: map[string]string{"cpa_focus": "portal-token-hash"}, + Idempotency: map[string]app.IdempotencyRecord{ + app.NewIdempotencyRecordKey("ten_focus", "key_focus", "POST", "/v1/release-bundles", "idem-a"): { + RequestHash: "sha256:request-a", Status: 201, Response: map[string]any{"id": "bundle_focus"}, CreatedAt: now, + }, + }, + SigningKeys: []domain.SigningKey{{ + ID: "sigkey_focus", TenantID: "ten_focus", KID: "kid-focus", Algorithm: "Ed25519", + Status: "active", PublicKey: "public-key", CreatedAt: now, + }}, + SigningKeyPrivate: map[string][]byte{"sigkey_focus": []byte("encrypted-dev-key")}, + Signatures: []domain.Signature{{ + ID: "sig_focus", TenantID: "ten_focus", SubjectType: "release_bundle", SubjectID: "bundle_focus", + KeyID: "sigkey_focus", Algorithm: "Ed25519", Value: "signature", CreatedAt: now, + }}, + ReleaseBundles: []domain.ReleaseBundle{{ + ID: "bundle_focus", TenantID: "ten_focus", ReleaseID: "rel_focus", State: "generated", + Manifest: map[string]any{"release_id": "rel_focus"}, ManifestHash: "sha256:" + strings.Repeat("a", 64), + SignatureRefs: []string{"sig_focus"}, CreatedAt: now, + }}, + VerificationResults: []domain.VerificationResult{{ + ID: "verify_focus", TenantID: "ten_focus", SubjectType: "release_bundle", SubjectID: "bundle_focus", + Result: "passed", Checks: []domain.VerifyCheck{{Name: "signature", Result: "passed"}}, VerifiedAt: now, + }}, + VulnerabilityDecisions: []domain.VulnerabilityDecision{{ + ID: "decision_focus", TenantID: "ten_focus", FindingID: "finding_focus", ScanID: "scan_focus", + ReleaseID: "rel_focus", Vulnerability: "CVE-2099-0001", Component: "pkg:generic/api", SBOMID: "sbom_focus", SBOMComponentPURL: "pkg:generic/api", SBOMComponentName: "api", + Status: "not_affected", Justification: "component_not_present", ImpactStatement: "not shipped", + CustomerVisible: true, InternalNotes: "private triage", EvidenceIDs: []string{"ev_support"}, Source: "manual", + SupportingRefs: []domain.SubjectRef{{Type: "release_bundle", ID: "bundle_focus"}}, + SchemaVersion: domain.VulnerabilityDecisionVersion, CreatedAt: now, + }}, + AuditChainEntries: []domain.AuditChainEntry{{ + ID: "chain_focus", TenantID: "ten_focus", Sequence: 1, EntryType: "release_bundle.created", + SubjectType: "release_bundle", SubjectID: "bundle_focus", ActorType: "api_key", ActorID: "key_focus", + OccurredAt: now, CanonicalEntryHash: "sha256:" + strings.Repeat("b", 64), + PreviousEntryHash: "", EntryHash: "sha256:" + strings.Repeat("c", 64), + Metadata: map[string]any{"focused": true}, SchemaVersion: domain.AuditChainEntrySchemaVersion, + }}, + OutboxJobs: []app.OutboxJob{{ + ID: "job_focus", TenantID: "ten_focus", Kind: "sign_bundle", + SubjectType: "release_bundle", SubjectID: "bundle_focus", Payload: map[string]any{"bundle_id": "bundle_focus"}, + CreatedAt: now, + }}, + } + if err := store.ApplyCriticalMutation(ctx, mutation); err != nil { + t.Fatal(err) + } + if err := store.ApplyCriticalMutation(ctx, mutation); err != nil { + t.Fatalf("retry focused mutation: %v", err) + } + if err := store.ApplyCriticalMutation(ctx, app.CriticalMutation{ + Idempotency: map[string]app.IdempotencyRecord{ + app.NewIdempotencyRecordKey("ten_focus", "key_focus", "POST", "/v1/release-bundles", "idem-b"): { + RequestHash: "sha256:request-b", Status: 201, Response: map[string]any{"id": "bundle_other"}, CreatedAt: now, + }, + }, + }); err != nil { + t.Fatalf("second idempotency mutation: %v", err) + } + + var snapshotRows int + if err := store.pool.QueryRow(ctx, `SELECT count(*) FROM ledger_state`).Scan(&snapshotRows); err != nil { + t.Fatal(err) + } + if snapshotRows != 0 { + t.Fatalf("ledger_state rows = %d, want 0", snapshotRows) + } + checks := []struct { + name string + query string + }{ + {name: "tenant", query: `SELECT count(*) FROM tenants WHERE id = 'ten_focus'`}, + {name: "api key hash", query: `SELECT count(*) FROM api_keys WHERE id = 'key_focus' AND hash = 'api-key-hmac-hash' AND last_used_at IS NOT NULL`}, + {name: "sso session hash", query: `SELECT count(*) FROM sso_sessions WHERE id = 'sess_focus' AND hash = 'sso-session-hash' AND groups = '["security"]'::jsonb`}, + {name: "portal hash", query: `SELECT count(*) FROM customer_portal_access WHERE id = 'cpa_focus' AND hash = 'portal-token-hash' AND reviewer_email = 'reviewer@example.test' AND failed_access_count = 1`}, + {name: "audit chain", query: `SELECT count(*) FROM audit_chain_entries WHERE tenant_id = 'ten_focus' AND sequence = 1`}, + {name: "signing key private", query: `SELECT count(*) FROM signing_keys WHERE id = 'sigkey_focus' AND encrypted_private_key IS NOT NULL`}, + {name: "signature", query: `SELECT count(*) FROM signatures WHERE id = 'sig_focus' AND key_id = 'sigkey_focus'`}, + {name: "bundle", query: `SELECT count(*) FROM release_bundles WHERE id = 'bundle_focus' AND signature_refs = '["sig_focus"]'::jsonb`}, + {name: "verification", query: `SELECT count(*) FROM verification_results WHERE id = 'verify_focus' AND result = 'passed'`}, + {name: "decision", query: `SELECT count(*) FROM vulnerability_decisions WHERE id = 'decision_focus' AND status = 'not_affected' AND customer_visible = true AND internal_notes = 'private triage' AND evidence_ids = ARRAY['ev_support']::text[] AND sbom_component_purl = 'pkg:generic/api'`}, + {name: "outbox", query: `SELECT count(*) FROM outbox_jobs WHERE id = 'job_focus' AND status = 'queued'`}, + {name: "resource index", query: `SELECT count(*) FROM resource_index WHERE tenant_id = 'ten_focus' AND resource_type = 'release_bundle' AND resource_id = 'bundle_focus'`}, + } + for _, check := range checks { + var rows int + if err := store.pool.QueryRow(ctx, check.query).Scan(&rows); err != nil { + t.Fatalf("%s query: %v", check.name, err) + } + if rows != 1 { + t.Fatalf("%s rows = %d, want 1", check.name, rows) + } + } + var idempotencyRows int + if err := store.pool.QueryRow(ctx, `SELECT count(*) FROM idempotency_records WHERE tenant_id = 'ten_focus'`).Scan(&idempotencyRows); err != nil { + t.Fatal(err) + } + if idempotencyRows != 2 { + t.Fatalf("idempotency rows = %d, want 2", idempotencyRows) + } + var outboxRows int + if err := store.pool.QueryRow(ctx, `SELECT count(*) FROM outbox_jobs WHERE id = 'job_focus'`).Scan(&outboxRows); err != nil { + t.Fatal(err) + } + if outboxRows != 1 { + t.Fatalf("outbox retry rows = %d, want 1", outboxRows) + } + + loaded, ok, err := store.LoadState(ctx) + if err != nil || !ok { + t.Fatalf("relational load ok=%v err=%v", ok, err) + } + if loaded.APIKeyHashes["key_focus"] != "api-key-hmac-hash" || loaded.APIKeys["key_focus"].Hash != "" { + t.Fatalf("loaded api key hash=%q exposed=%q", loaded.APIKeyHashes["key_focus"], loaded.APIKeys["key_focus"].Hash) + } + if loaded.SSOSessionHashes["sess_focus"] != "sso-session-hash" || loaded.SSOSessions["sess_focus"].Hash != "" { + t.Fatalf("loaded sso session hash=%q exposed=%q", loaded.SSOSessionHashes["sess_focus"], loaded.SSOSessions["sess_focus"].Hash) + } + if loaded.CustomerPortalHashes["cpa_focus"] != "portal-token-hash" || loaded.CustomerPortalAccess["cpa_focus"].Hash != "" { + t.Fatalf("loaded portal hash=%q exposed=%q", loaded.CustomerPortalHashes["cpa_focus"], loaded.CustomerPortalAccess["cpa_focus"].Hash) + } + if loaded.Bundles["bundle_focus"].ManifestHash == "" || loaded.Decisions["decision_focus"].Status != "not_affected" || len(loaded.Chain["ten_focus"]) != 1 { + t.Fatalf("loaded critical resources missing: bundle=%#v decision=%#v chain=%#v", loaded.Bundles["bundle_focus"], loaded.Decisions["decision_focus"], loaded.Chain["ten_focus"]) + } +} + +func TestApplyReleaseLedgerMutationWithPostgres(t *testing.T) { + databaseURL := os.Getenv("EVYDENCE_TEST_DATABASE_URL") + if databaseURL == "" { + t.Skip("EVYDENCE_TEST_DATABASE_URL is not set") + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + admin, err := Open(ctx, databaseURL) + if err != nil { + t.Fatal(err) + } + defer admin.Close() + schema := "evydence_release_mutation_" + strings.ReplaceAll(time.Now().Format("150405.000000000"), ".", "_") + quotedSchema := pgx.Identifier{schema}.Sanitize() + if _, err := admin.pool.Exec(ctx, "CREATE SCHEMA "+quotedSchema); err != nil { + t.Fatal(err) + } + defer func(cleanupCtx context.Context) { + _, _ = admin.pool.Exec(cleanupCtx, "DROP SCHEMA "+quotedSchema+" CASCADE") + }(context.WithoutCancel(ctx)) + + store, err := OpenWithOptions(ctx, databaseURLWithSearchPath(t, databaseURL, schema), StoreOptions{LoadMode: LoadModeRelationalOnly, DisableSnapshotWrites: true}) + if err != nil { + t.Fatal(err) + } + defer store.Close() + if _, err := store.ApplyMigrations(ctx, "../../../migrations"); err != nil { + t.Fatal(err) + } + + now := time.Now().UTC() + if err := store.ApplyCriticalMutation(ctx, app.CriticalMutation{ + Tenants: []domain.Tenant{{ID: "ten_release_focus", Name: "Release Focus", CreatedAt: now}}, + }); err != nil { + t.Fatalf("seed tenant: %v", err) + } + hash := "sha256:" + strings.Repeat("a", 64) + chainHash := "sha256:" + strings.Repeat("b", 64) + mutation := app.ReleaseLedgerMutation{ + Products: []domain.Product{{ + ID: "prod_focus", TenantID: "ten_release_focus", Name: "Payments", Slug: "payments", CreatedAt: now, + }}, + Projects: []domain.Project{{ + ID: "proj_focus", TenantID: "ten_release_focus", ProductID: "prod_focus", Name: "API", CreatedAt: now, + }}, + Releases: []domain.Release{{ + ID: "rel_focus", TenantID: "ten_release_focus", ProductID: "prod_focus", Version: "1.0.0", State: "approved", + FrozenAt: ptrTime(now), ApprovedAt: ptrTime(now), CreatedAt: now, + }}, + Artifacts: []domain.Artifact{{ + ID: "art_focus", TenantID: "ten_release_focus", Name: "api.tar.gz", MediaType: "application/gzip", Size: 42, + Digest: hash, CreatedAt: now, + }}, + Evidence: []domain.EvidenceItem{{ + ID: "ev_focus", TenantID: "ten_release_focus", ProductID: "prod_focus", ProjectID: "proj_focus", ReleaseID: "rel_focus", + Type: "sbom", Subtype: "cyclonedx", Title: "SBOM", SourceSystem: "api", ObservedAt: now, + EvidenceVersion: 1, SchemaVersion: domain.EvidenceItemSchemaVersion, PayloadHash: hash, CanonicalHash: hash, + Canonicalization: domain.CanonicalizationProfileVersion, TrustLevel: "L2", VerificationStatus: "pending", + SubjectRefs: []domain.SubjectRef{{Type: "artifact", ID: "art_focus"}}, CreatedAt: now, + }}, + EvidenceLifecycle: []domain.EvidenceLifecycleEvent{{ + ID: "elc_focus", TenantID: "ten_release_focus", EvidenceID: "ev_focus", Action: "amendment", + Reason: "correct metadata", ActorID: "key_focus", SchemaVersion: domain.EvidenceLifecycleSchemaVersion, CreatedAt: now, + }}, + SBOMs: []domain.SBOM{{ + ID: "sbom_focus", TenantID: "ten_release_focus", EvidenceID: "ev_focus", ReleaseID: "rel_focus", ArtifactID: "art_focus", + Format: "cyclonedx", SpecVersion: "1.5", ComponentCount: 1, + Components: []domain.SBOMComponent{{Name: "lib", Version: "1.0.0"}}, CreatedAt: now, + }}, + Scans: []domain.VulnerabilityScan{{ + ID: "scan_focus", TenantID: "ten_release_focus", EvidenceID: "ev_focus", ReleaseID: "rel_focus", + Scanner: "generic", TargetRef: "api", Summary: map[string]int{"critical": 1}, + Findings: []domain.VulnerabilityFinding{{ID: "finding_focus", Vulnerability: "CVE-2099-0001", Component: "lib", Severity: "critical", State: "open"}}, + CreatedAt: now, + }}, + Contracts: []domain.OpenAPIContract{{ + ID: "oas_focus", TenantID: "ten_release_focus", ProductID: "prod_focus", ReleaseID: "rel_focus", + Version: "1.0.0", Hash: hash, PathCount: 1, Operations: []domain.OpenAPIOperation{{Method: "GET", Path: "/health", OperationID: "health"}}, + EvidenceID: "ev_focus", CreatedAt: now, + }}, + VEXDocuments: []domain.VEXDocument{{ + ID: "vex_focus", TenantID: "ten_release_focus", EvidenceID: "ev_focus", ReleaseID: "rel_focus", ArtifactID: "art_focus", + Format: "openvex", Author: "security", Version: "1", StatementCount: 1, + StatusSummary: map[string]int{"not_affected": 1}, SchemaVersion: domain.VEXDocumentSchemaVersion, CreatedAt: now, + }}, + VEXImportReports: []domain.VEXImportReport{{ + ID: "vex_report_focus", TenantID: "ten_release_focus", VEXDocumentID: "vex_focus", EvidenceID: "ev_focus", ReleaseID: "rel_focus", + ParserVersion: app.ParserVersionOpenVEXJSON, Status: "parsed", StatementCount: 1, DecisionsCreated: 1, + SchemaVersion: domain.VEXImportReportSchemaVersion, CreatedAt: now, UpdatedAt: now, + }}, + VulnerabilityDecisions: []domain.VulnerabilityDecision{{ + ID: "decision_release_focus", TenantID: "ten_release_focus", FindingID: "finding_focus", ScanID: "scan_focus", + ReleaseID: "rel_focus", Vulnerability: "CVE-2099-0001", Component: "lib", SBOMID: "sbom_focus", SBOMComponentName: "lib", Status: "not_affected", + Justification: "component_not_present", Source: "vex", EvidenceID: "ev_focus", VEXDocumentID: "vex_focus", + SupportingRefs: []domain.SubjectRef{{Type: "release_bundle", ID: "bundle_focus"}}, + SchemaVersion: domain.VulnerabilityDecisionVersion, CreatedAt: now, + }}, + AuditChainEntries: []domain.AuditChainEntry{{ + ID: "chain_release_focus", TenantID: "ten_release_focus", Sequence: 1, EntryType: "evidence.created", + SubjectType: "evidence_item", SubjectID: "ev_focus", ActorType: "api_key", ActorID: "key_focus", + OccurredAt: now, PayloadHash: hash, CanonicalEntryHash: chainHash, EntryHash: chainHash, + SchemaVersion: domain.AuditChainEntrySchemaVersion, + }}, + OutboxJobs: []app.OutboxJob{{ + ID: "job_release_focus", TenantID: "ten_release_focus", Kind: "parse_sbom", + SubjectType: "sbom", SubjectID: "sbom_focus", Payload: map[string]any{"payload_hash": hash}, CreatedAt: now, + }}, + } + if err := store.ApplyReleaseLedgerMutation(ctx, mutation); err != nil { + t.Fatal(err) + } + if err := store.ApplyReleaseLedgerMutation(ctx, mutation); err != nil { + t.Fatalf("retry release ledger mutation: %v", err) + } + + var snapshotRows int + if err := store.pool.QueryRow(ctx, `SELECT count(*) FROM ledger_state`).Scan(&snapshotRows); err != nil { + t.Fatal(err) + } + if snapshotRows != 0 { + t.Fatalf("ledger_state rows = %d, want 0", snapshotRows) + } + checks := []struct { + name string + query string + }{ + {name: "product", query: `SELECT count(*) FROM products WHERE id = 'prod_focus' AND slug = 'payments'`}, + {name: "project", query: `SELECT count(*) FROM projects WHERE id = 'proj_focus' AND product_id = 'prod_focus'`}, + {name: "release", query: `SELECT count(*) FROM releases WHERE id = 'rel_focus' AND state = 'approved'`}, + {name: "artifact", query: `SELECT count(*) FROM artifacts WHERE id = 'art_focus' AND digest = '` + hash + `'`}, + {name: "evidence", query: `SELECT count(*) FROM evidence_items WHERE id = 'ev_focus' AND product_id = 'prod_focus'`}, + {name: "lifecycle", query: `SELECT count(*) FROM evidence_lifecycle_events WHERE id = 'elc_focus' AND action = 'amendment'`}, + {name: "sbom", query: `SELECT count(*) FROM sboms WHERE id = 'sbom_focus' AND component_count = 1`}, + {name: "scan", query: `SELECT count(*) FROM vulnerability_scans WHERE id = 'scan_focus' AND release_id = 'rel_focus'`}, + {name: "contract", query: `SELECT count(*) FROM openapi_contracts WHERE id = 'oas_focus' AND path_count = 1`}, + {name: "vex", query: `SELECT count(*) FROM vex_documents WHERE id = 'vex_focus' AND statement_count = 1`}, + {name: "vex report", query: `SELECT count(*) FROM vex_import_reports WHERE id = 'vex_report_focus' AND decisions_created = 1`}, + {name: "decision", query: `SELECT count(*) FROM vulnerability_decisions WHERE id = 'decision_release_focus' AND status = 'not_affected' AND sbom_id = 'sbom_focus'`}, + {name: "audit chain", query: `SELECT count(*) FROM audit_chain_entries WHERE id = 'chain_release_focus'`}, + {name: "outbox", query: `SELECT count(*) FROM outbox_jobs WHERE id = 'job_release_focus' AND status = 'queued'`}, + {name: "resource index", query: `SELECT count(*) FROM resource_index WHERE tenant_id = 'ten_release_focus' AND resource_type = 'evidence_item' AND resource_id = 'ev_focus'`}, + } + for _, check := range checks { + var rows int + if err := store.pool.QueryRow(ctx, check.query).Scan(&rows); err != nil { + t.Fatalf("%s query: %v", check.name, err) + } + if rows != 1 { + t.Fatalf("%s rows = %d, want 1", check.name, rows) + } + } + var outboxRows int + if err := store.pool.QueryRow(ctx, `SELECT count(*) FROM outbox_jobs WHERE id = 'job_release_focus'`).Scan(&outboxRows); err != nil { + t.Fatal(err) + } + if outboxRows != 1 { + t.Fatalf("outbox retry rows = %d, want 1", outboxRows) + } + loaded, ok, err := store.LoadState(ctx) + if err != nil || !ok { + t.Fatalf("relational load ok=%v err=%v", ok, err) + } + if loaded.Products["prod_focus"].Slug != "payments" || loaded.Evidence["ev_focus"].CanonicalHash == "" || len(loaded.EvidenceLifecycle) != 1 { + t.Fatalf("loaded release ledger core missing: product=%#v evidence=%#v lifecycle=%#v", loaded.Products["prod_focus"], loaded.Evidence["ev_focus"], loaded.EvidenceLifecycle) + } + if loaded.SBOMs["sbom_focus"].ComponentCount != 1 || loaded.Scans["scan_focus"].ReleaseID != "rel_focus" || loaded.Contracts["oas_focus"].PathCount != 1 || loaded.VEXDocuments["vex_focus"].StatementCount != 1 || loaded.VEXImportReports["vex_report_focus"].DecisionsCreated != 1 { + t.Fatalf("loaded parser metadata missing: sbom=%#v scan=%#v contract=%#v vex=%#v report=%#v", loaded.SBOMs["sbom_focus"], loaded.Scans["scan_focus"], loaded.Contracts["oas_focus"], loaded.VEXDocuments["vex_focus"], loaded.VEXImportReports["vex_report_focus"]) + } + if loaded.Decisions["decision_release_focus"].Status != "not_affected" || len(loaded.Chain["ten_release_focus"]) != 1 { + t.Fatalf("loaded decision/chain missing: decision=%#v chain=%#v", loaded.Decisions["decision_release_focus"], loaded.Chain["ten_release_focus"]) + } +} + +func TestApplyReleaseLedgerMutationRollsBackTransaction(t *testing.T) { + databaseURL := os.Getenv("EVYDENCE_TEST_DATABASE_URL") + if databaseURL == "" { + t.Skip("EVYDENCE_TEST_DATABASE_URL is not set") + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + admin, err := Open(ctx, databaseURL) + if err != nil { + t.Fatal(err) + } + defer admin.Close() + schema := "evydence_release_rollback_" + strings.ReplaceAll(time.Now().Format("150405.000000000"), ".", "_") + quotedSchema := pgx.Identifier{schema}.Sanitize() + if _, err := admin.pool.Exec(ctx, "CREATE SCHEMA "+quotedSchema); err != nil { + t.Fatal(err) + } + defer func(cleanupCtx context.Context) { + _, _ = admin.pool.Exec(cleanupCtx, "DROP SCHEMA "+quotedSchema+" CASCADE") + }(context.WithoutCancel(ctx)) + + store, err := OpenWithOptions(ctx, databaseURLWithSearchPath(t, databaseURL, schema), StoreOptions{LoadMode: LoadModeRelationalOnly, DisableSnapshotWrites: true}) + if err != nil { + t.Fatal(err) + } + defer store.Close() + if _, err := store.ApplyMigrations(ctx, "../../../migrations"); err != nil { + t.Fatal(err) + } + now := time.Now().UTC() + if err := store.ApplyCriticalMutation(ctx, app.CriticalMutation{ + Tenants: []domain.Tenant{{ID: "ten_release_rollback", Name: "Rollback", CreatedAt: now}}, + }); err != nil { + t.Fatalf("seed tenant: %v", err) + } + err = store.ApplyReleaseLedgerMutation(ctx, app.ReleaseLedgerMutation{ + Products: []domain.Product{{ + ID: "prod_release_rollback", TenantID: "ten_release_rollback", Name: "Rollback", Slug: "rollback", CreatedAt: now, + }}, + Projects: []domain.Project{{ + ID: "proj_release_rollback", TenantID: "ten_release_rollback", ProductID: "missing_product", Name: "Broken", CreatedAt: now, + }}, + OutboxJobs: []app.OutboxJob{{ + ID: "job_release_rollback", TenantID: "ten_release_rollback", Kind: "parse_sbom", + SubjectType: "sbom", SubjectID: "sbom_missing", CreatedAt: now, + }}, + }) + if err == nil { + t.Fatal("expected missing product to fail release ledger mutation") + } + checks := []struct { + name string + query string + }{ + {name: "product", query: `SELECT count(*) FROM products WHERE id = 'prod_release_rollback'`}, + {name: "project", query: `SELECT count(*) FROM projects WHERE id = 'proj_release_rollback'`}, + {name: "outbox", query: `SELECT count(*) FROM outbox_jobs WHERE id = 'job_release_rollback'`}, + } + for _, check := range checks { + var rows int + if queryErr := store.pool.QueryRow(ctx, check.query).Scan(&rows); queryErr != nil { + t.Fatalf("%s query: %v", check.name, queryErr) + } + if rows != 0 { + t.Fatalf("%s rows after rollback = %d, want 0", check.name, rows) + } + } +} + +func TestApplyCriticalMutationRollsBackTransaction(t *testing.T) { + databaseURL := os.Getenv("EVYDENCE_TEST_DATABASE_URL") + if databaseURL == "" { + t.Skip("EVYDENCE_TEST_DATABASE_URL is not set") + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + admin, err := Open(ctx, databaseURL) + if err != nil { + t.Fatal(err) + } + defer admin.Close() + schema := "evydence_critical_rollback_" + strings.ReplaceAll(time.Now().Format("150405.000000000"), ".", "_") + quotedSchema := pgx.Identifier{schema}.Sanitize() + if _, err := admin.pool.Exec(ctx, "CREATE SCHEMA "+quotedSchema); err != nil { + t.Fatal(err) + } + defer func(cleanupCtx context.Context) { + _, _ = admin.pool.Exec(cleanupCtx, "DROP SCHEMA "+quotedSchema+" CASCADE") + }(context.WithoutCancel(ctx)) + + store, err := OpenWithOptions(ctx, databaseURLWithSearchPath(t, databaseURL, schema), StoreOptions{LoadMode: LoadModeRelationalOnly, DisableSnapshotWrites: true}) + if err != nil { + t.Fatal(err) + } + defer store.Close() + if _, err := store.ApplyMigrations(ctx, "../../../migrations"); err != nil { + t.Fatal(err) + } + now := time.Now().UTC() + err = store.ApplyCriticalMutation(ctx, app.CriticalMutation{ + Tenants: []domain.Tenant{{ID: "ten_rollback", Name: "Rollback", CreatedAt: now}}, + Signatures: []domain.Signature{{ + ID: "sig_rollback", TenantID: "ten_rollback", SubjectType: "release_bundle", + SubjectID: "bundle_rollback", KeyID: "missing_key", Algorithm: "Ed25519", + Value: "signature", CreatedAt: now, + }}, + }) + if err == nil { + t.Fatal("expected missing signing key to fail critical mutation") + } + var tenantRows int + if queryErr := store.pool.QueryRow(ctx, `SELECT count(*) FROM tenants WHERE id = 'ten_rollback'`).Scan(&tenantRows); queryErr != nil { + t.Fatal(queryErr) + } + if tenantRows != 0 { + t.Fatalf("rollback tenant rows = %d, want 0", tenantRows) + } +} + +func TestPendingMigrationVersionsWithPostgres(t *testing.T) { + databaseURL := os.Getenv("EVYDENCE_TEST_DATABASE_URL") + if databaseURL == "" { + t.Skip("EVYDENCE_TEST_DATABASE_URL is not set") + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + baseStore, err := Open(ctx, databaseURL) + if err != nil { + t.Fatal(err) + } + defer baseStore.Close() + schema := "evydence_pending_migrations_" + strings.ReplaceAll(time.Now().Format("150405.000000000"), ".", "_") + quotedSchema := pgx.Identifier{schema}.Sanitize() + if _, err := baseStore.pool.Exec(ctx, "CREATE SCHEMA "+quotedSchema); err != nil { + t.Fatal(err) + } + defer func(cleanupCtx context.Context) { + _, _ = baseStore.pool.Exec(cleanupCtx, "DROP SCHEMA "+quotedSchema+" CASCADE") + }(context.WithoutCancel(ctx)) + + store, err := Open(ctx, databaseURLWithSearchPath(t, databaseURL, schema)) + if err != nil { + t.Fatal(err) + } + defer store.Close() + pending, err := store.PendingMigrationVersions(ctx, "../../../migrations") + if err != nil { + t.Fatal(err) + } + names := migrationFileNames(t, "../../../migrations") + if len(pending) != len(names) { + t.Fatalf("pending migrations = %d, want %d", len(pending), len(names)) + } + if err := store.RequireNoPendingMigrations(ctx, "../../../migrations"); err == nil { + t.Fatal("expected pending migrations to fail closed") + } + if _, err := store.ApplyMigrations(ctx, "../../../migrations"); err != nil { + t.Fatal(err) + } + pending, err = store.PendingMigrationVersions(ctx, "../../../migrations") + if err != nil { + t.Fatal(err) + } + if len(pending) != 0 { + t.Fatalf("pending after apply = %#v", pending) + } + if err := store.RequireNoPendingMigrations(ctx, "../../../migrations"); err != nil { + t.Fatalf("require no pending after apply: %v", err) + } +} + +func TestPostgresBackupRestoreRehearsalPreservesLedgerAndObjects(t *testing.T) { + databaseURL := os.Getenv("EVYDENCE_TEST_DATABASE_URL") + if databaseURL == "" { + t.Skip("EVYDENCE_TEST_DATABASE_URL is not set") + } + ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) + defer cancel() + baseStore, err := Open(ctx, databaseURL) + if err != nil { + t.Fatal(err) + } + defer baseStore.Close() + sourceSchema := "evydence_restore_source_" + strings.ReplaceAll(time.Now().Format("150405.000000000"), ".", "_") + targetSchema := "evydence_restore_target_" + strings.ReplaceAll(time.Now().Format("150405.000000000"), ".", "_") + for _, schema := range []string{sourceSchema, targetSchema} { + quoted := pgx.Identifier{schema}.Sanitize() + if _, err := baseStore.pool.Exec(ctx, "CREATE SCHEMA "+quoted); err != nil { + t.Fatal(err) + } + defer func(schema string) { + _, _ = baseStore.pool.Exec(context.WithoutCancel(ctx), "DROP SCHEMA "+pgx.Identifier{schema}.Sanitize()+" CASCADE") + }(schema) + } + + sourceStore, err := Open(ctx, databaseURLWithSearchPath(t, databaseURL, sourceSchema)) + if err != nil { + t.Fatal(err) + } + defer sourceStore.Close() + if _, err := sourceStore.ApplyMigrations(ctx, "../../../migrations"); err != nil { + t.Fatal(err) + } + sourceObjectRoot := t.TempDir() + sourceObjects, err := fsobject.New(sourceObjectRoot) + if err != nil { + t.Fatal(err) + } + ledger, err := app.NewLedgerWithError(app.Config{APIKeyPepper: "test-pepper", Store: sourceStore, ObjectStore: sourceObjects}) + if err != nil { + t.Fatal(err) + } + _, _, secret, err := ledger.BootstrapTenant(ctx, "Restore Tenant", "admin", []string{"*"}) + if err != nil { + t.Fatal(err) + } + actor, err := ledger.Authenticate(ctx, secret) + if err != nil { + t.Fatal(err) + } + product, err := ledger.CreateProduct(ctx, actor, "Payments API", "payments-restore") + if err != nil { + t.Fatal(err) + } + release, err := ledger.CreateRelease(ctx, actor, product.ID, "1.0.0") + if err != nil { + t.Fatal(err) + } + artifact, err := ledger.RegisterArtifact(ctx, actor, "payments.tar.gz", "application/gzip", "sha256:"+strings.Repeat("a", 64), 42) + if err != nil { + t.Fatal(err) + } + sbom, err := ledger.UploadSBOM(ctx, actor, release.ID, artifact.ID, []byte(`{"bomFormat":"CycloneDX","specVersion":"1.6","components":[{"name":"api","purl":"pkg:oci/api"}]}`)) + if err != nil { + t.Fatal(err) + } + bundle, err := ledger.CreateReleaseBundle(ctx, actor, release.ID) + if err != nil { + t.Fatal(err) + } + manifest, err := ledger.GenerateBackupManifest(ctx, actor) + if err != nil { + t.Fatal(err) + } + dbBackup, ok, err := sourceStore.LoadState(ctx) + if err != nil || !ok { + t.Fatalf("load backup state ok=%v err=%v", ok, err) + } + + targetStore, err := Open(ctx, databaseURLWithSearchPath(t, databaseURL, targetSchema)) + if err != nil { + t.Fatal(err) + } + defer targetStore.Close() + if _, err := targetStore.ApplyMigrations(ctx, "../../../migrations"); err != nil { + t.Fatal(err) + } + if err := targetStore.SaveState(ctx, dbBackup); err != nil { + t.Fatal(err) + } + targetObjectRoot := t.TempDir() + copyTree(t, sourceObjectRoot, targetObjectRoot) + targetObjects, err := fsobject.New(targetObjectRoot) + if err != nil { + t.Fatal(err) + } + restored, err := app.NewLedgerWithError(app.Config{APIKeyPepper: "test-pepper", Store: targetStore, ObjectStore: targetObjects}) + if err != nil { + t.Fatal(err) + } + restoredActor, err := restored.Authenticate(ctx, secret) + if err != nil { + t.Fatal(err) + } + if _, err := restored.VerifyBackupManifest(ctx, restoredActor, manifest.ID); err != nil { + t.Fatalf("verify backup manifest after restore: %v", err) + } + restoredSBOM, err := restored.GetSBOM(ctx, restoredActor, sbom.ID) + if err != nil || restoredSBOM.ComponentCount != sbom.ComponentCount { + t.Fatalf("restored sbom = %#v err=%v", restoredSBOM, err) + } + evidence, err := restored.GetEvidence(ctx, restoredActor, restoredSBOM.EvidenceID) + if err != nil { + t.Fatal(err) + } + objectKey := strings.TrimPrefix(evidence.PayloadRef, "object://") + object, err := targetObjects.Get(ctx, objectKey) + if err != nil { + t.Fatalf("restored object: %v", err) + } + if object.Digest != evidence.PayloadHash { + t.Fatalf("restored object digest = %q, want %q", object.Digest, evidence.PayloadHash) + } + if vr, err := restored.VerifySubject(ctx, restoredActor, "release_bundle", bundle.ID); err != nil || vr.Result != "passed" { + t.Fatalf("verify restored bundle = %#v err=%v", vr, err) + } +} + +func copyTree(t *testing.T, sourceRoot, targetRoot string) { + t.Helper() + if err := os.CopyFS(targetRoot, os.DirFS(sourceRoot)); err != nil { + t.Fatal(err) + } } diff --git a/internal/adapters/signing/awskms/awskms.go b/internal/adapters/signing/awskms/awskms.go new file mode 100644 index 0000000..cf0b942 --- /dev/null +++ b/internal/adapters/signing/awskms/awskms.go @@ -0,0 +1,151 @@ +package awskms + +import ( + "context" + "encoding/base64" + "encoding/hex" + "errors" + "strings" + "time" + + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/kms" + "github.com/aws/aws-sdk-go-v2/service/kms/types" + + "github.com/aatuh/evydence/internal/app" + "github.com/aatuh/evydence/internal/domain" +) + +const defaultTimeout = 10 * time.Second + +type Config struct { + Region string + KeyID string + Endpoint string + SigningAlgorithm string + Timeout time.Duration +} + +type kmsSigner interface { + Sign(context.Context, *kms.SignInput, ...func(*kms.Options)) (*kms.SignOutput, error) +} + +type Executor struct { + client kmsSigner + keyID string + algorithm types.SigningAlgorithmSpec + timeout time.Duration +} + +func New(ctx context.Context, cfg Config) (*Executor, error) { + if strings.TrimSpace(cfg.Region) == "" { + return nil, app.ErrValidation + } + awsCfg, err := config.LoadDefaultConfig(ctx, config.WithRegion(strings.TrimSpace(cfg.Region))) + if err != nil { + return nil, errors.New("load AWS KMS signing configuration") + } + options := []func(*kms.Options){} + if endpoint := strings.TrimSpace(cfg.Endpoint); endpoint != "" { + options = append(options, func(o *kms.Options) { + o.BaseEndpoint = &endpoint + }) + } + return NewWithClient(kms.NewFromConfig(awsCfg, options...), cfg) +} + +func NewWithClient(client kmsSigner, cfg Config) (*Executor, error) { + keyID := strings.TrimSpace(cfg.KeyID) + if client == nil || keyID == "" { + return nil, app.ErrValidation + } + algorithm, err := parseSigningAlgorithm(cfg.SigningAlgorithm) + if err != nil { + return nil, err + } + timeout := cfg.Timeout + if timeout <= 0 { + timeout = defaultTimeout + } + return &Executor{client: client, keyID: keyID, algorithm: algorithm, timeout: timeout}, nil +} + +func (e *Executor) Sign(ctx context.Context, request app.SigningRequest) (app.SigningResult, error) { + if e == nil || e.client == nil || e.keyID == "" { + return app.SigningResult{}, app.ErrValidation + } + if strings.TrimSpace(request.TenantID) == "" || strings.TrimSpace(request.SubjectType) == "" || strings.TrimSpace(request.SubjectID) == "" { + return app.SigningResult{}, app.ErrValidation + } + digest, err := parseSHA256Digest(request.PayloadHash) + if err != nil { + return app.SigningResult{}, err + } + callCtx := ctx + var cancel context.CancelFunc + if e.timeout > 0 { + callCtx, cancel = context.WithTimeout(ctx, e.timeout) + defer cancel() + } + output, err := e.client.Sign(callCtx, &kms.SignInput{ + KeyId: &e.keyID, + Message: digest, + MessageType: types.MessageTypeDigest, + SigningAlgorithm: e.algorithm, + }) + if err != nil { + return app.SigningResult{}, errors.New("execute AWS KMS signing request") + } + if len(output.Signature) == 0 || len(output.Signature) > 32768 { + return app.SigningResult{}, app.ErrValidation + } + keyID := e.keyID + if output.KeyId != nil && strings.TrimSpace(*output.KeyId) != "" { + keyID = strings.TrimSpace(*output.KeyId) + } + algorithm := output.SigningAlgorithm + if algorithm == "" { + algorithm = e.algorithm + } + return app.SigningResult{ + Signature: base64.StdEncoding.EncodeToString(output.Signature), + KeyID: keyID, + Algorithm: "aws-kms:" + string(algorithm), + Checks: []domain.VerifyCheck{ + {Name: "aws_kms_signature_returned", Result: "passed", Detail: "AWS KMS returned a signature over the submitted SHA-256 digest."}, + }, + }, nil +} + +func parseSHA256Digest(value string) ([]byte, error) { + const prefix = "sha256:" + trimmed := strings.TrimSpace(value) + if !strings.HasPrefix(trimmed, prefix) { + return nil, app.ErrValidation + } + encoded := strings.TrimPrefix(trimmed, prefix) + if len(encoded) != 64 { + return nil, app.ErrValidation + } + digest, err := hex.DecodeString(encoded) + if err != nil || len(digest) != 32 { + return nil, app.ErrValidation + } + return digest, nil +} + +func parseSigningAlgorithm(value string) (types.SigningAlgorithmSpec, error) { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return types.SigningAlgorithmSpecEcdsaSha256, nil + } + algorithm := types.SigningAlgorithmSpec(trimmed) + switch algorithm { + case types.SigningAlgorithmSpecEcdsaSha256, + types.SigningAlgorithmSpecRsassaPssSha256, + types.SigningAlgorithmSpecRsassaPkcs1V15Sha256: + return algorithm, nil + default: + return "", errors.New("unsupported AWS KMS signing algorithm") + } +} diff --git a/internal/adapters/signing/awskms/awskms_test.go b/internal/adapters/signing/awskms/awskms_test.go new file mode 100644 index 0000000..122ed5e --- /dev/null +++ b/internal/adapters/signing/awskms/awskms_test.go @@ -0,0 +1,118 @@ +package awskms + +import ( + "context" + "encoding/base64" + "errors" + "strings" + "testing" + + "github.com/aws/aws-sdk-go-v2/service/kms" + "github.com/aws/aws-sdk-go-v2/service/kms/types" + + "github.com/aatuh/evydence/internal/app" +) + +type fakeKMSSigner struct { + input *kms.SignInput + err error +} + +func (f *fakeKMSSigner) Sign(_ context.Context, input *kms.SignInput, _ ...func(*kms.Options)) (*kms.SignOutput, error) { + f.input = input + if f.err != nil { + return nil, f.err + } + keyID := "arn:aws:kms:eu-north-1:111122223333:key/test" + return &kms.SignOutput{ + KeyId: &keyID, + Signature: []byte("der-signature"), + SigningAlgorithm: input.SigningAlgorithm, + }, nil +} + +func TestNewWithClientRequiresClientAndKeyID(t *testing.T) { + if _, err := NewWithClient(nil, Config{KeyID: "key"}); err == nil { + t.Fatal("expected nil client to be rejected") + } + if _, err := NewWithClient(&fakeKMSSigner{}, Config{}); err == nil { + t.Fatal("expected missing key id to be rejected") + } +} + +func TestNewWithClientRejectsNonSHA256Algorithms(t *testing.T) { + if _, err := NewWithClient(&fakeKMSSigner{}, Config{KeyID: "key", SigningAlgorithm: string(types.SigningAlgorithmSpecEcdsaSha384)}); err == nil { + t.Fatal("expected SHA-384 algorithm to be rejected for sha256 payload hashes") + } +} + +func TestSignSendsDigestOnlyAndReturnsBase64Signature(t *testing.T) { + fake := &fakeKMSSigner{} + executor, err := NewWithClient(fake, Config{KeyID: "alias/evydence-release", SigningAlgorithm: string(types.SigningAlgorithmSpecRsassaPssSha256)}) + if err != nil { + t.Fatal(err) + } + result, err := executor.Sign(t.Context(), app.SigningRequest{ + TenantID: "ten_1", + SubjectType: "release", + SubjectID: "rel_1", + PayloadHash: "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + }) + if err != nil { + t.Fatal(err) + } + if fake.input == nil { + t.Fatal("expected KMS request") + } + if fake.input.KeyId == nil || *fake.input.KeyId != "alias/evydence-release" { + t.Fatalf("key id = %v", fake.input.KeyId) + } + if fake.input.MessageType != types.MessageTypeDigest { + t.Fatalf("message type = %q, want DIGEST", fake.input.MessageType) + } + if got := len(fake.input.Message); got != 32 { + t.Fatalf("message length = %d, want SHA-256 digest length", got) + } + if fake.input.SigningAlgorithm != types.SigningAlgorithmSpecRsassaPssSha256 { + t.Fatalf("algorithm = %q", fake.input.SigningAlgorithm) + } + if result.Signature != base64.StdEncoding.EncodeToString([]byte("der-signature")) { + t.Fatalf("signature = %q", result.Signature) + } + if result.Algorithm != "aws-kms:RSASSA_PSS_SHA_256" { + t.Fatalf("algorithm = %q", result.Algorithm) + } + if result.KeyID == "" || len(result.Checks) != 1 || result.Checks[0].Name != "aws_kms_signature_returned" { + t.Fatalf("result = %#v", result) + } +} + +func TestSignRejectsMalformedDigest(t *testing.T) { + executor, err := NewWithClient(&fakeKMSSigner{}, Config{KeyID: "alias/evydence-release"}) + if err != nil { + t.Fatal(err) + } + if _, err := executor.Sign(t.Context(), app.SigningRequest{TenantID: "ten_1", SubjectType: "release", SubjectID: "rel_1", PayloadHash: "sha256:not-hex"}); !errors.Is(err, app.ErrValidation) { + t.Fatalf("err=%v, want validation", err) + } +} + +func TestSignHidesProviderErrorDetails(t *testing.T) { + fake := &fakeKMSSigner{err: errors.New("provider included payload sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")} + executor, err := NewWithClient(fake, Config{KeyID: "alias/evydence-release"}) + if err != nil { + t.Fatal(err) + } + _, err = executor.Sign(context.Background(), app.SigningRequest{ + TenantID: "ten_1", + SubjectType: "release", + SubjectID: "rel_1", + PayloadHash: "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + }) + if err == nil { + t.Fatal("expected provider error") + } + if strings.Contains(err.Error(), "0123456789abcdef") || strings.Contains(err.Error(), "payload") { + t.Fatalf("error leaked provider details: %v", err) + } +} diff --git a/internal/adapters/signing/azurekeyvault/azurekeyvault.go b/internal/adapters/signing/azurekeyvault/azurekeyvault.go new file mode 100644 index 0000000..22d5845 --- /dev/null +++ b/internal/adapters/signing/azurekeyvault/azurekeyvault.go @@ -0,0 +1,213 @@ +package azurekeyvault + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/aatuh/evydence/internal/app" + "github.com/aatuh/evydence/internal/domain" +) + +const ( + defaultAPIVersion = "7.4" + defaultAlgorithm = "ES256" + defaultTimeout = 10 * time.Second +) + +type Config struct { + VaultURL string + AccessToken string + KeyName string + KeyVersion string + Algorithm string + APIVersion string + Timeout time.Duration + Client *http.Client +} + +type Executor struct { + vaultURL string + accessToken string + keyName string + keyVersion string + algorithm string + apiVersion string + client *http.Client +} + +type signRequest struct { + Algorithm string `json:"alg"` + Value string `json:"value"` +} + +type signResponse struct { + KeyID string `json:"kid,omitempty"` + Value string `json:"value"` +} + +func New(cfg Config) (*Executor, error) { + vaultURL := strings.TrimRight(strings.TrimSpace(cfg.VaultURL), "/") + if vaultURL == "" { + return nil, app.ErrValidation + } + parsed, err := url.Parse(vaultURL) + if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil { + return nil, app.ErrValidation + } + if strings.TrimSpace(cfg.AccessToken) == "" { + return nil, errors.New("configure Azure Key Vault access token") + } + algorithm := strings.TrimSpace(cfg.Algorithm) + if algorithm == "" { + algorithm = defaultAlgorithm + } + apiVersion := strings.TrimSpace(cfg.APIVersion) + if apiVersion == "" { + apiVersion = defaultAPIVersion + } + timeout := cfg.Timeout + if timeout <= 0 { + timeout = defaultTimeout + } + client := cfg.Client + if client == nil { + client = &http.Client{Timeout: timeout} + } + return &Executor{ + vaultURL: vaultURL, + accessToken: strings.TrimSpace(cfg.AccessToken), + keyName: strings.TrimSpace(cfg.KeyName), + keyVersion: strings.TrimSpace(cfg.KeyVersion), + algorithm: algorithm, + apiVersion: apiVersion, + client: client, + }, nil +} + +func (e *Executor) Sign(ctx context.Context, req app.SigningRequest) (app.SigningResult, error) { + if e == nil || e.client == nil { + return app.SigningResult{}, app.ErrValidation + } + if providerType := strings.TrimSpace(req.ProviderType); providerType != "" && providerType != "azure_key_vault" { + return app.SigningResult{}, app.ErrValidation + } + digest, err := decodePayloadHash(req.PayloadHash) + if err != nil { + return app.SigningResult{}, err + } + vaultURL, keyName, keyVersion, err := e.resolveKey(req.KeyRef) + if err != nil { + return app.SigningResult{}, err + } + body, err := json.Marshal(signRequest{Algorithm: e.algorithm, Value: base64.RawURLEncoding.EncodeToString(digest)}) + if err != nil { + return app.SigningResult{}, app.ErrValidation + } + signURL := fmt.Sprintf("%s/keys/%s/%s/sign?api-version=%s", vaultURL, url.PathEscape(keyName), url.PathEscape(keyVersion), url.QueryEscape(e.apiVersion)) + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, signURL, bytes.NewReader(body)) + if err != nil { + return app.SigningResult{}, app.ErrValidation + } + httpReq.Header.Set("Accept", "application/json") + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+e.accessToken) + resp, err := e.client.Do(httpReq) + if err != nil { + return app.SigningResult{}, errors.New("execute Azure Key Vault signing request") + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096)) + return app.SigningResult{}, fmt.Errorf("azure Key Vault signing request returned status %d", resp.StatusCode) + } + var decoded signResponse + decoder := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&decoded); err != nil { + return app.SigningResult{}, errors.New("decode Azure Key Vault signing response") + } + signature, err := normalizeBase64URLSignature(decoded.Value) + if err != nil || len(signature) > 32768 { + return app.SigningResult{}, app.ErrValidation + } + return app.SigningResult{ + Signature: signature, + KeyID: firstNonEmpty(strings.TrimSpace(decoded.KeyID), vaultURL+"/keys/"+keyName+"/"+keyVersion), + Algorithm: "azure-key-vault:" + e.algorithm, + Checks: []domain.VerifyCheck{ + {Name: "azure_key_vault_signature_returned", Result: "passed", Detail: "Azure Key Vault returned a signature over the submitted SHA-256 digest."}, + }, + }, nil +} + +func (e *Executor) resolveKey(keyRef string) (string, string, string, error) { + keyRef = strings.TrimSpace(keyRef) + if keyRef != "" && strings.HasPrefix(keyRef, "https://") { + parsed, err := url.Parse(keyRef) + if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil { + return "", "", "", app.ErrValidation + } + parts := strings.Split(strings.Trim(parsed.Path, "/"), "/") + if len(parts) < 3 || parts[0] != "keys" || !validSegment(parts[1]) || !validSegment(parts[2]) { + return "", "", "", app.ErrValidation + } + return parsed.Scheme + "://" + parsed.Host, parts[1], parts[2], nil + } + keyName := firstNonEmpty(keyRef, e.keyName) + keyVersion := e.keyVersion + if !validSegment(keyName) || !validSegment(keyVersion) { + return "", "", "", app.ErrValidation + } + return e.vaultURL, keyName, keyVersion, nil +} + +func decodePayloadHash(value string) ([]byte, error) { + value = strings.TrimSpace(value) + if !strings.HasPrefix(value, "sha256:") { + return nil, app.ErrValidation + } + raw, err := hex.DecodeString(strings.TrimPrefix(value, "sha256:")) + if err != nil || len(raw) != 32 { + return nil, app.ErrValidation + } + return raw, nil +} + +func normalizeBase64URLSignature(value string) (string, error) { + value = strings.TrimSpace(value) + if value == "" { + return "", app.ErrValidation + } + raw, err := base64.RawURLEncoding.DecodeString(value) + if err != nil { + raw, err = base64.URLEncoding.DecodeString(value) + } + if err != nil || len(raw) == 0 { + return "", app.ErrValidation + } + return base64.StdEncoding.EncodeToString(raw), nil +} + +func validSegment(value string) bool { + value = strings.TrimSpace(value) + return value != "" && !strings.ContainsAny(value, "/?#") && !strings.Contains(value, "..") +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} diff --git a/internal/adapters/signing/azurekeyvault/azurekeyvault_test.go b/internal/adapters/signing/azurekeyvault/azurekeyvault_test.go new file mode 100644 index 0000000..bb503fe --- /dev/null +++ b/internal/adapters/signing/azurekeyvault/azurekeyvault_test.go @@ -0,0 +1,100 @@ +package azurekeyvault + +import ( + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/aatuh/evydence/internal/app" +) + +func TestSignSendsDigestOnlyToAzureKeyVault(t *testing.T) { + var gotAuth string + var gotPath string + var gotQuery string + var gotRequest signRequest + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + gotPath = r.URL.Path + gotQuery = r.URL.RawQuery + if err := json.NewDecoder(r.Body).Decode(&gotRequest); err != nil { + t.Fatal(err) + } + _ = json.NewEncoder(w).Encode(signResponse{KeyID: serverKeyID(r), Value: base64.RawURLEncoding.EncodeToString([]byte("sig"))}) + })) + defer server.Close() + + executor, err := New(Config{VaultURL: server.URL, AccessToken: "access-token", KeyName: "evydence", KeyVersion: "v1", Algorithm: "ES256", Client: server.Client()}) + if err != nil { + t.Fatal(err) + } + result, err := executor.Sign(t.Context(), app.SigningRequest{ProviderType: "azure_key_vault", PayloadHash: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}) + if err != nil { + t.Fatal(err) + } + if gotAuth != "Bearer access-token" { + t.Fatalf("authorization = %q", gotAuth) + } + if gotPath != "/keys/evydence/v1/sign" || gotQuery != "api-version=7.4" { + t.Fatalf("path/query = %q?%q", gotPath, gotQuery) + } + if gotRequest.Algorithm != "ES256" || gotRequest.Value != base64.RawURLEncoding.EncodeToString(bytesOf(0xaa, 32)) { + t.Fatalf("request = %#v", gotRequest) + } + if result.Signature != base64.StdEncoding.EncodeToString([]byte("sig")) || result.Algorithm != "azure-key-vault:ES256" || len(result.Checks) == 0 { + t.Fatalf("result = %#v", result) + } +} + +func TestSignCanUseKeyRefURL(t *testing.T) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/keys/from-ref/v2/sign" { + t.Fatalf("path = %s", r.URL.Path) + } + _ = json.NewEncoder(w).Encode(signResponse{Value: base64.RawURLEncoding.EncodeToString([]byte("sig"))}) + })) + defer server.Close() + + executor, err := New(Config{VaultURL: "https://unused.example.test", AccessToken: "access-token", KeyName: "evydence", KeyVersion: "v1", Client: server.Client()}) + if err != nil { + t.Fatal(err) + } + _, err = executor.Sign(t.Context(), app.SigningRequest{ProviderType: "azure_key_vault", KeyRef: server.URL + "/keys/from-ref/v2", PayloadHash: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}) + if err != nil { + t.Fatal(err) + } +} + +func TestSignRejectsWrongProviderType(t *testing.T) { + executor, err := New(Config{VaultURL: "https://vault.example.test", AccessToken: "token", KeyName: "evydence", KeyVersion: "v1"}) + if err != nil { + t.Fatal(err) + } + if _, err := executor.Sign(t.Context(), app.SigningRequest{ProviderType: "gcp_kms", PayloadHash: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}); err == nil { + t.Fatal("expected wrong provider type to be rejected") + } +} + +func TestNewRequiresHTTPSAndAccessToken(t *testing.T) { + if _, err := New(Config{VaultURL: "http://vault.example.test", AccessToken: "token"}); err == nil { + t.Fatal("expected non-HTTPS vault URL to be rejected") + } + if _, err := New(Config{VaultURL: "https://vault.example.test"}); err == nil { + t.Fatal("expected missing access token to be rejected") + } +} + +func serverKeyID(r *http.Request) string { + return "https://" + r.Host + strings.TrimSuffix(r.URL.Path, "/sign") +} + +func bytesOf(value byte, count int) []byte { + out := make([]byte, count) + for i := range out { + out[i] = value + } + return out +} diff --git a/internal/adapters/signing/gcpkms/gcpkms.go b/internal/adapters/signing/gcpkms/gcpkms.go new file mode 100644 index 0000000..d8b77e0 --- /dev/null +++ b/internal/adapters/signing/gcpkms/gcpkms.go @@ -0,0 +1,165 @@ +package gcpkms + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/aatuh/evydence/internal/app" + "github.com/aatuh/evydence/internal/domain" +) + +const ( + defaultEndpoint = "https://cloudkms.googleapis.com" + defaultTimeout = 10 * time.Second +) + +type Config struct { + Endpoint string + AccessToken string + KeyName string + Timeout time.Duration + Client *http.Client +} + +type Executor struct { + endpoint string + accessToken string + keyName string + client *http.Client +} + +type signRequest struct { + Digest digestValue `json:"digest"` +} + +type digestValue struct { + SHA256 string `json:"sha256"` +} + +type signResponse struct { + Signature string `json:"signature"` + Name string `json:"name,omitempty"` +} + +func New(cfg Config) (*Executor, error) { + endpoint := strings.TrimRight(strings.TrimSpace(cfg.Endpoint), "/") + if endpoint == "" { + endpoint = defaultEndpoint + } + parsed, err := url.Parse(endpoint) + if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil { + return nil, app.ErrValidation + } + if strings.TrimSpace(cfg.AccessToken) == "" { + return nil, errors.New("configure GCP KMS access token") + } + timeout := cfg.Timeout + if timeout <= 0 { + timeout = defaultTimeout + } + client := cfg.Client + if client == nil { + client = &http.Client{Timeout: timeout} + } + return &Executor{ + endpoint: endpoint, + accessToken: strings.TrimSpace(cfg.AccessToken), + keyName: strings.TrimSpace(cfg.KeyName), + client: client, + }, nil +} + +func (e *Executor) Sign(ctx context.Context, req app.SigningRequest) (app.SigningResult, error) { + if e == nil || e.client == nil { + return app.SigningResult{}, app.ErrValidation + } + if providerType := strings.TrimSpace(req.ProviderType); providerType != "" && providerType != "gcp_kms" { + return app.SigningResult{}, app.ErrValidation + } + digest, err := decodePayloadHash(req.PayloadHash) + if err != nil { + return app.SigningResult{}, err + } + keyName := strings.TrimSpace(req.KeyRef) + if keyName == "" { + keyName = e.keyName + } + if !validKeyName(keyName) { + return app.SigningResult{}, app.ErrValidation + } + body, err := json.Marshal(signRequest{Digest: digestValue{SHA256: base64.StdEncoding.EncodeToString(digest)}}) + if err != nil { + return app.SigningResult{}, app.ErrValidation + } + signURL := e.endpoint + "/v1/" + strings.TrimLeft(keyName, "/") + ":asymmetricSign" + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, signURL, bytes.NewReader(body)) + if err != nil { + return app.SigningResult{}, app.ErrValidation + } + httpReq.Header.Set("Accept", "application/json") + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+e.accessToken) + resp, err := e.client.Do(httpReq) + if err != nil { + return app.SigningResult{}, errors.New("execute GCP KMS signing request") + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096)) + return app.SigningResult{}, fmt.Errorf("gcp KMS signing request returned status %d", resp.StatusCode) + } + var decoded signResponse + decoder := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&decoded); err != nil { + return app.SigningResult{}, errors.New("decode GCP KMS signing response") + } + decoded.Signature = strings.TrimSpace(decoded.Signature) + if decoded.Signature == "" || len(decoded.Signature) > 32768 { + return app.SigningResult{}, app.ErrValidation + } + return app.SigningResult{ + Signature: decoded.Signature, + KeyID: firstNonEmpty(strings.TrimSpace(decoded.Name), keyName), + Algorithm: "gcp-kms:asymmetric-sign-sha256", + Checks: []domain.VerifyCheck{ + {Name: "gcp_kms_signature_returned", Result: "passed", Detail: "GCP Cloud KMS returned a signature over the submitted SHA-256 digest."}, + }, + }, nil +} + +func decodePayloadHash(value string) ([]byte, error) { + value = strings.TrimSpace(value) + if !strings.HasPrefix(value, "sha256:") { + return nil, app.ErrValidation + } + raw, err := hex.DecodeString(strings.TrimPrefix(value, "sha256:")) + if err != nil || len(raw) != 32 { + return nil, app.ErrValidation + } + return raw, nil +} + +func validKeyName(value string) bool { + value = strings.TrimSpace(value) + return value != "" && !strings.ContainsAny(value, "?#") && !strings.Contains(value, "..") +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} diff --git a/internal/adapters/signing/gcpkms/gcpkms_test.go b/internal/adapters/signing/gcpkms/gcpkms_test.go new file mode 100644 index 0000000..077816f --- /dev/null +++ b/internal/adapters/signing/gcpkms/gcpkms_test.go @@ -0,0 +1,79 @@ +package gcpkms + +import ( + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/aatuh/evydence/internal/app" +) + +func TestSignSendsDigestOnlyToGCPKMS(t *testing.T) { + var gotAuth string + var gotPath string + var gotRequest signRequest + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + gotPath = r.URL.Path + if err := json.NewDecoder(r.Body).Decode(&gotRequest); err != nil { + t.Fatal(err) + } + _ = json.NewEncoder(w).Encode(signResponse{Signature: base64.StdEncoding.EncodeToString([]byte("sig")), Name: "projects/p/locations/l/keyRings/r/cryptoKeys/k/cryptoKeyVersions/1"}) + })) + defer server.Close() + + executor, err := New(Config{Endpoint: server.URL, AccessToken: "access-token", Client: server.Client()}) + if err != nil { + t.Fatal(err) + } + result, err := executor.Sign(t.Context(), app.SigningRequest{ + ProviderType: "gcp_kms", + KeyRef: "projects/p/locations/l/keyRings/r/cryptoKeys/k/cryptoKeyVersions/1", + PayloadHash: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + }) + if err != nil { + t.Fatal(err) + } + if gotAuth != "Bearer access-token" { + t.Fatalf("authorization = %q", gotAuth) + } + if !strings.HasSuffix(gotPath, ":asymmetricSign") || !strings.Contains(gotPath, "/v1/projects/p/") { + t.Fatalf("path = %q", gotPath) + } + if gotRequest.Digest.SHA256 != base64.StdEncoding.EncodeToString(bytesOf(0xaa, 32)) { + t.Fatalf("digest = %q", gotRequest.Digest.SHA256) + } + if result.Signature == "" || result.Algorithm != "gcp-kms:asymmetric-sign-sha256" || len(result.Checks) == 0 { + t.Fatalf("result = %#v", result) + } +} + +func TestSignRejectsWrongProviderType(t *testing.T) { + executor, err := New(Config{Endpoint: "https://kms.example.test", AccessToken: "token", KeyName: "projects/p/locations/l/keyRings/r/cryptoKeys/k/cryptoKeyVersions/1"}) + if err != nil { + t.Fatal(err) + } + if _, err := executor.Sign(t.Context(), app.SigningRequest{ProviderType: "aws_kms", PayloadHash: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}); err == nil { + t.Fatal("expected wrong provider type to be rejected") + } +} + +func TestNewRequiresHTTPSAndAccessToken(t *testing.T) { + if _, err := New(Config{Endpoint: "http://kms.example.test", AccessToken: "token"}); err == nil { + t.Fatal("expected non-HTTPS endpoint to be rejected") + } + if _, err := New(Config{Endpoint: "https://kms.example.test"}); err == nil { + t.Fatal("expected missing access token to be rejected") + } +} + +func bytesOf(value byte, count int) []byte { + out := make([]byte, count) + for i := range out { + out[i] = value + } + return out +} diff --git a/internal/adapters/signing/httpgateway/httpgateway.go b/internal/adapters/signing/httpgateway/httpgateway.go new file mode 100644 index 0000000..c9c52fa --- /dev/null +++ b/internal/adapters/signing/httpgateway/httpgateway.go @@ -0,0 +1,137 @@ +package httpgateway + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "strings" + "time" + + "github.com/aatuh/evydence/internal/app" + "github.com/aatuh/evydence/internal/domain" +) + +const defaultTimeout = 10 * time.Second + +type Config struct { + Endpoint string + BearerToken string + Timeout time.Duration + AllowInsecureForLocalhost bool + Client *http.Client +} + +type Executor struct { + endpoint string + bearerToken string + client *http.Client +} + +type signRequest struct { + TenantID string `json:"tenant_id"` + ProviderID string `json:"provider_id"` + ProviderType string `json:"provider_type"` + KeyRef string `json:"key_ref"` + SubjectType string `json:"subject_type"` + SubjectID string `json:"subject_id"` + PayloadHash string `json:"payload_hash"` +} + +type signResponse struct { + Signature string `json:"signature"` + KeyID string `json:"key_id"` + Algorithm string `json:"algorithm"` +} + +func New(cfg Config) (*Executor, error) { + endpoint := strings.TrimSpace(cfg.Endpoint) + if endpoint == "" { + return nil, app.ErrValidation + } + parsed, err := url.Parse(endpoint) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return nil, app.ErrValidation + } + if parsed.Scheme != "https" && (!cfg.AllowInsecureForLocalhost || parsed.Scheme != "http" || !localhostHost(parsed.Hostname())) { + return nil, errors.New("signing gateway endpoint must use https") + } + timeout := cfg.Timeout + if timeout <= 0 { + timeout = defaultTimeout + } + client := cfg.Client + if client == nil { + client = &http.Client{Timeout: timeout} + } + return &Executor{endpoint: endpoint, bearerToken: strings.TrimSpace(cfg.BearerToken), client: client}, nil +} + +func (e *Executor) Sign(ctx context.Context, request app.SigningRequest) (app.SigningResult, error) { + if e == nil || e.client == nil { + return app.SigningResult{}, app.ErrValidation + } + body, err := json.Marshal(signRequest{ + TenantID: request.TenantID, + ProviderID: request.ProviderID, + ProviderType: request.ProviderType, + KeyRef: request.KeyRef, + SubjectType: request.SubjectType, + SubjectID: request.SubjectID, + PayloadHash: request.PayloadHash, + }) + if err != nil { + return app.SigningResult{}, fmt.Errorf("encode signing request: %w", err) + } + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, e.endpoint, bytes.NewReader(body)) + if err != nil { + return app.SigningResult{}, fmt.Errorf("create signing request: %w", err) + } + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Accept", "application/json") + if e.bearerToken != "" { + httpReq.Header.Set("Authorization", "Bearer "+e.bearerToken) + } + resp, err := e.client.Do(httpReq) + if err != nil { + return app.SigningResult{}, errors.New("execute signing request") + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096)) + return app.SigningResult{}, fmt.Errorf("signing gateway returned status %d", resp.StatusCode) + } + var decoded signResponse + decoder := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&decoded); err != nil { + return app.SigningResult{}, errors.New("decode signing response") + } + decoded.Signature = strings.TrimSpace(decoded.Signature) + decoded.KeyID = strings.TrimSpace(decoded.KeyID) + decoded.Algorithm = strings.TrimSpace(decoded.Algorithm) + if decoded.Signature == "" || len(decoded.Signature) > 32768 { + return app.SigningResult{}, app.ErrValidation + } + return app.SigningResult{ + Signature: decoded.Signature, + KeyID: decoded.KeyID, + Algorithm: decoded.Algorithm, + Checks: []domain.VerifyCheck{ + {Name: "signing_gateway_response", Result: "passed", Detail: "External signing gateway returned a signature over the submitted payload hash."}, + }, + }, nil +} + +func localhostHost(host string) bool { + if host == "localhost" { + return true + } + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() +} diff --git a/internal/adapters/signing/httpgateway/httpgateway_test.go b/internal/adapters/signing/httpgateway/httpgateway_test.go new file mode 100644 index 0000000..06b135c --- /dev/null +++ b/internal/adapters/signing/httpgateway/httpgateway_test.go @@ -0,0 +1,77 @@ +package httpgateway + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/aatuh/evydence/internal/app" +) + +func TestNewRequiresHTTPSEndpointExceptLocalhostOverride(t *testing.T) { + if _, err := New(Config{Endpoint: "http://example.com/sign"}); err == nil { + t.Fatal("expected non-HTTPS remote endpoint to be rejected") + } + if _, err := New(Config{Endpoint: "http://127.0.0.1/sign", AllowInsecureForLocalhost: true}); err != nil { + t.Fatalf("localhost override should be accepted: %v", err) + } +} + +func TestSignPostsHashOnlyAndReturnsSignature(t *testing.T) { + var gotAuth, gotPayloadHash, gotKeyRef string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Fatalf("method = %s", r.Method) + } + gotAuth = r.Header.Get("Authorization") + var req signRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatal(err) + } + gotPayloadHash = req.PayloadHash + gotKeyRef = req.KeyRef + _ = json.NewEncoder(w).Encode(signResponse{Signature: "sig_external", KeyID: "kms-key-1", Algorithm: "external-aws_kms"}) + })) + defer server.Close() + + executor, err := New(Config{Endpoint: server.URL, BearerToken: "secret-token", AllowInsecureForLocalhost: true}) + if err != nil { + t.Fatal(err) + } + result, err := executor.Sign(t.Context(), app.SigningRequest{ + TenantID: "ten_1", + ProviderID: "sp_1", + ProviderType: "aws_kms", + KeyRef: "arn:aws:kms:example", + SubjectType: "release", + SubjectID: "rel_1", + PayloadHash: "sha256:abcdef", + }) + if err != nil { + t.Fatal(err) + } + if result.Signature != "sig_external" || result.KeyID != "kms-key-1" || result.Algorithm != "external-aws_kms" { + t.Fatalf("result = %#v", result) + } + if gotAuth != "Bearer secret-token" { + t.Fatalf("auth header = %q", gotAuth) + } + if gotPayloadHash != "sha256:abcdef" || gotKeyRef != "arn:aws:kms:example" { + t.Fatalf("request hash/key ref = %q/%q", gotPayloadHash, gotKeyRef) + } +} + +func TestSignRejectsUnknownResponseFields(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"signature":"sig","extra":"nope"}`)) + })) + defer server.Close() + executor, err := New(Config{Endpoint: server.URL, AllowInsecureForLocalhost: true}) + if err != nil { + t.Fatal(err) + } + if _, err := executor.Sign(t.Context(), app.SigningRequest{PayloadHash: "sha256:abc"}); err == nil { + t.Fatal("expected strict response decoding to reject unknown fields") + } +} diff --git a/internal/adapters/transparency/httpfetcher/httpfetcher.go b/internal/adapters/transparency/httpfetcher/httpfetcher.go new file mode 100644 index 0000000..92a4c7a --- /dev/null +++ b/internal/adapters/transparency/httpfetcher/httpfetcher.go @@ -0,0 +1,157 @@ +package httpfetcher + +import ( + "context" + "encoding/hex" + "encoding/json" + "errors" + "io" + "net" + "net/http" + "net/url" + "strings" + "time" + + "github.com/aatuh/evydence/internal/app" + "github.com/aatuh/evydence/internal/domain" +) + +const ( + defaultTimeout = 10 * time.Second + maxProofBytes = 128 * 1024 +) + +type Config struct { + Timeout time.Duration + AllowInsecureForLocalhost bool + Client *http.Client +} + +type Fetcher struct { + client *http.Client + allowInsecureForLocalhost bool +} + +type proofResponse struct { + ExternalID string `json:"external_id"` + LeafHash string `json:"leaf_hash"` + RootHash string `json:"root_hash"` + LeafIndex int `json:"leaf_index"` + TreeSize int `json:"tree_size"` + InclusionProof []string `json:"inclusion_proof"` +} + +func New(cfg Config) *Fetcher { + timeout := cfg.Timeout + if timeout <= 0 { + timeout = defaultTimeout + } + client := cfg.Client + if client == nil { + client = &http.Client{ + Timeout: timeout, + CheckRedirect: func(_ *http.Request, _ []*http.Request) error { + return http.ErrUseLastResponse + }, + } + } + return &Fetcher{client: client, allowInsecureForLocalhost: cfg.AllowInsecureForLocalhost} +} + +func (f *Fetcher) FetchTransparencyProof(ctx context.Context, req app.TransparencyProofRequest) (app.TransparencyProofResult, error) { + if f == nil || f.client == nil { + return app.TransparencyProofResult{}, app.ErrValidation + } + externalID := strings.TrimSpace(req.ExternalID) + if externalID == "" { + return app.TransparencyProofResult{}, app.ErrValidation + } + endpoint, err := parseEndpoint(req.Endpoint, f.allowInsecureForLocalhost) + if err != nil { + return app.TransparencyProofResult{}, err + } + endpoint.Path = strings.TrimRight(endpoint.Path, "/") + "/entries/" + url.PathEscape(externalID) + "/inclusion-proof" + endpoint.RawQuery = "" + endpoint.Fragment = "" + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil) + if err != nil { + return app.TransparencyProofResult{}, app.ErrValidation + } + httpReq.Header.Set("Accept", "application/json") + resp, err := f.client.Do(httpReq) + if err != nil { + return app.TransparencyProofResult{}, errors.New("fetch transparency proof") + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096)) + return app.TransparencyProofResult{}, app.ErrVerificationFailed + } + var decoded proofResponse + decoder := json.NewDecoder(io.LimitReader(resp.Body, maxProofBytes+1)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&decoded); err != nil { + return app.TransparencyProofResult{}, app.ErrVerificationFailed + } + if decoder.InputOffset() > maxProofBytes { + return app.TransparencyProofResult{}, app.ErrVerificationFailed + } + decoded.ExternalID = strings.TrimSpace(decoded.ExternalID) + decoded.LeafHash = strings.TrimSpace(decoded.LeafHash) + decoded.RootHash = strings.TrimSpace(decoded.RootHash) + if decoded.ExternalID != "" && decoded.ExternalID != externalID { + return app.TransparencyProofResult{}, app.ErrVerificationFailed + } + if !validSHA256Digest(decoded.RootHash) || (decoded.LeafHash != "" && !validSHA256Digest(decoded.LeafHash)) || decoded.TreeSize <= 0 || decoded.LeafIndex < 0 || decoded.LeafIndex >= decoded.TreeSize || len(decoded.InclusionProof) > 64 { + return app.TransparencyProofResult{}, app.ErrVerificationFailed + } + proof := make([]string, 0, len(decoded.InclusionProof)) + for _, hash := range decoded.InclusionProof { + hash = strings.TrimSpace(hash) + if !validSHA256Digest(hash) { + return app.TransparencyProofResult{}, app.ErrVerificationFailed + } + proof = append(proof, hash) + } + return app.TransparencyProofResult{ + ExternalID: decoded.ExternalID, + LeafHash: decoded.LeafHash, + RootHash: decoded.RootHash, + LeafIndex: decoded.LeafIndex, + TreeSize: decoded.TreeSize, + InclusionProof: proof, + Checks: []domain.VerifyCheck{{Name: "public_log_proof_fetch", Result: "passed"}}, + Limitations: []string{"Fetched public-log proof material is validated locally; endpoint trust and provider semantics remain deployment responsibilities."}, + }, nil +} + +func validSHA256Digest(value string) bool { + if !strings.HasPrefix(value, "sha256:") { + return false + } + raw, err := hex.DecodeString(strings.TrimPrefix(value, "sha256:")) + return err == nil && len(raw) == 32 +} + +func parseEndpoint(raw string, allowInsecureLocalhost bool) (*url.URL, error) { + parsed, err := url.Parse(strings.TrimSpace(raw)) + if err != nil || parsed.Scheme == "" || parsed.Host == "" || parsed.User != nil { + return nil, app.ErrValidation + } + if parsed.Scheme == "https" { + return parsed, nil + } + if parsed.Scheme == "http" && allowInsecureLocalhost && localhostHost(parsed.Hostname()) { + return parsed, nil + } + return nil, app.ErrValidation +} + +func localhostHost(host string) bool { + if strings.EqualFold(host, "localhost") { + return true + } + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() +} diff --git a/internal/adapters/transparency/httpfetcher/httpfetcher_test.go b/internal/adapters/transparency/httpfetcher/httpfetcher_test.go new file mode 100644 index 0000000..f7545ee --- /dev/null +++ b/internal/adapters/transparency/httpfetcher/httpfetcher_test.go @@ -0,0 +1,59 @@ +package httpfetcher + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/aatuh/evydence/internal/app" +) + +func TestFetchTransparencyProofFetchesStrictJSON(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/log/entries/entry-1/inclusion-proof" { + t.Fatalf("path = %s", r.URL.Path) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "external_id": "entry-1", + "root_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "leaf_index": 0, + "tree_size": 1, + "inclusion_proof": []string{}, + }) + })) + defer server.Close() + + fetcher := New(Config{AllowInsecureForLocalhost: true}) + result, err := fetcher.FetchTransparencyProof(t.Context(), app.TransparencyProofRequest{Endpoint: server.URL + "/log", ExternalID: "entry-1"}) + if err != nil { + t.Fatalf("FetchTransparencyProof: %v", err) + } + if result.ExternalID != "entry-1" || result.RootHash == "" || len(result.Checks) == 0 { + t.Fatalf("result = %#v", result) + } +} + +func TestFetchTransparencyProofRejectsInsecureRemoteEndpoint(t *testing.T) { + fetcher := New(Config{}) + if _, err := fetcher.FetchTransparencyProof(t.Context(), app.TransparencyProofRequest{Endpoint: "http://example.test/log", ExternalID: "entry-1"}); err == nil { + t.Fatal("FetchTransparencyProof err=nil, want insecure endpoint rejection") + } +} + +func TestFetchTransparencyProofRejectsUnknownFieldsAndDoesNotLeakBody(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"root_hash":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","leaf_index":0,"tree_size":1,"inclusion_proof":[],"provider_secret":"secret-value"}`)) + })) + defer server.Close() + + fetcher := New(Config{AllowInsecureForLocalhost: true}) + _, err := fetcher.FetchTransparencyProof(t.Context(), app.TransparencyProofRequest{Endpoint: server.URL, ExternalID: "entry-1"}) + if err == nil { + t.Fatal("FetchTransparencyProof err=nil, want strict JSON error") + } + if strings.Contains(err.Error(), "secret-value") { + t.Fatalf("error leaked provider body: %v", err) + } +} diff --git a/internal/adapters/transparency/httpgateway/httpgateway.go b/internal/adapters/transparency/httpgateway/httpgateway.go new file mode 100644 index 0000000..c3b1e1b --- /dev/null +++ b/internal/adapters/transparency/httpgateway/httpgateway.go @@ -0,0 +1,208 @@ +package httpgateway + +import ( + "bytes" + "context" + "encoding/hex" + "encoding/json" + "errors" + "io" + "net" + "net/http" + "net/url" + "strings" + "time" + + "github.com/aatuh/evydence/internal/app" + "github.com/aatuh/evydence/internal/domain" +) + +const ( + defaultTimeout = 10 * time.Second + maxBodyBytes = 128 * 1024 +) + +type Config struct { + Endpoint string + BearerToken string + Timeout time.Duration + AllowInsecureForLocalhost bool + Client *http.Client +} + +type Fetcher struct { + endpoint string + bearerToken string + client *http.Client +} + +type proofRequest struct { + TenantID string `json:"tenant_id"` + LogID string `json:"log_id"` + EntryID string `json:"entry_id"` + Endpoint string `json:"endpoint"` + ExternalID string `json:"external_id"` + EntryHash string `json:"entry_hash"` +} + +type proofResponse struct { + ExternalID string `json:"external_id"` + LeafHash string `json:"leaf_hash"` + RootHash string `json:"root_hash"` + LeafIndex int `json:"leaf_index"` + TreeSize int `json:"tree_size"` + InclusionProof []string `json:"inclusion_proof"` + Checks []domain.VerifyCheck `json:"checks,omitempty"` + Limitations []string `json:"limitations,omitempty"` +} + +func New(cfg Config) (*Fetcher, error) { + endpoint := strings.TrimSpace(cfg.Endpoint) + if endpoint == "" { + return nil, app.ErrValidation + } + parsed, err := url.Parse(endpoint) + if err != nil || parsed.Scheme == "" || parsed.Host == "" || parsed.User != nil { + return nil, app.ErrValidation + } + if parsed.Scheme != "https" && (!cfg.AllowInsecureForLocalhost || parsed.Scheme != "http" || !localhostHost(parsed.Hostname())) { + return nil, errors.New("transparency proof gateway endpoint must use https") + } + timeout := cfg.Timeout + if timeout <= 0 { + timeout = defaultTimeout + } + client := cfg.Client + if client == nil { + client = &http.Client{Timeout: timeout} + } + return &Fetcher{endpoint: endpoint, bearerToken: strings.TrimSpace(cfg.BearerToken), client: client}, nil +} + +func (f *Fetcher) FetchTransparencyProof(ctx context.Context, req app.TransparencyProofRequest) (app.TransparencyProofResult, error) { + if f == nil || f.client == nil { + return app.TransparencyProofResult{}, app.ErrValidation + } + externalID := strings.TrimSpace(req.ExternalID) + if externalID == "" { + return app.TransparencyProofResult{}, app.ErrValidation + } + body, err := json.Marshal(proofRequest{ + TenantID: strings.TrimSpace(req.TenantID), + LogID: strings.TrimSpace(req.LogID), + EntryID: strings.TrimSpace(req.EntryID), + Endpoint: strings.TrimSpace(req.Endpoint), + ExternalID: externalID, + EntryHash: strings.TrimSpace(req.EntryHash), + }) + if err != nil { + return app.TransparencyProofResult{}, app.ErrValidation + } + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, f.endpoint, bytes.NewReader(body)) + if err != nil { + return app.TransparencyProofResult{}, app.ErrValidation + } + httpReq.Header.Set("Accept", "application/json") + httpReq.Header.Set("Content-Type", "application/json") + if f.bearerToken != "" { + httpReq.Header.Set("Authorization", "Bearer "+f.bearerToken) + } + resp, err := f.client.Do(httpReq) + if err != nil { + return app.TransparencyProofResult{}, errors.New("transparency proof gateway request failed") + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096)) + return app.TransparencyProofResult{}, app.ErrVerificationFailed + } + var decoded proofResponse + decoder := json.NewDecoder(io.LimitReader(resp.Body, maxBodyBytes+1)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&decoded); err != nil { + return app.TransparencyProofResult{}, app.ErrVerificationFailed + } + if decoder.InputOffset() > maxBodyBytes { + return app.TransparencyProofResult{}, app.ErrVerificationFailed + } + decoded.ExternalID = strings.TrimSpace(decoded.ExternalID) + decoded.LeafHash = strings.TrimSpace(decoded.LeafHash) + decoded.RootHash = strings.TrimSpace(decoded.RootHash) + if decoded.ExternalID != "" && decoded.ExternalID != externalID { + return app.TransparencyProofResult{}, app.ErrVerificationFailed + } + if !validSHA256Digest(decoded.RootHash) || (decoded.LeafHash != "" && !validSHA256Digest(decoded.LeafHash)) || decoded.TreeSize <= 0 || decoded.LeafIndex < 0 || decoded.LeafIndex >= decoded.TreeSize || len(decoded.InclusionProof) > 64 { + return app.TransparencyProofResult{}, app.ErrVerificationFailed + } + proof := make([]string, 0, len(decoded.InclusionProof)) + for _, hash := range decoded.InclusionProof { + hash = strings.TrimSpace(hash) + if !validSHA256Digest(hash) { + return app.TransparencyProofResult{}, app.ErrVerificationFailed + } + proof = append(proof, hash) + } + checks := append([]domain.VerifyCheck{{Name: "transparency_proof_gateway", Result: "passed"}}, safeChecks(decoded.Checks)...) + limitations := safeLimitations(decoded.Limitations) + if len(limitations) == 0 { + limitations = []string{"Transparency proof gateway returned proof material for local verification; provider trust and timestamp semantics remain deployment responsibilities."} + } + return app.TransparencyProofResult{ + ExternalID: decoded.ExternalID, + LeafHash: decoded.LeafHash, + RootHash: decoded.RootHash, + LeafIndex: decoded.LeafIndex, + TreeSize: decoded.TreeSize, + InclusionProof: proof, + Checks: checks, + Limitations: limitations, + }, nil +} + +func validSHA256Digest(value string) bool { + if !strings.HasPrefix(value, "sha256:") || len(value) != len("sha256:")+64 { + return false + } + raw, err := hex.DecodeString(strings.TrimPrefix(value, "sha256:")) + return err == nil && len(raw) == 32 +} + +func safeChecks(in []domain.VerifyCheck) []domain.VerifyCheck { + out := make([]domain.VerifyCheck, 0, len(in)) + for _, check := range in { + name := strings.TrimSpace(check.Name) + result := strings.TrimSpace(check.Result) + detail := strings.TrimSpace(check.Detail) + if name == "" || result == "" || len(name) > 128 || len(result) > 32 || len(detail) > 1024 { + continue + } + out = append(out, domain.VerifyCheck{Name: name, Result: result, Detail: detail}) + if len(out) >= 32 { + break + } + } + return out +} + +func safeLimitations(in []string) []string { + out := make([]string, 0, len(in)) + for _, limitation := range in { + limitation = strings.TrimSpace(limitation) + if limitation == "" || len(limitation) > 1024 { + continue + } + out = append(out, limitation) + if len(out) >= 16 { + break + } + } + return out +} + +func localhostHost(host string) bool { + if strings.EqualFold(host, "localhost") { + return true + } + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() +} diff --git a/internal/adapters/transparency/httpgateway/httpgateway_test.go b/internal/adapters/transparency/httpgateway/httpgateway_test.go new file mode 100644 index 0000000..d71576a --- /dev/null +++ b/internal/adapters/transparency/httpgateway/httpgateway_test.go @@ -0,0 +1,107 @@ +package httpgateway + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/aatuh/evydence/internal/app" + "github.com/aatuh/evydence/internal/domain" +) + +const testDigest = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + +func TestFetchTransparencyProofPostsSafeRequestAndReturnsProof(t *testing.T) { + var gotAuth string + var gotRequest proofRequest + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Fatalf("method = %s", r.Method) + } + gotAuth = r.Header.Get("Authorization") + if err := json.NewDecoder(r.Body).Decode(&gotRequest); err != nil { + t.Fatal(err) + } + _ = json.NewEncoder(w).Encode(proofResponse{ + ExternalID: "entry-1", + LeafHash: testDigest, + RootHash: testDigest, + LeafIndex: 0, + TreeSize: 1, + InclusionProof: []string{}, + Checks: []domain.VerifyCheck{{Name: "provider_timestamp", Result: "passed"}}, + Limitations: []string{"Gateway checked provider proof material."}, + }) + })) + defer server.Close() + + fetcher, err := New(Config{Endpoint: server.URL, BearerToken: "gateway-token", AllowInsecureForLocalhost: true}) + if err != nil { + t.Fatal(err) + } + result, err := fetcher.FetchTransparencyProof(t.Context(), app.TransparencyProofRequest{ + TenantID: "ten_1", + LogID: "log_1", + EntryID: "pte_1", + Endpoint: "https://log.example.test", + ExternalID: "entry-1", + EntryHash: testDigest, + }) + if err != nil { + t.Fatal(err) + } + if gotAuth != "Bearer gateway-token" { + t.Fatalf("auth header = %q", gotAuth) + } + if gotRequest.ExternalID != "entry-1" || gotRequest.EntryHash != testDigest || gotRequest.Endpoint != "https://log.example.test" { + t.Fatalf("request = %#v", gotRequest) + } + if result.RootHash != testDigest || len(result.Checks) < 2 || result.Checks[0].Name != "transparency_proof_gateway" { + t.Fatalf("result = %#v", result) + } +} + +func TestFetchTransparencyProofRejectsExternalIDMismatch(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(proofResponse{ExternalID: "other", RootHash: testDigest, LeafIndex: 0, TreeSize: 1, InclusionProof: []string{}}) + })) + defer server.Close() + + fetcher, err := New(Config{Endpoint: server.URL, AllowInsecureForLocalhost: true}) + if err != nil { + t.Fatal(err) + } + if _, err := fetcher.FetchTransparencyProof(t.Context(), app.TransparencyProofRequest{ExternalID: "entry-1"}); err == nil { + t.Fatal("expected external id mismatch to fail") + } +} + +func TestFetchTransparencyProofRejectsUnknownFieldsAndHidesBody(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"external_id":"entry-1","root_hash":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","leaf_index":0,"tree_size":1,"inclusion_proof":[],"secret":"provider-secret"}`)) + })) + defer server.Close() + + fetcher, err := New(Config{Endpoint: server.URL, AllowInsecureForLocalhost: true}) + if err != nil { + t.Fatal(err) + } + _, err = fetcher.FetchTransparencyProof(t.Context(), app.TransparencyProofRequest{ExternalID: "entry-1"}) + if err == nil { + t.Fatal("expected strict response decoding failure") + } + if strings.Contains(err.Error(), "provider-secret") { + t.Fatalf("error leaked body: %v", err) + } +} + +func TestNewRequiresHTTPSEndpointExceptLocalhostOverride(t *testing.T) { + if _, err := New(Config{Endpoint: "http://example.com/proof"}); err == nil { + t.Fatal("expected remote HTTP endpoint to be rejected") + } + if _, err := New(Config{Endpoint: "http://127.0.0.1/proof", AllowInsecureForLocalhost: true}); err != nil { + t.Fatalf("localhost override should be accepted: %v", err) + } +} diff --git a/internal/app/authorization_helpers_more_test.go b/internal/app/authorization_helpers_more_test.go new file mode 100644 index 0000000..83149ea --- /dev/null +++ b/internal/app/authorization_helpers_more_test.go @@ -0,0 +1,162 @@ +package app + +import ( + "errors" + "testing" + + "github.com/aatuh/evydence/internal/domain" +) + +func TestEnterpriseGovernanceAndFutureHelperBranches(t *testing.T) { + now := fixedNow() + ledger := NewLedger(Config{APIKeyPepper: "test-pepper", Now: fixedNow}) + ledger.mu.Lock() + ledger.tenants["ten_1"] = domain.Tenant{ID: "ten_1", CreatedAt: now} + ledger.products["prod_1"] = domain.Product{ID: "prod_1", TenantID: "ten_1", CreatedAt: now} + ledger.projects["proj_1"] = domain.Project{ID: "proj_1", TenantID: "ten_1", ProductID: "prod_1", CreatedAt: now} + ledger.releases["rel_1"] = domain.Release{ID: "rel_1", TenantID: "ten_1", ProductID: "prod_1", CreatedAt: now} + ledger.artifacts["art_1"] = domain.Artifact{ID: "art_1", TenantID: "ten_1", CreatedAt: now} + ledger.evidence["ev_1"] = domain.EvidenceItem{ID: "ev_1", TenantID: "ten_1", ProductID: "prod_1", ProjectID: "proj_1", ReleaseID: "rel_1", Type: "sbom", CreatedAt: now} + ledger.users["usr_1"] = domain.HumanUser{ID: "usr_1", TenantID: "ten_1", CreatedAt: now} + ledger.collectors["col_1"] = domain.Collector{ID: "col_1", TenantID: "ten_1", CreatedAt: now} + ledger.customerPackages["pkg_1"] = domain.CustomerSecurityPackage{ID: "pkg_1", TenantID: "ten_1", ProductID: "prod_1", ReleaseID: "rel_1", CreatedAt: now} + ledger.evidenceBundles["eb_1"] = domain.EvidenceBundle{ID: "eb_1", TenantID: "ten_1", ReleaseID: "rel_1", CreatedAt: now} + ledger.buildRuns["build_1"] = domain.BuildRun{ID: "build_1", TenantID: "ten_1", ProjectID: "proj_1", ReleaseID: "rel_1", CreatedAt: now} + ledger.repositories["repo_1"] = domain.SourceRepository{ID: "repo_1", TenantID: "ten_1", ProjectID: "proj_1", CreatedAt: now} + ledger.deployments["dep_1"] = domain.DeploymentEvent{ID: "dep_1", TenantID: "ten_1", ReleaseID: "rel_1", CreatedAt: now} + ledger.environments["env_1"] = domain.DeploymentEnvironment{ID: "env_1", TenantID: "ten_1", ProductID: "prod_1", CreatedAt: now} + ledger.incidents["inc_1"] = domain.Incident{ID: "inc_1", TenantID: "ten_1", ProductID: "prod_1", ReleaseID: "rel_1", CreatedAt: now} + ledger.securityScans["sec_1"] = domain.SecurityScan{ID: "sec_1", TenantID: "ten_1", ProductID: "prod_1", ReleaseID: "rel_1", CreatedAt: now} + ledger.frameworks["fw_1"] = domain.ControlFramework{ID: "fw_1", TenantID: "ten_1", Slug: "b", Version: "1", CreatedAt: now} + ledger.frameworks["fw_2"] = domain.ControlFramework{ID: "fw_2", TenantID: "ten_1", Slug: "a", Version: "1", CreatedAt: now} + ledger.controls["ctrl_1"] = domain.SecurityControl{ID: "ctrl_1", TenantID: "ten_1", FrameworkID: "fw_1", CreatedAt: now} + ledger.customPolicies["pol_1"] = domain.CustomPolicy{ID: "pol_1", TenantID: "ten_1", CreatedAt: now} + ledger.scans["scan_1"] = domain.VulnerabilityScan{ID: "scan_1", TenantID: "ten_1", ReleaseID: "rel_1", Findings: []domain.VulnerabilityFinding{{ID: "finding_1", Vulnerability: "CVE-1", Severity: "critical", State: "open"}}, CreatedAt: now} + ledger.contractDiffs["cdiff_1"] = domain.ContractDiff{ID: "cdiff_1", TenantID: "ten_1", CreatedAt: now} + ledger.waivers["waiver_1"] = domain.Waiver{ID: "waiver_1", TenantID: "ten_1", ScopeType: "release", ScopeID: "rel_1", CreatedAt: now} + ledger.manualDocs["review_1"] = domain.ManualSecurityDocument{ID: "review_1", TenantID: "ten_1", ProductID: "prod_1", ReleaseID: "rel_1", DocumentType: "security_review", CreatedAt: now} + ledger.controlLinks["ce_1"] = domain.ControlEvidence{ID: "ce_1", TenantID: "ten_1", ControlID: "ctrl_1", SubjectType: "evidence", SubjectID: "ev_1", EvidenceType: "sbom", ProductID: "prod_1", ReleaseID: "rel_1", Confidence: confidenceHigh, CreatedAt: now} + ledger.roleBindings["rb_1"] = domain.RoleBinding{ID: "rb_1", TenantID: "ten_1", SubjectType: "user", SubjectID: "usr_1", Role: "security_engineer", ResourceType: "release", ResourceID: "rel_1", CreatedAt: now} + ledger.mu.Unlock() + + ledger.mu.Lock() + defer ledger.mu.Unlock() + for _, subject := range []struct{ typ, id string }{{"user", "usr_1"}, {"collector", "col_1"}} { + if err := ledger.ensureRoleSubjectLocked("ten_1", subject.typ, subject.id); err != nil { + t.Fatalf("role subject %s: %v", subject.typ, err) + } + } + if !errors.Is(ledger.ensureRoleSubjectLocked("ten_1", "team", "team_1"), ErrValidation) { + t.Fatal("unknown role subject should be validation") + } + for _, scope := range []struct{ typ, id string }{{"tenant", "ten_1"}, {"product", "prod_1"}, {"project", "proj_1"}, {"release", "rel_1"}, {"evidence", "ev_1"}} { + if err := ledger.ensureRetentionScopeLocked("ten_1", scope.typ, scope.id); err != nil { + t.Fatalf("retention scope %s: %v", scope.typ, err) + } + } + if !errors.Is(ledger.ensureRetentionScopeLocked("ten_1", "unknown", "id"), ErrValidation) { + t.Fatal("unknown retention scope should be validation") + } + for _, resource := range []struct{ typ, id string }{{"", ""}, {"tenant", "ten_1"}, {"product", "prod_1"}, {"project", "proj_1"}, {"release", "rel_1"}, {"customer_security_package", "pkg_1"}, {"evidence_bundle", "eb_1"}} { + if err := ledger.ensureRoleResourceLocked("ten_1", resource.typ, resource.id); err != nil { + t.Fatalf("role resource %s: %v", resource.typ, err) + } + } + if !errors.Is(ledger.ensureRoleResourceLocked("ten_1", "", "unexpected"), ErrValidation) { + t.Fatal("empty resource type with id should be validation") + } + if !validRoleSubject("user") || !validRoleSubject("collector") || validRoleSubject("group") { + t.Fatal("role subject validation mismatch") + } + for _, role := range []string{"tenant_admin", "security_engineer", "release_manager", "customer_verifier", "collector"} { + if !validRole(role) { + t.Fatalf("valid role rejected: %s", role) + } + } + if validRole("owner") { + t.Fatal("invalid role accepted") + } + grants := ledger.resourceGrantsForUserLocked("usr_1") + if len(grants) != 1 || !grantHasScope(grants[0], ScopeEvidenceRead) { + t.Fatalf("resource grants = %#v", grants) + } + if scopes := scopesFromResourceGrants(grants); len(scopes) == 0 { + t.Fatal("scopes from grants should not be empty") + } + + refs := resourceRefs{ProductID: "prod_1", ProjectID: "proj_1", ReleaseID: "rel_1", SourceRepositoryID: "repo_1", CustomerPackageID: "pkg_1", EvidenceBundleID: "eb_1"} + for _, grant := range []domain.ResourceGrant{ + {ResourceType: "tenant", ResourceID: "ten_1", Scopes: []string{ScopeEvidenceRead}}, + {ResourceType: "product", ResourceID: "prod_1", Scopes: []string{ScopeEvidenceRead}}, + {ResourceType: "project", ResourceID: "proj_1", Scopes: []string{ScopeEvidenceRead}}, + {ResourceType: "release", ResourceID: "rel_1", Scopes: []string{ScopeEvidenceRead}}, + {ResourceType: "customer_security_package", ResourceID: "pkg_1", Scopes: []string{ScopeEvidenceRead}}, + {ResourceType: "evidence_bundle", ResourceID: "eb_1", Scopes: []string{ScopeEvidenceRead}}, + } { + if !ledger.grantCoversResourceLocked("ten_1", grant, refs) { + t.Fatalf("grant should cover refs: %#v", grant) + } + } + if ledger.grantCoversResourceLocked("ten_1", domain.ResourceGrant{ResourceType: "unknown", ResourceID: "id"}, refs) { + t.Fatal("unknown grant resource should not cover refs") + } + + for _, subject := range []struct{ typ, id string }{ + {"tenant", "ten_1"}, {"product", "prod_1"}, {"release", "rel_1"}, {"evidence", "ev_1"}, {"build", "build_1"}, {"customer_package", "pkg_1"}, + } { + if _, err := ledger.ensureFutureSubjectLocked("ten_1", subject.typ, subject.id); err != nil { + t.Fatalf("future subject %s: %v", subject.typ, err) + } + } + if !errors.Is(mustErrFuture(ledger.ensureFutureSubjectLocked("ten_1", "unknown", "id")), ErrValidation) { + t.Fatal("unknown future subject should be validation") + } + ids := ledger.evidenceIDsForQuestionLocked("ten_1", domain.QuestionnaireQuestion{ControlID: "ctrl_1"}, "prod_1", "rel_1") + if len(ids) != 1 || ids[0] != "ev_1" { + t.Fatalf("control question evidence ids = %#v", ids) + } + ids = ledger.evidenceIDsForQuestionLocked("ten_1", domain.QuestionnaireQuestion{EvidenceType: "sbom"}, "prod_1", "rel_1") + if len(ids) != 1 || ids[0] != "ev_1" { + t.Fatalf("typed question evidence ids = %#v", ids) + } + if !evidenceMatchesRefs(ledger.evidence["ev_1"], resourceRefs{ProductID: "prod_1", ProjectID: "proj_1", ReleaseID: "rel_1"}) { + t.Fatal("evidence should match refs") + } + if evidenceMatchesRefs(ledger.evidence["ev_1"], resourceRefs{ProductID: "other"}) || evidenceMatchesRefs(ledger.evidence["ev_1"], resourceRefs{ProjectID: "other"}) || evidenceMatchesRefs(ledger.evidence["ev_1"], resourceRefs{ReleaseID: "other"}) { + t.Fatal("evidence should not match wrong refs") + } + items := []domain.MarketplaceCollector{{ID: "z"}, {ID: "a"}, {ID: "m"}} + sortMarketplaceCollectors(items) + if items[0].ID != "a" || items[2].ID != "z" { + t.Fatalf("marketplace sort = %#v", items) + } + + for _, scope := range []string{"release", "finding", "control", "policy"} { + if !validWaiverScope(scope) { + t.Fatalf("valid waiver scope rejected: %s", scope) + } + } + for _, subject := range []string{"release", "contract_diff", "waiver", "security_review", "customer_package"} { + if !validApprovalSubject(subject) { + t.Fatalf("valid approval subject rejected: %s", subject) + } + } + if !validApprovalDecision("approved") || !validApprovalDecision("rejected") || validApprovalDecision("maybe") { + t.Fatal("approval decision validation mismatch") + } + for _, scope := range []struct{ typ, id string }{{"release", "rel_1"}, {"control", "ctrl_1"}, {"policy", "pol_1"}, {"finding", "finding_1"}} { + if err := ledger.ensureWaiverScopeLocked("ten_1", scope.typ, scope.id); err != nil { + t.Fatalf("waiver scope %s: %v", scope.typ, err) + } + } + for _, subject := range []struct{ typ, id string }{{"release", "rel_1"}, {"contract_diff", "cdiff_1"}, {"waiver", "waiver_1"}, {"security_review", "review_1"}, {"customer_package", "pkg_1"}} { + if err := ledger.ensureApprovalSubjectLocked("ten_1", subject.typ, subject.id); err != nil { + t.Fatalf("approval subject %s: %v", subject.typ, err) + } + } + if first := ledger.firstFrameworkIDLocked("ten_1"); first != "fw_2" { + t.Fatalf("first framework = %s", first) + } +} + +func mustErrFuture(_ resourceRefs, err error) error { return err } diff --git a/internal/app/authz_inventory_test.go b/internal/app/authz_inventory_test.go new file mode 100644 index 0000000..1a0eedf --- /dev/null +++ b/internal/app/authz_inventory_test.go @@ -0,0 +1,97 @@ +package app + +import ( + "os" + "strings" + "testing" +) + +func TestResourceScopedAuthorizationCoverageInventory(t *testing.T) { + files := map[string][]string{ + "builds.go": { + "CreateBuildRun", + "GetBuildRun", + "UploadBuildAttestation", + }, + "controls.go": { + "LinkControlEvidence", + "ListControlEvidence", + "ControlCoverageReport", + "CRAReadinessReport", + "CRAVulnerabilityHandlingReport", + "SecurityUpdateEvidenceReport", + }, + "enterprise.go": { + "CreateQuestionnaireAnswerLibraryEntry", + "ListQuestionnaireAnswerLibrary", + }, + "risk_workflows.go": { + "CreateIncident", + "RecordIncidentTimelineEvent", + "CreateRemediationTask", + "IncidentReport", + "uploadSecurityScan", + "UploadManualSecurityDocument", + "VulnerabilityPostureReport", + "EvaluateCustomPolicy", + }, + "implementation_increments.go": { + "SearchEvidence", + "CreateReleaseCandidate", + "GetReleaseCandidate", + "ListReleaseCandidates", + "UpdateReleaseCandidateState", + "ListSourceRepositories", + "CreateSourceRepository", + "RecordSourceCommit", + "UpsertSourceBranch", + "RecordPullRequest", + "ListDeploymentEnvironments", + "CreateDeploymentEnvironment", + "RecordDeployment", + "GetDeployment", + "ListDeployments", + }, + } + for file, funcs := range files { + bodyBytes, err := os.ReadFile(file) + if err != nil { + t.Fatalf("read %s: %v", file, err) + } + body := string(bodyBytes) + for _, name := range funcs { + fn := functionBody(t, body, name) + if !strings.Contains(fn, "authorizeResourceLocked") && !strings.Contains(fn, "resourceAllowedLocked") { + t.Fatalf("%s.%s missing resource-scoped authorization call", file, name) + } + } + } +} + +func functionBody(t *testing.T, fileBody, name string) string { + t.Helper() + markers := []string{ + "func (l *Ledger) " + name, + "func (s identityService) " + name, + "func (s releaseEvidenceService) " + name, + "func (s packageReportService) " + name, + } + start := -1 + marker := "" + for _, candidate := range markers { + if idx := strings.Index(fileBody, candidate); idx >= 0 { + start = idx + marker = candidate + break + } + } + if marker == "" { + t.Fatalf("missing function %s", name) + } + rest := fileBody[start+len(marker):] + next := strings.Index(rest, "\nfunc ") + if next < 0 { + return rest + } + return rest[:next] +} diff --git a/internal/app/benchmark_test.go b/internal/app/benchmark_test.go new file mode 100644 index 0000000..acd2c14 --- /dev/null +++ b/internal/app/benchmark_test.go @@ -0,0 +1,46 @@ +package app + +import ( + "context" + "strconv" + "testing" +) + +func BenchmarkReleaseEvidenceIngestion(b *testing.B) { + ctx := context.Background() + ledger := NewLedger(Config{APIKeyPepper: "benchmark-pepper"}) + _, _, secret, err := ledger.BootstrapTenant(ctx, "Benchmark Tenant", "admin", []string{"*"}) + if err != nil { + b.Fatal(err) + } + actor, err := ledger.Authenticate(ctx, secret) + if err != nil { + b.Fatal(err) + } + product, err := ledger.CreateProduct(ctx, actor, "Benchmark Product", "bench-product") + if err != nil { + b.Fatal(err) + } + release, err := ledger.CreateRelease(ctx, actor, product.ID, "1.0.0") + if err != nil { + b.Fatal(err) + } + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, err := ledger.CreateEvidence(ctx, actor, CreateEvidenceInput{ + ProductID: product.ID, + ReleaseID: release.ID, + Type: "build", + Subtype: "benchmark", + Title: "Benchmark evidence " + strconv.Itoa(i), + PayloadHash: "sha256:ca978112ca1bbdcafac231b39a23dc4da786eff8147c4e72b9807785afee48bb", + Tags: []string{"benchmark"}, + Limitations: []string{"Local benchmark evidence only."}, + }) + if err != nil { + b.Fatal(err) + } + } +} diff --git a/internal/app/builds.go b/internal/app/builds.go index dc7430e..d9409bd 100644 --- a/internal/app/builds.go +++ b/internal/app/builds.go @@ -308,6 +308,9 @@ func (l *Ledger) CreateBuildRun(ctx context.Context, actor domain.Actor, in Crea if project.ProductID != release.ProductID { return domain.BuildRun{}, ErrValidation } + if err := l.authorizeResourceLocked(actor, ScopeBuildWrite, resourceRefs{ProductID: project.ProductID, ProjectID: project.ID, ReleaseID: release.ID}); err != nil { + return domain.BuildRun{}, err + } for _, output := range build.Outputs { if !validDigest(output.Digest) { return domain.BuildRun{}, ErrValidation @@ -350,6 +353,9 @@ func (l *Ledger) GetBuildRun(ctx context.Context, actor domain.Actor, id string) if !ok || build.TenantID != actor.TenantID { return domain.BuildRun{}, ErrNotFound } + if err := l.authorizeResourceLocked(actor, ScopeBuildRead, resourceRefs{ProjectID: build.ProjectID, ReleaseID: build.ReleaseID, BuildID: build.ID}); err != nil { + return domain.BuildRun{}, err + } return build, nil } @@ -374,6 +380,10 @@ func (l *Ledger) UploadBuildAttestation(ctx context.Context, actor domain.Actor, l.mu.Unlock() return domain.BuildAttestation{}, ErrNotFound } + if err := l.authorizeResourceLocked(actor, ScopeBuildWrite, resourceRefs{ProjectID: build.ProjectID, ReleaseID: build.ReleaseID, BuildID: build.ID}); err != nil { + l.mu.Unlock() + return domain.BuildAttestation{}, err + } if !subjectsMatchBuildOutputs(parsed.SubjectDigests, build.Outputs) { l.mu.Unlock() return domain.BuildAttestation{}, ErrValidation @@ -433,9 +443,22 @@ func (l *Ledger) UploadBuildAttestation(ctx context.Context, actor domain.Actor, SchemaVersion: domain.BuildAttestationSchemaVersion, CreatedAt: l.now(), } - l.attestations[attestation.ID] = attestation - _, _ = l.appendChainLocked(actor.TenantID, "build_attestation.created", "build_attestation", attestation.ID, actorType(actor), actorID(actor), payloadHash, "") - if err := l.enqueue(ctx, actor.TenantID, "verify_attestation", "build_attestation", attestation.ID, map[string]any{"payload_ref": payloadRef, "payload_hash": payloadHash}); err != nil { + persistedAttestation := attestation + chainAction := "build_attestation.created" + if l.workerOwnedParsers { + persistedAttestation.PayloadType = "" + persistedAttestation.PredicateType = "" + persistedAttestation.SubjectDigests = nil + persistedAttestation.BuilderID = "" + persistedAttestation.BuildType = "" + persistedAttestation.MaterialsCount = 0 + persistedAttestation.SignatureCount = 0 + persistedAttestation.VerificationStatus = "accepted" + chainAction = "build_attestation.accepted" + } + l.attestations[attestation.ID] = persistedAttestation + _, _ = l.appendChainLocked(actor.TenantID, chainAction, "build_attestation", attestation.ID, actorType(actor), actorID(actor), payloadHash, "") + if err := l.enqueue(ctx, actor.TenantID, "verify_attestation", "build_attestation", attestation.ID, map[string]any{"payload_ref": payloadRef, "payload_hash": payloadHash, "parser_version": ParserVersionDSSEInTotoJSON}); err != nil { return domain.BuildAttestation{}, err } if err := l.persistLocked(ctx); err != nil { @@ -741,7 +764,7 @@ func (l *Ledger) checkReleaseHasPassedBuildLocked(tenantID, releaseID string) do } } } - return domain.PolicyCheck{Name: "release_requires_passed_build", Result: "failed", Severity: "high", Missing: []string{"passed_build"}, Explanation: "no passed build with output digest linked to the release was found"} + return domain.PolicyCheck{Name: "release_requires_passed_build", Result: "failed", Severity: "high", Missing: []string{"passed_build"}, Explanation: "no passed build with output digest linked to the release was found", Remediation: "Upload a passed build run whose output digest matches a release artifact digest."} } func (l *Ledger) checkReleaseHasBuildAttestationLocked(tenantID, releaseID string) domain.PolicyCheck { @@ -760,7 +783,7 @@ func (l *Ledger) checkReleaseHasBuildAttestationLocked(tenantID, releaseID strin } } } - return domain.PolicyCheck{Name: "release_requires_build_attestation", Result: "failed", Severity: "high", Missing: []string{"build_attestation"}, Explanation: "no build attestation subject matches a release artifact digest"} + return domain.PolicyCheck{Name: "release_requires_build_attestation", Result: "failed", Severity: "high", Missing: []string{"build_attestation"}, Explanation: "no build attestation subject matches a release artifact digest", Remediation: "Upload a DSSE/in-toto attestation whose subject digest matches a release artifact digest."} } func (l *Ledger) releaseArtifactDigestsLocked(tenantID, releaseID string) map[string]struct{} { diff --git a/internal/app/context_cancellation_test.go b/internal/app/context_cancellation_test.go new file mode 100644 index 0000000..94e87f2 --- /dev/null +++ b/internal/app/context_cancellation_test.go @@ -0,0 +1,750 @@ +package app + +import ( + "context" + "errors" + "testing" + + "github.com/aatuh/evydence/internal/domain" +) + +func TestLedgerOperationsHonorCanceledContextBeforeWork(t *testing.T) { + ledger := NewLedger(Config{APIKeyPepper: "test-pepper", Now: fixedNow}) + actor := domain.Actor{TenantID: "ten_ctx", KeyID: "key_ctx", Scopes: []string{"*"}} + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + checks := []struct { + name string + run func() error + }{ + {"BootstrapTenant", func() error { + _, _, _, err := ledger.BootstrapTenant(ctx, "Tenant", "admin", []string{"*"}) + return err + }}, + {"Authenticate", func() error { _, err := ledger.Authenticate(ctx, "secret"); return err }}, + {"CreateAPIKey", func() error { _, _, err := ledger.CreateAPIKey(ctx, actor, "key", []string{"*"}, nil); return err }}, + {"ListAPIKeys", func() error { _, err := ledger.ListAPIKeys(ctx, actor); return err }}, + {"CreateProduct", func() error { _, err := ledger.CreateProduct(ctx, actor, "Product", "product"); return err }}, + {"ListProducts", func() error { _, err := ledger.ListProducts(ctx, actor); return err }}, + {"CreateProject", func() error { _, err := ledger.CreateProject(ctx, actor, "prod", "api"); return err }}, + {"CreateRelease", func() error { _, err := ledger.CreateRelease(ctx, actor, "prod", "1.0.0"); return err }}, + {"GetRelease", func() error { _, err := ledger.GetRelease(ctx, actor, "rel"); return err }}, + {"FreezeRelease", func() error { _, err := ledger.FreezeRelease(ctx, actor, "rel"); return err }}, + {"ApproveRelease", func() error { _, err := ledger.ApproveRelease(ctx, actor, "rel"); return err }}, + {"RegisterArtifact", func() error { + _, err := ledger.RegisterArtifact(ctx, actor, "artifact", "application/octet-stream", sampleDigest("artifact"), 1) + return err + }}, + {"CreateEvidence", func() error { + _, err := ledger.CreateEvidence(ctx, actor, CreateEvidenceInput{Type: "build", Title: "Build"}) + return err + }}, + {"GetEvidence", func() error { _, err := ledger.GetEvidence(ctx, actor, "ev"); return err }}, + {"ListEvidence", func() error { _, err := ledger.ListEvidence(ctx, actor, "", ""); return err }}, + {"SupersedeEvidence", func() error { _, err := ledger.SupersedeEvidence(ctx, actor, "ev", "ev2", "reason"); return err }}, + {"LinkEvidence", func() error { _, err := ledger.LinkEvidence(ctx, actor, "ev", "release", "rel"); return err }}, + {"UploadSBOM", func() error { _, err := ledger.UploadSBOM(ctx, actor, "rel", "art", []byte(`{}`)); return err }}, + {"UploadVulnerabilityScan", func() error { _, err := ledger.UploadVulnerabilityScan(ctx, actor, []byte(`{}`)); return err }}, + {"UploadOpenAPIContract", func() error { + _, err := ledger.UploadOpenAPIContract(ctx, actor, "prod", "rel", "v1", []byte(`{}`)) + return err + }}, + {"EvaluateRelease", func() error { _, err := ledger.EvaluateRelease(ctx, actor, "rel"); return err }}, + {"CreateReleaseBundle", func() error { _, err := ledger.CreateReleaseBundle(ctx, actor, "rel"); return err }}, + {"GetReleaseBundle", func() error { _, err := ledger.GetReleaseBundle(ctx, actor, "bundle"); return err }}, + {"GetSBOM", func() error { _, err := ledger.GetSBOM(ctx, actor, "sbom"); return err }}, + {"ListSBOMComponents", func() error { _, err := ledger.ListSBOMComponents(ctx, actor, ListSBOMComponentsInput{}); return err }}, + {"GetVulnerabilityScan", func() error { _, err := ledger.GetVulnerabilityScan(ctx, actor, "scan"); return err }}, + {"GetOpenAPIContract", func() error { _, err := ledger.GetOpenAPIContract(ctx, actor, "contract"); return err }}, + {"VerifySubject", func() error { _, err := ledger.VerifySubject(ctx, actor, "audit_chain", ""); return err }}, + {"RotateSigningKey", func() error { _, err := ledger.RotateSigningKey(ctx, actor, "reason"); return err }}, + {"ListSigningKeys", func() error { _, err := ledger.ListSigningKeys(ctx, actor); return err }}, + {"MissingEvidenceReport", func() error { _, err := ledger.MissingEvidenceReport(ctx, actor, "rel"); return err }}, + {"SearchEvidence", func() error { _, err := ledger.SearchEvidence(ctx, actor, EvidenceSearchInput{}); return err }}, + {"RecordEvidenceLifecycleEvent", func() error { + _, err := ledger.RecordEvidenceLifecycleEvent(ctx, actor, "ev", RecordEvidenceLifecycleInput{Action: "amendment", Reason: "reason"}) + return err + }}, + {"ListEvidenceLifecycleEvents", func() error { _, err := ledger.ListEvidenceLifecycleEvents(ctx, actor, "ev"); return err }}, + {"CreateReleaseCandidate", func() error { + _, err := ledger.CreateReleaseCandidate(ctx, actor, CreateReleaseCandidateInput{ReleaseID: "rel", Name: "rc"}) + return err + }}, + {"GetReleaseCandidate", func() error { _, err := ledger.GetReleaseCandidate(ctx, actor, "rc"); return err }}, + {"ListReleaseCandidates", func() error { _, err := ledger.ListReleaseCandidates(ctx, actor, "rel"); return err }}, + {"UpdateReleaseCandidateState", func() error { + _, err := ledger.UpdateReleaseCandidateState(ctx, actor, "rc", "promoted", "reason") + return err + }}, + {"RegisterContainerImage", func() error { + _, err := ledger.RegisterContainerImage(ctx, actor, RegisterContainerImageInput{ArtifactID: "art", Repository: "repo", Digest: sampleDigest("image")}) + return err + }}, + {"CreateArtifactSignature", func() error { + _, err := ledger.CreateArtifactSignature(ctx, actor, CreateArtifactSignatureInput{ArtifactID: "art", Algorithm: "cosign", Signature: "sig"}) + return err + }}, + {"GetArtifactSignature", func() error { _, err := ledger.GetArtifactSignature(ctx, actor, "sig"); return err }}, + {"CreateSourceRepository", func() error { + _, err := ledger.CreateSourceRepository(ctx, actor, CreateRepositoryInput{ProjectID: "proj", Provider: "github", FullName: "owner/repo"}) + return err + }}, + {"ListSourceRepositories", func() error { _, err := ledger.ListSourceRepositories(ctx, actor, "proj"); return err }}, + {"RecordSourceCommit", func() error { + _, err := ledger.RecordSourceCommit(ctx, actor, RecordCommitInput{RepositoryID: "repo", SHA: "0123456789abcdef0123456789abcdef01234567"}) + return err + }}, + {"UpsertSourceBranch", func() error { + _, err := ledger.UpsertSourceBranch(ctx, actor, UpsertBranchInput{RepositoryID: "repo", Name: "main"}) + return err + }}, + {"RecordPullRequest", func() error { + _, err := ledger.RecordPullRequest(ctx, actor, RecordPullRequestInput{RepositoryID: "repo", ProviderID: "1", Title: "PR", State: "open"}) + return err + }}, + {"UploadGitHubSourceSnapshot", func() error { _, err := ledger.UploadGitHubSourceSnapshot(ctx, actor, []byte(`{}`)); return err }}, + {"UploadGitLabSourceSnapshot", func() error { _, err := ledger.UploadGitLabSourceSnapshot(ctx, actor, []byte(`{}`)); return err }}, + {"CreateDeploymentEnvironment", func() error { + _, err := ledger.CreateDeploymentEnvironment(ctx, actor, CreateEnvironmentInput{ProductID: "prod", Name: "prod", Kind: "production"}) + return err + }}, + {"ListDeploymentEnvironments", func() error { _, err := ledger.ListDeploymentEnvironments(ctx, actor, "prod"); return err }}, + {"RecordDeployment", func() error { + _, err := ledger.RecordDeployment(ctx, actor, RecordDeploymentInput{EnvironmentID: "env", ReleaseID: "rel", Status: "started"}) + return err + }}, + {"GetDeployment", func() error { _, err := ledger.GetDeployment(ctx, actor, "dep"); return err }}, + {"ListDeployments", func() error { _, err := ledger.ListDeployments(ctx, actor, "rel", "env"); return err }}, + {"UploadVEX", func() error { _, err := ledger.UploadVEX(ctx, actor, "rel", "art", []byte(`{}`)); return err }}, + {"GetVEXDocument", func() error { _, err := ledger.GetVEXDocument(ctx, actor, "vex"); return err }}, + {"CreateVulnerabilityDecision", func() error { + _, err := ledger.CreateVulnerabilityDecision(ctx, actor, "finding", CreateVulnerabilityDecisionInput{Status: "fixed", Justification: "fixed"}) + return err + }}, + {"CreateException", func() error { _, err := ledger.CreateException(ctx, actor, CreateExceptionInput{}); return err }}, + {"ListExceptions", func() error { _, err := ledger.ListExceptions(ctx, actor, "rel"); return err }}, + {"ApproveException", func() error { _, err := ledger.ApproveException(ctx, actor, "ex"); return err }}, + {"ReleaseReadinessReport", func() error { _, err := ledger.ReleaseReadinessReport(ctx, actor, "rel"); return err }}, + {"CreateControlFramework", func() error { + _, err := ledger.CreateControlFramework(ctx, actor, CreateControlFrameworkInput{Name: "Framework", Version: "1"}) + return err + }}, + {"ListControlFrameworks", func() error { _, err := ledger.ListControlFrameworks(ctx, actor); return err }}, + {"CreateSecurityControl", func() error { + _, err := ledger.CreateSecurityControl(ctx, actor, CreateSecurityControlInput{FrameworkID: "fw", Code: "C", Title: "Control", Objective: "Objective"}) + return err + }}, + {"GetSecurityControl", func() error { _, err := ledger.GetSecurityControl(ctx, actor, "ctrl"); return err }}, + {"LinkControlEvidence", func() error { + _, err := ledger.LinkControlEvidence(ctx, actor, "ctrl", LinkControlEvidenceInput{EvidenceType: "sbom", SubjectType: "sbom", SubjectID: "sbom"}) + return err + }}, + {"ListControlEvidence", func() error { _, err := ledger.ListControlEvidence(ctx, actor, "ctrl", "prod", "rel"); return err }}, + {"ControlCoverageReport", func() error { + _, err := ledger.ControlCoverageReport(ctx, actor, ControlCoverageReportInput{FrameworkID: "fw"}) + return err + }}, + {"CRAReadinessReport", func() error { + _, err := ledger.CRAReadinessReport(ctx, actor, CRAReadinessReportInput{ProductID: "prod", ReleaseID: "rel"}) + return err + }}, + {"CRAVulnerabilityHandlingReport", func() error { + _, err := ledger.CRAVulnerabilityHandlingReport(ctx, actor, "prod", "rel") + return err + }}, + {"SecurityUpdateEvidenceReport", func() error { + _, err := ledger.SecurityUpdateEvidenceReport(ctx, actor, "prod", "rel") + return err + }}, + {"CreateCollector", func() error { + _, _, _, err := ledger.CreateCollector(ctx, actor, CreateCollectorInput{Name: "collector", Type: "github_actions", Version: "1"}) + return err + }}, + {"ListCollectors", func() error { _, err := ledger.ListCollectors(ctx, actor); return err }}, + {"RecordCollectorRelease", func() error { + _, err := ledger.RecordCollectorRelease(ctx, actor, RecordCollectorReleaseInput{CollectorID: "collector", Version: "1", ArtifactDigest: sampleDigest("collector")}) + return err + }}, + {"CollectorHealthReport", func() error { _, err := ledger.CollectorHealthReport(ctx, actor, "collector"); return err }}, + {"CreateBuildRun", func() error { + _, err := ledger.CreateBuildRun(ctx, actor, CreateBuildRunInput{ProjectID: "proj", ReleaseID: "rel", Provider: "generic_ci", CommitSHA: "0123456789abcdef0123456789abcdef01234567", Status: "passed", StartedAt: fixedNow()}) + return err + }}, + {"GetBuildRun", func() error { _, err := ledger.GetBuildRun(ctx, actor, "build"); return err }}, + {"UploadBuildAttestation", func() error { _, err := ledger.UploadBuildAttestation(ctx, actor, "build", []byte(`{}`)); return err }}, + {"CreateWaiver", func() error { _, err := ledger.CreateWaiver(ctx, actor, CreateWaiverInput{}); return err }}, + {"ApproveWaiver", func() error { _, err := ledger.ApproveWaiver(ctx, actor, "waiver"); return err }}, + {"CreateApprovalRecord", func() error { _, err := ledger.CreateApprovalRecord(ctx, actor, CreateApprovalInput{}); return err }}, + {"CreateRedactionProfile", func() error { + _, err := ledger.CreateRedactionProfile(ctx, actor, CreateRedactionProfileInput{Name: "profile"}) + return err + }}, + {"CreateCustomerSecurityPackage", func() error { + _, err := ledger.CreateCustomerSecurityPackage(ctx, actor, CreateCustomerPackageInput{}) + return err + }}, + {"AccessCustomerSecurityPackage", func() error { _, err := ledger.AccessCustomerSecurityPackage(ctx, actor, "pkg"); return err }}, + {"ExportCustomerSecurityPackageArchive", func() error { _, err := ledger.ExportCustomerSecurityPackageArchive(ctx, actor, "pkg"); return err }}, + {"ExportCustomerPortalPackageArchive", func() error { _, err := ledger.ExportCustomerPortalPackageArchive(ctx, "token"); return err }}, + {"ExportCustomerPortalPackageArchiveWithAcceptance", func() error { + _, err := ledger.ExportCustomerPortalPackageArchiveWithAcceptance(ctx, "token", CustomerPortalAcceptanceInput{NDAAccepted: true, NDAAcceptedBy: "reviewer"}) + return err + }}, + {"SecurityReviewPackageReport", func() error { _, err := ledger.SecurityReviewPackageReport(ctx, actor, "pkg"); return err }}, + {"CRAReadinessHTMLPackage", func() error { _, err := ledger.CRAReadinessHTMLPackage(ctx, actor, "prod", "rel"); return err }}, + {"ListControlFrameworkTemplatePacks", func() error { _, err := ledger.ListControlFrameworkTemplatePacks(ctx, actor); return err }}, + {"InstallControlFrameworkTemplatePack", func() error { + _, err := ledger.InstallControlFrameworkTemplatePack(ctx, actor, "evydence-cra-readiness") + return err + }}, + {"CreateCustomReportTemplate", func() error { + _, err := ledger.CreateCustomReportTemplate(ctx, actor, CreateReportTemplateInput{Name: "template", Version: "1", ReportType: "summary", Template: "json"}) + return err + }}, + {"RenderCustomReport", func() error { + _, err := ledger.RenderCustomReport(ctx, actor, RenderReportInput{TemplateID: "template", SubjectType: "release", SubjectID: "rel"}) + return err + }}, + {"ExportEvidenceBundle", func() error { _, err := ledger.ExportEvidenceBundle(ctx, actor, "rel", nil); return err }}, + {"ImportEvidenceBundle", func() error { _, err := ledger.ImportEvidenceBundle(ctx, actor, domain.EvidenceBundle{}); return err }}, + {"CreateDSSETrustRoot", func() error { + _, err := ledger.CreateDSSETrustRoot(ctx, actor, CreateDSSETrustRootInput{Name: "root", KeyID: "root", Algorithm: "Ed25519", PublicKey: "pub"}) + return err + }}, + {"VerifyDSSEAttestationSignature", func() error { _, err := ledger.VerifyDSSEAttestationSignature(ctx, actor, "att"); return err }}, + {"CreateIncident", func() error { + _, err := ledger.CreateIncident(ctx, actor, CreateIncidentInput{ProductID: "prod", Title: "incident", Severity: "high"}) + return err + }}, + {"RecordIncidentTimelineEvent", func() error { + _, err := ledger.RecordIncidentTimelineEvent(ctx, actor, "inc", RecordIncidentTimelineInput{EventType: "detected", Summary: "summary"}) + return err + }}, + {"CreateIncidentWebhookReceiver", func() error { + _, err := ledger.CreateIncidentWebhookReceiver(ctx, actor, CreateIncidentWebhookReceiverInput{IncidentID: "inc", Name: "receiver", Provider: "generic", PublicKey: "pub"}) + return err + }}, + {"HandleIncidentWebhook", func() error { _, _, err := ledger.HandleIncidentWebhook(ctx, HandleIncidentWebhookInput{}); return err }}, + {"CreateRemediationTask", func() error { + _, err := ledger.CreateRemediationTask(ctx, actor, CreateRemediationTaskInput{IncidentID: "inc", Title: "task", Owner: "security"}) + return err + }}, + {"IncidentReport", func() error { _, err := ledger.IncidentReport(ctx, actor, "inc"); return err }}, + {"UploadSecurityScan", func() error { _, err := ledger.UploadSecurityScan(ctx, actor, UploadSecurityScanInput{}); return err }}, + {"UploadAPISecurityScan", func() error { + _, err := ledger.UploadAPISecurityScan(ctx, actor, UploadSecurityScanInput{}) + return err + }}, + {"UploadManualSecurityDocument", func() error { + _, err := ledger.UploadManualSecurityDocument(ctx, actor, UploadManualSecurityDocumentInput{}) + return err + }}, + {"UploadSPDXSBOM", func() error { _, err := ledger.UploadSPDXSBOM(ctx, actor, "rel", "art", []byte(`{}`)); return err }}, + {"CreateSBOMDiff", func() error { _, err := ledger.CreateSBOMDiff(ctx, actor, CreateSBOMDiffInput{}); return err }}, + {"UploadCycloneDXVEX", func() error { _, err := ledger.UploadCycloneDXVEX(ctx, actor, "rel", "art", []byte(`{}`)); return err }}, + {"RecordVulnerabilityWorkflow", func() error { + _, err := ledger.RecordVulnerabilityWorkflow(ctx, actor, RecordVulnerabilityWorkflowInput{}) + return err + }}, + {"VulnerabilityPostureReport", func() error { _, err := ledger.VulnerabilityPostureReport(ctx, actor, "rel"); return err }}, + {"CreateContractDiff", func() error { _, err := ledger.CreateContractDiff(ctx, actor, CreateContractDiffInput{}); return err }}, + {"CreateCustomPolicy", func() error { _, err := ledger.CreateCustomPolicy(ctx, actor, CreateCustomPolicyInput{}); return err }}, + {"EvaluateCustomPolicy", func() error { _, err := ledger.EvaluateCustomPolicy(ctx, actor, "policy", "rel"); return err }}, + {"CreateOrganization", func() error { + _, err := ledger.CreateOrganization(ctx, actor, CreateOrganizationInput{Name: "Org", Slug: "org"}) + return err + }}, + {"CreateUser", func() error { + _, err := ledger.CreateUser(ctx, actor, CreateUserInput{OrganizationID: "org", Email: "user@example.test"}) + return err + }}, + {"DeactivateUser", func() error { _, err := ledger.DeactivateUser(ctx, actor, "user"); return err }}, + {"CreateRoleBinding", func() error { + _, err := ledger.CreateRoleBinding(ctx, actor, CreateRoleBindingInput{SubjectType: "user", SubjectID: "user", Role: "tenant_admin", ResourceType: "tenant"}) + return err + }}, + {"ListRoleBindings", func() error { _, err := ledger.ListRoleBindings(ctx, actor); return err }}, + {"CreateSSOProvider", func() error { + _, err := ledger.CreateSSOProvider(ctx, actor, CreateSSOProviderInput{Name: "OIDC", Type: "oidc", Issuer: "https://idp.example.test", ClientID: "client"}) + return err + }}, + {"LinkSSOIdentity", func() error { + _, err := ledger.LinkSSOIdentity(ctx, actor, LinkSSOIdentityInput{UserID: "user", ProviderID: "provider", Subject: "sub", Email: "user@example.test"}) + return err + }}, + {"CreateSSOSession", func() error { + _, _, err := ledger.CreateSSOSession(ctx, actor, CreateSSOSessionInput{UserID: "user", ProviderID: "provider", ExpiresAt: fixedNow().AddDate(0, 0, 1)}) + return err + }}, + {"RevokeSSOSession", func() error { _, err := ledger.RevokeSSOSession(ctx, actor, "session"); return err }}, + {"InstanceAdminSnapshot", func() error { _, err := ledger.InstanceAdminSnapshot(ctx, actor); return err }}, + {"CreateLegalHold", func() error { + _, err := ledger.CreateLegalHold(ctx, actor, CreateLegalHoldInput{ScopeType: "release", ScopeID: "rel", Reason: "review", Owner: "legal"}) + return err + }}, + {"CreateRetentionOverride", func() error { + _, err := ledger.CreateRetentionOverride(ctx, actor, CreateRetentionOverrideInput{ScopeType: "release", ScopeID: "rel", RetentionUntil: fixedNow().AddDate(1, 0, 0), Reason: "review", Owner: "legal"}) + return err + }}, + {"RetentionReport", func() error { _, err := ledger.RetentionReport(ctx, actor, "release", "rel"); return err }}, + {"CreateCustomerPortalAccess", func() error { + _, _, err := ledger.CreateCustomerPortalAccess(ctx, actor, CreateCustomerPortalAccessInput{PackageID: "pkg", CustomerName: "ACME", ExpiresAt: fixedNow().AddDate(0, 0, 1)}) + return err + }}, + {"ListCustomerPortalAccess", func() error { _, err := ledger.ListCustomerPortalAccess(ctx, actor, "pkg"); return err }}, + {"RevokeCustomerPortalAccess", func() error { _, err := ledger.RevokeCustomerPortalAccess(ctx, actor, "access"); return err }}, + {"AccessCustomerPortalPackage", func() error { _, err := ledger.AccessCustomerPortalPackage(ctx, "token"); return err }}, + {"AccessCustomerPortalPackageWithAcceptance", func() error { + _, err := ledger.AccessCustomerPortalPackageWithAcceptance(ctx, "token", CustomerPortalAcceptanceInput{NDAAccepted: true, NDAAcceptedBy: "reviewer"}) + return err + }}, + {"CreateQuestionnaireTemplate", func() error { + _, err := ledger.CreateQuestionnaireTemplate(ctx, actor, CreateQuestionnaireTemplateInput{Name: "template", Version: "1"}) + return err + }}, + {"CreateQuestionnairePackage", func() error { + _, err := ledger.CreateQuestionnairePackage(ctx, actor, CreateQuestionnairePackageInput{TemplateID: "template", PackageID: "pkg", ProductID: "prod", ReleaseID: "rel"}) + return err + }}, + {"CreateQuestionnaireAnswerLibraryEntry", func() error { + _, err := ledger.CreateQuestionnaireAnswerLibraryEntry(ctx, actor, CreateQuestionnaireAnswerLibraryEntryInput{QuestionID: "q1", Answer: "answer"}) + return err + }}, + {"ListQuestionnaireAnswerLibrary", func() error { + _, err := ledger.ListQuestionnaireAnswerLibrary(ctx, actor, ListQuestionnaireAnswerLibraryInput{QuestionID: "q1"}) + return err + }}, + {"CreateCommercialCollectorDefinition", func() error { + _, err := ledger.CreateCommercialCollectorDefinition(ctx, actor, CreateCommercialCollectorInput{Name: "collector", Provider: "jira", Version: "1", ManifestHash: sampleDigest("collector")}) + return err + }}, + {"ListCommercialCollectorDefinitions", func() error { _, err := ledger.ListCommercialCollectorDefinitions(ctx, actor); return err }}, + {"CreateEvidenceSummary", func() error { + _, err := ledger.CreateEvidenceSummary(ctx, actor, CreateEvidenceSummaryInput{SubjectType: "release", SubjectID: "rel"}) + return err + }}, + {"CreateQuestionnaireDraft", func() error { + _, err := ledger.CreateQuestionnaireDraft(ctx, actor, CreateQuestionnaireDraftInput{TemplateID: "template", ProductID: "prod", ReleaseID: "rel"}) + return err + }}, + {"CreateGraphSnapshot", func() error { + _, err := ledger.CreateGraphSnapshot(ctx, actor, CreateGraphSnapshotInput{ProductID: "prod", ReleaseID: "rel"}) + return err + }}, + {"CreateSaaSEditionProfile", func() error { + _, err := ledger.CreateSaaSEditionProfile(ctx, actor, CreateSaaSEditionProfileInput{Name: "profile", Region: "eu", AdminTenantID: "ten", IsolationModel: "self-hosted"}) + return err + }}, + {"CreatePublicTransparencyLog", func() error { + _, err := ledger.CreatePublicTransparencyLog(ctx, actor, CreatePublicTransparencyLogInput{Name: "log", Endpoint: "https://log.example.test", PublicKey: "pub"}) + return err + }}, + {"PublishPublicTransparencyLogEntry", func() error { + _, err := ledger.PublishPublicTransparencyLogEntry(ctx, actor, PublishPublicTransparencyLogEntryInput{LogID: "log", CheckpointID: "checkpoint", ExternalID: "entry"}) + return err + }}, + {"CreateMarketplaceCollector", func() error { + _, err := ledger.CreateMarketplaceCollector(ctx, actor, CreateMarketplaceCollectorInput{Name: "collector", Provider: "scanner", Version: "1", Publisher: "vendor", ManifestHash: sampleDigest("collector")}) + return err + }}, + {"ListMarketplaceCollectors", func() error { _, err := ledger.ListMarketplaceCollectors(ctx, actor); return err }}, + {"MarketplaceCollectorHealth", func() error { _, err := ledger.MarketplaceCollectorHealth(ctx, actor, "collector"); return err }}, + {"CreatePDFReportPackage", func() error { + _, err := ledger.CreatePDFReportPackage(ctx, actor, CreatePDFReportPackageInput{ReportType: "release_readiness", ProductID: "prod", ReleaseID: "rel", Title: "Report"}) + return err + }}, + {"GenerateAnomalyReport", func() error { + _, err := ledger.GenerateAnomalyReport(ctx, actor, AnomalyReportInput{SubjectType: "release", SubjectID: "rel"}) + return err + }}, + {"CreateSigningOperation", func() error { + _, err := ledger.CreateSigningOperation(ctx, actor, CreateSigningOperationInput{ProviderID: "provider", SubjectType: "release", SubjectID: "rel", PayloadHash: sampleDigest("payload"), ExternalSignature: "sig"}) + return err + }}, + {"VerifyProviderIdentity", func() error { + _, err := ledger.VerifyProviderIdentity(ctx, actor, VerifyProviderIdentityInput{ProviderType: "oidc", ProviderID: "provider", Subject: "sub"}) + return err + }}, + {"VerifyCosignSignature", func() error { + _, err := ledger.VerifyCosignSignature(ctx, actor, VerifyCosignInput{ArtifactSignatureID: "sig"}) + return err + }}, + {"RevokeSigningKey", func() error { _, err := ledger.RevokeSigningKey(ctx, actor, "key", "reason"); return err }}, + {"CreateSigningProvider", func() error { + _, err := ledger.CreateSigningProvider(ctx, actor, CreateSigningProviderInput{Name: "kms", Type: "aws_kms", KeyRef: "arn:aws:kms:example", Encrypted: true}) + return err + }}, + {"CreateMerkleBatch", func() error { + _, err := ledger.CreateMerkleBatch(ctx, actor, CreateMerkleBatchInput{FromSequence: 1, ToSequence: 1}) + return err + }}, + {"VerifyMerkleBatch", func() error { _, err := ledger.VerifyMerkleBatch(ctx, actor, "batch"); return err }}, + {"CreateTransparencyCheckpoint", func() error { + _, err := ledger.CreateTransparencyCheckpoint(ctx, actor, CreateTransparencyCheckpointInput{BatchID: "batch", Provider: "internal", ExternalID: "checkpoint"}) + return err + }}, + {"CreateObjectRetentionPolicy", func() error { + _, err := ledger.CreateObjectRetentionPolicy(ctx, actor, CreateObjectRetentionPolicyInput{Name: "policy", ObjectPrefix: "tenants/ten/", Mode: "governance", RetentionDays: 30}) + return err + }}, + {"VerifyObjectRetentionPolicy", func() error { _, err := ledger.VerifyObjectRetentionPolicy(ctx, actor, "policy"); return err }}, + {"SigningCustodyReviewReport", func() error { _, err := ledger.SigningCustodyReviewReport(ctx, actor); return err }}, + {"GenerateBackupManifest", func() error { _, err := ledger.GenerateBackupManifest(ctx, actor); return err }}, + {"VerifyBackupManifest", func() error { _, err := ledger.VerifyBackupManifest(ctx, actor, "backup"); return err }}, + {"ReadinessStatus", func() error { _, err := ledger.ReadinessStatus(ctx); return err }}, + {"Metrics", func() error { _, err := ledger.Metrics(ctx, actor); return err }}, + {"ListAuditLog", func() error { _, err := ledger.ListAuditLog(ctx, actor, AuditLogFilter{}); return err }}, + } + + for _, check := range checks { + t.Run(check.name, func(t *testing.T) { + if err := check.run(); !errors.Is(err, context.Canceled) { + t.Fatalf("err=%v, want context.Canceled", err) + } + }) + } +} + +func TestLedgerOperationsRejectActorsWithoutRequiredScopesBeforeResourceWork(t *testing.T) { + ledger := NewLedger(Config{APIKeyPepper: "test-pepper", Now: fixedNow}) + actor := domain.Actor{TenantID: "ten_scope", KeyID: "key_scope"} + ctx := context.Background() + + checks := []struct { + name string + run func() error + }{ + {"CreateAPIKey", func() error { _, _, err := ledger.CreateAPIKey(ctx, actor, "key", []string{"*"}, nil); return err }}, + {"ListAPIKeys", func() error { _, err := ledger.ListAPIKeys(ctx, actor); return err }}, + {"CreateProduct", func() error { _, err := ledger.CreateProduct(ctx, actor, "Product", "product"); return err }}, + {"ListProducts", func() error { _, err := ledger.ListProducts(ctx, actor); return err }}, + {"CreateProject", func() error { _, err := ledger.CreateProject(ctx, actor, "prod", "api"); return err }}, + {"CreateRelease", func() error { _, err := ledger.CreateRelease(ctx, actor, "prod", "1.0.0"); return err }}, + {"GetRelease", func() error { _, err := ledger.GetRelease(ctx, actor, "rel"); return err }}, + {"FreezeRelease", func() error { _, err := ledger.FreezeRelease(ctx, actor, "rel"); return err }}, + {"ApproveRelease", func() error { _, err := ledger.ApproveRelease(ctx, actor, "rel"); return err }}, + {"RegisterArtifact", func() error { + _, err := ledger.RegisterArtifact(ctx, actor, "artifact", "application/octet-stream", sampleDigest("artifact"), 1) + return err + }}, + {"CreateEvidence", func() error { + _, err := ledger.CreateEvidence(ctx, actor, CreateEvidenceInput{Type: "build", Title: "Build"}) + return err + }}, + {"GetEvidence", func() error { _, err := ledger.GetEvidence(ctx, actor, "ev"); return err }}, + {"ListEvidence", func() error { _, err := ledger.ListEvidence(ctx, actor, "", ""); return err }}, + {"SupersedeEvidence", func() error { _, err := ledger.SupersedeEvidence(ctx, actor, "ev", "ev2", "reason"); return err }}, + {"LinkEvidence", func() error { _, err := ledger.LinkEvidence(ctx, actor, "ev", "release", "rel"); return err }}, + {"UploadSBOM", func() error { _, err := ledger.UploadSBOM(ctx, actor, "rel", "art", []byte(`{}`)); return err }}, + {"UploadVulnerabilityScan", func() error { _, err := ledger.UploadVulnerabilityScan(ctx, actor, []byte(`{}`)); return err }}, + {"UploadOpenAPIContract", func() error { + _, err := ledger.UploadOpenAPIContract(ctx, actor, "prod", "rel", "v1", []byte(`{}`)) + return err + }}, + {"EvaluateRelease", func() error { _, err := ledger.EvaluateRelease(ctx, actor, "rel"); return err }}, + {"CreateReleaseBundle", func() error { _, err := ledger.CreateReleaseBundle(ctx, actor, "rel"); return err }}, + {"GetReleaseBundle", func() error { _, err := ledger.GetReleaseBundle(ctx, actor, "bundle"); return err }}, + {"GetSBOM", func() error { _, err := ledger.GetSBOM(ctx, actor, "sbom"); return err }}, + {"ListSBOMComponents", func() error { _, err := ledger.ListSBOMComponents(ctx, actor, ListSBOMComponentsInput{}); return err }}, + {"GetVulnerabilityScan", func() error { _, err := ledger.GetVulnerabilityScan(ctx, actor, "scan"); return err }}, + {"GetOpenAPIContract", func() error { _, err := ledger.GetOpenAPIContract(ctx, actor, "contract"); return err }}, + {"VerifySubject", func() error { _, err := ledger.VerifySubject(ctx, actor, "audit_chain", ""); return err }}, + {"RotateSigningKey", func() error { _, err := ledger.RotateSigningKey(ctx, actor, "reason"); return err }}, + {"ListSigningKeys", func() error { _, err := ledger.ListSigningKeys(ctx, actor); return err }}, + {"MissingEvidenceReport", func() error { _, err := ledger.MissingEvidenceReport(ctx, actor, "rel"); return err }}, + {"SearchEvidence", func() error { _, err := ledger.SearchEvidence(ctx, actor, EvidenceSearchInput{}); return err }}, + {"RecordEvidenceLifecycleEvent", func() error { + _, err := ledger.RecordEvidenceLifecycleEvent(ctx, actor, "ev", RecordEvidenceLifecycleInput{Action: "amendment", Reason: "reason"}) + return err + }}, + {"ListEvidenceLifecycleEvents", func() error { _, err := ledger.ListEvidenceLifecycleEvents(ctx, actor, "ev"); return err }}, + {"CreateReleaseCandidate", func() error { + _, err := ledger.CreateReleaseCandidate(ctx, actor, CreateReleaseCandidateInput{ReleaseID: "rel", Name: "rc"}) + return err + }}, + {"GetReleaseCandidate", func() error { _, err := ledger.GetReleaseCandidate(ctx, actor, "rc"); return err }}, + {"ListReleaseCandidates", func() error { _, err := ledger.ListReleaseCandidates(ctx, actor, "rel"); return err }}, + {"UpdateReleaseCandidateState", func() error { + _, err := ledger.UpdateReleaseCandidateState(ctx, actor, "rc", "promoted", "reason") + return err + }}, + {"RegisterContainerImage", func() error { + _, err := ledger.RegisterContainerImage(ctx, actor, RegisterContainerImageInput{ArtifactID: "art", Repository: "repo", Digest: sampleDigest("image")}) + return err + }}, + {"CreateArtifactSignature", func() error { + _, err := ledger.CreateArtifactSignature(ctx, actor, CreateArtifactSignatureInput{ArtifactID: "art", Algorithm: "cosign", Signature: "sig"}) + return err + }}, + {"GetArtifactSignature", func() error { _, err := ledger.GetArtifactSignature(ctx, actor, "sig"); return err }}, + {"CreateSourceRepository", func() error { + _, err := ledger.CreateSourceRepository(ctx, actor, CreateRepositoryInput{ProjectID: "proj", Provider: "github", FullName: "owner/repo"}) + return err + }}, + {"ListSourceRepositories", func() error { _, err := ledger.ListSourceRepositories(ctx, actor, "proj"); return err }}, + {"RecordSourceCommit", func() error { + _, err := ledger.RecordSourceCommit(ctx, actor, RecordCommitInput{RepositoryID: "repo", SHA: "0123456789abcdef0123456789abcdef01234567"}) + return err + }}, + {"UpsertSourceBranch", func() error { + _, err := ledger.UpsertSourceBranch(ctx, actor, UpsertBranchInput{RepositoryID: "repo", Name: "main"}) + return err + }}, + {"RecordPullRequest", func() error { + _, err := ledger.RecordPullRequest(ctx, actor, RecordPullRequestInput{RepositoryID: "repo", ProviderID: "1", Title: "PR", State: "open"}) + return err + }}, + {"UploadGitHubSourceSnapshot", func() error { _, err := ledger.UploadGitHubSourceSnapshot(ctx, actor, []byte(`{}`)); return err }}, + {"UploadGitLabSourceSnapshot", func() error { _, err := ledger.UploadGitLabSourceSnapshot(ctx, actor, []byte(`{}`)); return err }}, + {"CreateDeploymentEnvironment", func() error { + _, err := ledger.CreateDeploymentEnvironment(ctx, actor, CreateEnvironmentInput{ProductID: "prod", Name: "prod", Kind: "production"}) + return err + }}, + {"ListDeploymentEnvironments", func() error { _, err := ledger.ListDeploymentEnvironments(ctx, actor, "prod"); return err }}, + {"RecordDeployment", func() error { + _, err := ledger.RecordDeployment(ctx, actor, RecordDeploymentInput{EnvironmentID: "env", ReleaseID: "rel", Status: "started"}) + return err + }}, + {"GetDeployment", func() error { _, err := ledger.GetDeployment(ctx, actor, "dep"); return err }}, + {"ListDeployments", func() error { _, err := ledger.ListDeployments(ctx, actor, "rel", "env"); return err }}, + {"UploadVEX", func() error { _, err := ledger.UploadVEX(ctx, actor, "rel", "art", []byte(`{}`)); return err }}, + {"GetVEXDocument", func() error { _, err := ledger.GetVEXDocument(ctx, actor, "vex"); return err }}, + {"CreateVulnerabilityDecision", func() error { + _, err := ledger.CreateVulnerabilityDecision(ctx, actor, "finding", CreateVulnerabilityDecisionInput{Status: "fixed", Justification: "fixed"}) + return err + }}, + {"CreateException", func() error { _, err := ledger.CreateException(ctx, actor, CreateExceptionInput{}); return err }}, + {"ListExceptions", func() error { _, err := ledger.ListExceptions(ctx, actor, "rel"); return err }}, + {"ApproveException", func() error { _, err := ledger.ApproveException(ctx, actor, "ex"); return err }}, + {"ReleaseReadinessReport", func() error { _, err := ledger.ReleaseReadinessReport(ctx, actor, "rel"); return err }}, + {"CreateControlFramework", func() error { + _, err := ledger.CreateControlFramework(ctx, actor, CreateControlFrameworkInput{Name: "Framework", Version: "1"}) + return err + }}, + {"ListControlFrameworks", func() error { _, err := ledger.ListControlFrameworks(ctx, actor); return err }}, + {"CreateSecurityControl", func() error { + _, err := ledger.CreateSecurityControl(ctx, actor, CreateSecurityControlInput{FrameworkID: "fw", Code: "C", Title: "Control", Objective: "Objective"}) + return err + }}, + {"GetSecurityControl", func() error { _, err := ledger.GetSecurityControl(ctx, actor, "ctrl"); return err }}, + {"LinkControlEvidence", func() error { + _, err := ledger.LinkControlEvidence(ctx, actor, "ctrl", LinkControlEvidenceInput{EvidenceType: "sbom", SubjectType: "sbom", SubjectID: "sbom"}) + return err + }}, + {"ListControlEvidence", func() error { _, err := ledger.ListControlEvidence(ctx, actor, "ctrl", "prod", "rel"); return err }}, + {"ControlCoverageReport", func() error { + _, err := ledger.ControlCoverageReport(ctx, actor, ControlCoverageReportInput{FrameworkID: "fw"}) + return err + }}, + {"CRAReadinessReport", func() error { + _, err := ledger.CRAReadinessReport(ctx, actor, CRAReadinessReportInput{ProductID: "prod", ReleaseID: "rel"}) + return err + }}, + {"CRAVulnerabilityHandlingReport", func() error { + _, err := ledger.CRAVulnerabilityHandlingReport(ctx, actor, "prod", "rel") + return err + }}, + {"SecurityUpdateEvidenceReport", func() error { + _, err := ledger.SecurityUpdateEvidenceReport(ctx, actor, "prod", "rel") + return err + }}, + {"CreateCollector", func() error { + _, _, _, err := ledger.CreateCollector(ctx, actor, CreateCollectorInput{Name: "collector", Type: "github_actions", Version: "1"}) + return err + }}, + {"ListCollectors", func() error { _, err := ledger.ListCollectors(ctx, actor); return err }}, + {"RecordCollectorRelease", func() error { + _, err := ledger.RecordCollectorRelease(ctx, actor, RecordCollectorReleaseInput{CollectorID: "collector", Version: "1", ArtifactDigest: sampleDigest("collector")}) + return err + }}, + {"CollectorHealthReport", func() error { _, err := ledger.CollectorHealthReport(ctx, actor, "collector"); return err }}, + {"CreateBuildRun", func() error { + _, err := ledger.CreateBuildRun(ctx, actor, CreateBuildRunInput{ProjectID: "proj", ReleaseID: "rel", Provider: "generic_ci", CommitSHA: "0123456789abcdef0123456789abcdef01234567", Status: "passed", StartedAt: fixedNow()}) + return err + }}, + {"GetBuildRun", func() error { _, err := ledger.GetBuildRun(ctx, actor, "build"); return err }}, + {"UploadBuildAttestation", func() error { _, err := ledger.UploadBuildAttestation(ctx, actor, "build", []byte(`{}`)); return err }}, + {"CreateWaiver", func() error { _, err := ledger.CreateWaiver(ctx, actor, CreateWaiverInput{}); return err }}, + {"ApproveWaiver", func() error { _, err := ledger.ApproveWaiver(ctx, actor, "waiver"); return err }}, + {"CreateApprovalRecord", func() error { _, err := ledger.CreateApprovalRecord(ctx, actor, CreateApprovalInput{}); return err }}, + {"CreateRedactionProfile", func() error { + _, err := ledger.CreateRedactionProfile(ctx, actor, CreateRedactionProfileInput{Name: "profile"}) + return err + }}, + {"CreateCustomerSecurityPackage", func() error { + _, err := ledger.CreateCustomerSecurityPackage(ctx, actor, CreateCustomerPackageInput{}) + return err + }}, + {"AccessCustomerSecurityPackage", func() error { _, err := ledger.AccessCustomerSecurityPackage(ctx, actor, "pkg"); return err }}, + {"ExportCustomerSecurityPackageArchive", func() error { _, err := ledger.ExportCustomerSecurityPackageArchive(ctx, actor, "pkg"); return err }}, + {"SecurityReviewPackageReport", func() error { _, err := ledger.SecurityReviewPackageReport(ctx, actor, "pkg"); return err }}, + {"CRAReadinessHTMLPackage", func() error { _, err := ledger.CRAReadinessHTMLPackage(ctx, actor, "prod", "rel"); return err }}, + {"ListControlFrameworkTemplatePacks", func() error { _, err := ledger.ListControlFrameworkTemplatePacks(ctx, actor); return err }}, + {"InstallControlFrameworkTemplatePack", func() error { + _, err := ledger.InstallControlFrameworkTemplatePack(ctx, actor, "evydence-cra-readiness") + return err + }}, + {"CreateCustomReportTemplate", func() error { + _, err := ledger.CreateCustomReportTemplate(ctx, actor, CreateReportTemplateInput{Name: "template", Version: "1", ReportType: "summary", Template: "json"}) + return err + }}, + {"RenderCustomReport", func() error { + _, err := ledger.RenderCustomReport(ctx, actor, RenderReportInput{TemplateID: "template", SubjectType: "release", SubjectID: "rel"}) + return err + }}, + {"ExportEvidenceBundle", func() error { _, err := ledger.ExportEvidenceBundle(ctx, actor, "rel", nil); return err }}, + {"ImportEvidenceBundle", func() error { _, err := ledger.ImportEvidenceBundle(ctx, actor, domain.EvidenceBundle{}); return err }}, + {"CreateDSSETrustRoot", func() error { + _, err := ledger.CreateDSSETrustRoot(ctx, actor, CreateDSSETrustRootInput{Name: "root", KeyID: "root", Algorithm: "Ed25519", PublicKey: "pub"}) + return err + }}, + {"VerifyDSSEAttestationSignature", func() error { _, err := ledger.VerifyDSSEAttestationSignature(ctx, actor, "att"); return err }}, + {"CreateOrganization", func() error { + _, err := ledger.CreateOrganization(ctx, actor, CreateOrganizationInput{Name: "Org", Slug: "org"}) + return err + }}, + {"CreateUser", func() error { + _, err := ledger.CreateUser(ctx, actor, CreateUserInput{OrganizationID: "org", Email: "user@example.test"}) + return err + }}, + {"DeactivateUser", func() error { _, err := ledger.DeactivateUser(ctx, actor, "user"); return err }}, + {"CreateRoleBinding", func() error { + _, err := ledger.CreateRoleBinding(ctx, actor, CreateRoleBindingInput{SubjectType: "user", SubjectID: "user", Role: "tenant_admin", ResourceType: "tenant"}) + return err + }}, + {"ListRoleBindings", func() error { _, err := ledger.ListRoleBindings(ctx, actor); return err }}, + {"CreateSSOProvider", func() error { + _, err := ledger.CreateSSOProvider(ctx, actor, CreateSSOProviderInput{Name: "OIDC", Type: "oidc", Issuer: "https://idp.example.test", ClientID: "client"}) + return err + }}, + {"LinkSSOIdentity", func() error { + _, err := ledger.LinkSSOIdentity(ctx, actor, LinkSSOIdentityInput{UserID: "user", ProviderID: "provider", Subject: "sub", Email: "user@example.test"}) + return err + }}, + {"CreateSSOSession", func() error { + _, _, err := ledger.CreateSSOSession(ctx, actor, CreateSSOSessionInput{UserID: "user", ProviderID: "provider", ExpiresAt: fixedNow().AddDate(0, 0, 1)}) + return err + }}, + {"RevokeSSOSession", func() error { _, err := ledger.RevokeSSOSession(ctx, actor, "session"); return err }}, + {"InstanceAdminSnapshot", func() error { _, err := ledger.InstanceAdminSnapshot(ctx, actor); return err }}, + {"CreateLegalHold", func() error { + _, err := ledger.CreateLegalHold(ctx, actor, CreateLegalHoldInput{ScopeType: "release", ScopeID: "rel", Reason: "review", Owner: "legal"}) + return err + }}, + {"CreateRetentionOverride", func() error { + _, err := ledger.CreateRetentionOverride(ctx, actor, CreateRetentionOverrideInput{ScopeType: "release", ScopeID: "rel", RetentionUntil: fixedNow().AddDate(1, 0, 0), Reason: "review", Owner: "legal"}) + return err + }}, + {"RetentionReport", func() error { _, err := ledger.RetentionReport(ctx, actor, "release", "rel"); return err }}, + {"CreateCustomerPortalAccess", func() error { + _, _, err := ledger.CreateCustomerPortalAccess(ctx, actor, CreateCustomerPortalAccessInput{PackageID: "pkg", CustomerName: "ACME", ExpiresAt: fixedNow().AddDate(0, 0, 1)}) + return err + }}, + {"ListCustomerPortalAccess", func() error { _, err := ledger.ListCustomerPortalAccess(ctx, actor, "pkg"); return err }}, + {"RevokeCustomerPortalAccess", func() error { _, err := ledger.RevokeCustomerPortalAccess(ctx, actor, "access"); return err }}, + {"CreateQuestionnaireTemplate", func() error { + _, err := ledger.CreateQuestionnaireTemplate(ctx, actor, CreateQuestionnaireTemplateInput{Name: "template", Version: "1"}) + return err + }}, + {"CreateQuestionnairePackage", func() error { + _, err := ledger.CreateQuestionnairePackage(ctx, actor, CreateQuestionnairePackageInput{TemplateID: "template", PackageID: "pkg", ProductID: "prod", ReleaseID: "rel"}) + return err + }}, + {"CreateQuestionnaireAnswerLibraryEntry", func() error { + _, err := ledger.CreateQuestionnaireAnswerLibraryEntry(ctx, actor, CreateQuestionnaireAnswerLibraryEntryInput{QuestionID: "q1", Answer: "answer"}) + return err + }}, + {"ListQuestionnaireAnswerLibrary", func() error { + _, err := ledger.ListQuestionnaireAnswerLibrary(ctx, actor, ListQuestionnaireAnswerLibraryInput{QuestionID: "q1"}) + return err + }}, + {"CreateCommercialCollectorDefinition", func() error { + _, err := ledger.CreateCommercialCollectorDefinition(ctx, actor, CreateCommercialCollectorInput{Name: "collector", Provider: "jira", Version: "1", ManifestHash: sampleDigest("collector")}) + return err + }}, + {"ListCommercialCollectorDefinitions", func() error { _, err := ledger.ListCommercialCollectorDefinitions(ctx, actor); return err }}, + {"CreateEvidenceSummary", func() error { + _, err := ledger.CreateEvidenceSummary(ctx, actor, CreateEvidenceSummaryInput{SubjectType: "release", SubjectID: "rel"}) + return err + }}, + {"CreateQuestionnaireDraft", func() error { + _, err := ledger.CreateQuestionnaireDraft(ctx, actor, CreateQuestionnaireDraftInput{TemplateID: "template", ProductID: "prod", ReleaseID: "rel"}) + return err + }}, + {"CreateGraphSnapshot", func() error { + _, err := ledger.CreateGraphSnapshot(ctx, actor, CreateGraphSnapshotInput{ProductID: "prod", ReleaseID: "rel"}) + return err + }}, + {"CreateSaaSEditionProfile", func() error { + _, err := ledger.CreateSaaSEditionProfile(ctx, actor, CreateSaaSEditionProfileInput{Name: "profile", Region: "eu", AdminTenantID: "ten", IsolationModel: "self-hosted"}) + return err + }}, + {"CreatePublicTransparencyLog", func() error { + _, err := ledger.CreatePublicTransparencyLog(ctx, actor, CreatePublicTransparencyLogInput{Name: "log", Endpoint: "https://log.example.test", PublicKey: "pub"}) + return err + }}, + {"PublishPublicTransparencyLogEntry", func() error { + _, err := ledger.PublishPublicTransparencyLogEntry(ctx, actor, PublishPublicTransparencyLogEntryInput{LogID: "log", CheckpointID: "checkpoint", ExternalID: "entry"}) + return err + }}, + {"CreateMarketplaceCollector", func() error { + _, err := ledger.CreateMarketplaceCollector(ctx, actor, CreateMarketplaceCollectorInput{Name: "collector", Provider: "scanner", Version: "1", Publisher: "vendor", ManifestHash: sampleDigest("collector")}) + return err + }}, + {"ListMarketplaceCollectors", func() error { _, err := ledger.ListMarketplaceCollectors(ctx, actor); return err }}, + {"MarketplaceCollectorHealth", func() error { _, err := ledger.MarketplaceCollectorHealth(ctx, actor, "collector"); return err }}, + {"CreatePDFReportPackage", func() error { + _, err := ledger.CreatePDFReportPackage(ctx, actor, CreatePDFReportPackageInput{ReportType: "release_readiness", ProductID: "prod", ReleaseID: "rel", Title: "Report"}) + return err + }}, + {"GenerateAnomalyReport", func() error { + _, err := ledger.GenerateAnomalyReport(ctx, actor, AnomalyReportInput{SubjectType: "release", SubjectID: "rel"}) + return err + }}, + {"CreateSigningOperation", func() error { + _, err := ledger.CreateSigningOperation(ctx, actor, CreateSigningOperationInput{ProviderID: "provider", SubjectType: "release", SubjectID: "rel", PayloadHash: sampleDigest("payload"), ExternalSignature: "sig"}) + return err + }}, + {"VerifyProviderIdentity", func() error { + _, err := ledger.VerifyProviderIdentity(ctx, actor, VerifyProviderIdentityInput{ProviderType: "oidc", ProviderID: "provider", Subject: "sub"}) + return err + }}, + {"VerifyCosignSignature", func() error { + _, err := ledger.VerifyCosignSignature(ctx, actor, VerifyCosignInput{ArtifactSignatureID: "sig"}) + return err + }}, + {"RevokeSigningKey", func() error { _, err := ledger.RevokeSigningKey(ctx, actor, "key", "reason"); return err }}, + {"CreateSigningProvider", func() error { + _, err := ledger.CreateSigningProvider(ctx, actor, CreateSigningProviderInput{Name: "kms", Type: "aws_kms", KeyRef: "arn:aws:kms:example", Encrypted: true}) + return err + }}, + {"CreateMerkleBatch", func() error { + _, err := ledger.CreateMerkleBatch(ctx, actor, CreateMerkleBatchInput{FromSequence: 1, ToSequence: 1}) + return err + }}, + {"VerifyMerkleBatch", func() error { _, err := ledger.VerifyMerkleBatch(ctx, actor, "batch"); return err }}, + {"CreateTransparencyCheckpoint", func() error { + _, err := ledger.CreateTransparencyCheckpoint(ctx, actor, CreateTransparencyCheckpointInput{BatchID: "batch", Provider: "internal", ExternalID: "checkpoint"}) + return err + }}, + {"CreateObjectRetentionPolicy", func() error { + _, err := ledger.CreateObjectRetentionPolicy(ctx, actor, CreateObjectRetentionPolicyInput{Name: "policy", ObjectPrefix: "tenants/ten/", Mode: "governance", RetentionDays: 30}) + return err + }}, + {"VerifyObjectRetentionPolicy", func() error { _, err := ledger.VerifyObjectRetentionPolicy(ctx, actor, "policy"); return err }}, + {"SigningCustodyReviewReport", func() error { _, err := ledger.SigningCustodyReviewReport(ctx, actor); return err }}, + {"GenerateBackupManifest", func() error { _, err := ledger.GenerateBackupManifest(ctx, actor); return err }}, + {"VerifyBackupManifest", func() error { _, err := ledger.VerifyBackupManifest(ctx, actor, "backup"); return err }}, + {"Metrics", func() error { _, err := ledger.Metrics(ctx, actor); return err }}, + {"ListAuditLog", func() error { _, err := ledger.ListAuditLog(ctx, actor, AuditLogFilter{}); return err }}, + } + + for _, check := range checks { + t.Run(check.name, func(t *testing.T) { + if err := check.run(); !errors.Is(err, ErrForbidden) { + t.Fatalf("err=%v, want ErrForbidden", err) + } + }) + } +} diff --git a/internal/app/controls.go b/internal/app/controls.go index 1b1208a..5b7ad69 100644 --- a/internal/app/controls.go +++ b/internal/app/controls.go @@ -219,6 +219,10 @@ func (l *Ledger) LinkControlEvidence(ctx context.Context, actor domain.Actor, co if err := l.ensureScopeLocked(actor.TenantID, in.ProductID, "", in.ReleaseID); err != nil { return domain.ControlEvidence{}, err } + refs := l.refsForControlEvidenceSubjectLocked(in.SubjectType, in.SubjectID, in.ProductID, in.ReleaseID) + if err := l.authorizeResourceLocked(actor, ScopeControlsWrite, refs); err != nil { + return domain.ControlEvidence{}, err + } if !l.controlSubjectExistsLocked(actor.TenantID, in.SubjectType, in.SubjectID, in.ProductID, in.ReleaseID) { return domain.ControlEvidence{}, ErrNotFound } @@ -272,13 +276,17 @@ func (l *Ledger) ListControlEvidence(ctx context.Context, actor domain.Actor, co if releaseID != "" && link.ReleaseID != releaseID { continue } + if !l.resourceAllowedLocked(actor, ScopeControlsRead, l.refsForControlEvidenceSubjectLocked(link.SubjectType, link.SubjectID, link.ProductID, link.ReleaseID)) { + continue + } out = append(out, link) } sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) return out, nil } -func (l *Ledger) ControlCoverageReport(ctx context.Context, actor domain.Actor, in ControlCoverageReportInput) (domain.ControlCoverageReport, error) { +func (s packageReportService) ControlCoverageReport(ctx context.Context, actor domain.Actor, in ControlCoverageReportInput) (domain.ControlCoverageReport, error) { + l := s.ledger if err := ctx.Err(); err != nil { return domain.ControlCoverageReport{}, err } @@ -291,10 +299,14 @@ func (l *Ledger) ControlCoverageReport(ctx context.Context, actor domain.Actor, if err != nil { return domain.ControlCoverageReport{}, err } + if err := l.authorizeResourceLocked(actor, ScopeReportRead, resourceRefs{ProductID: report.ProductID, ReleaseID: report.ReleaseID}); err != nil { + return domain.ControlCoverageReport{}, err + } return report, nil } -func (l *Ledger) CRAReadinessReport(ctx context.Context, actor domain.Actor, in CRAReadinessReportInput) (domain.CRAReadinessReport, error) { +func (s packageReportService) CRAReadinessReport(ctx context.Context, actor domain.Actor, in CRAReadinessReportInput) (domain.CRAReadinessReport, error) { + l := s.ledger if err := ctx.Err(); err != nil { return domain.CRAReadinessReport{}, err } @@ -309,6 +321,9 @@ func (l *Ledger) CRAReadinessReport(ctx context.Context, actor domain.Actor, in if err := l.ensureScopeLocked(actor.TenantID, in.ProductID, "", in.ReleaseID); err != nil { return domain.CRAReadinessReport{}, err } + if err := l.authorizeResourceLocked(actor, ScopeReportRead, resourceRefs{ProductID: in.ProductID, ReleaseID: in.ReleaseID}); err != nil { + return domain.CRAReadinessReport{}, err + } frameworkID := l.firstFrameworkIDLocked(actor.TenantID) coverage, err := l.controlCoverageReportLocked(actor.TenantID, ControlCoverageReportInput{FrameworkID: frameworkID, ProductID: in.ProductID, ReleaseID: in.ReleaseID}) if err != nil { @@ -329,6 +344,175 @@ func (l *Ledger) CRAReadinessReport(ctx context.Context, actor domain.Actor, in }, nil } +func (s packageReportService) CRAVulnerabilityHandlingReport(ctx context.Context, actor domain.Actor, productID, releaseID string) (domain.CRAVulnerabilityHandlingReport, error) { + l := s.ledger + if err := ctx.Err(); err != nil { + return domain.CRAVulnerabilityHandlingReport{}, err + } + if err := require(actor, ScopeReportRead); err != nil { + return domain.CRAVulnerabilityHandlingReport{}, err + } + productID, releaseID = strings.TrimSpace(productID), strings.TrimSpace(releaseID) + if productID == "" || releaseID == "" { + return domain.CRAVulnerabilityHandlingReport{}, ErrValidation + } + l.mu.Lock() + defer l.mu.Unlock() + if err := l.ensureScopeLocked(actor.TenantID, productID, "", releaseID); err != nil { + return domain.CRAVulnerabilityHandlingReport{}, err + } + if err := l.authorizeResourceLocked(actor, ScopeReportRead, resourceRefs{ProductID: productID, ReleaseID: releaseID}); err != nil { + return domain.CRAVulnerabilityHandlingReport{}, err + } + summary := map[string]int{ + "findings_total": 0, + "open_critical_total": 0, + "decisions_total": 0, + "approved_exceptions_total": 0, + } + evidenceIDs := []string{} + for _, scan := range l.scans { + if scan.TenantID != actor.TenantID || scan.ReleaseID != releaseID { + continue + } + evidenceIDs = append(evidenceIDs, scan.EvidenceID) + summary["findings_total"] += len(scan.Findings) + for _, finding := range scan.Findings { + if strings.EqualFold(finding.Severity, "critical") && strings.EqualFold(finding.State, "open") { + summary["open_critical_total"]++ + } + } + } + decisions := []domain.VulnerabilityDecisionCustomerSummary{} + for _, decision := range l.decisions { + if decision.TenantID != actor.TenantID || decision.ReleaseID != releaseID || decision.SupersededBy != "" { + continue + } + decisions = append(decisions, customerDecisionSummary(decision)) + evidenceIDs = append(evidenceIDs, decisionEvidenceIDs(decision.EvidenceID, decision.EvidenceIDs)...) + if decision.VEXDocumentID != "" { + if vex, ok := l.vexDocuments[decision.VEXDocumentID]; ok && vex.TenantID == actor.TenantID { + evidenceIDs = append(evidenceIDs, vex.EvidenceID) + } + } + } + sort.Slice(decisions, func(i, j int) bool { return decisions[i].ID < decisions[j].ID }) + summary["decisions_total"] = len(decisions) + exceptions := []domain.Exception{} + now := l.now() + for _, exception := range l.exceptions { + if exception.TenantID == actor.TenantID && exception.ReleaseID == releaseID && exception.Approved && exception.ExpiresAt.After(now) { + exceptions = append(exceptions, exception) + } + } + sort.Slice(exceptions, func(i, j int) bool { return exceptions[i].ID < exceptions[j].ID }) + summary["approved_exceptions_total"] = len(exceptions) + evidenceIDs = sortedUniqueNonEmptyStrings(evidenceIDs) + summary["evidence_total"] = len(evidenceIDs) + return domain.CRAVulnerabilityHandlingReport{ + ReportType: "cra_vulnerability_handling", + TemplateVersion: "cra-vulnerability-handling.v1.0.0", + ProductID: productID, + ReleaseID: releaseID, + Summary: summary, + Decisions: decisions, + AcceptedExceptions: exceptions, + EvidenceIDs: evidenceIDs, + Assumptions: []string{"This report summarizes vulnerability handling records for CRA readiness review using evidence stored in this tenant."}, + Limitations: []string{"Report contents do not prove legal compliance, certification, complete vulnerability detection, scanner authority, or release security status."}, + GeneratedAt: l.now(), + }, nil +} + +func (s packageReportService) SecurityUpdateEvidenceReport(ctx context.Context, actor domain.Actor, productID, releaseID string) (domain.SecurityUpdateEvidenceReport, error) { + l := s.ledger + if err := ctx.Err(); err != nil { + return domain.SecurityUpdateEvidenceReport{}, err + } + if err := require(actor, ScopeReportRead); err != nil { + return domain.SecurityUpdateEvidenceReport{}, err + } + productID, releaseID = strings.TrimSpace(productID), strings.TrimSpace(releaseID) + if productID == "" || releaseID == "" { + return domain.SecurityUpdateEvidenceReport{}, ErrValidation + } + l.mu.Lock() + defer l.mu.Unlock() + if err := l.ensureScopeLocked(actor.TenantID, productID, "", releaseID); err != nil { + return domain.SecurityUpdateEvidenceReport{}, err + } + if err := l.authorizeResourceLocked(actor, ScopeReportRead, resourceRefs{ProductID: productID, ReleaseID: releaseID}); err != nil { + return domain.SecurityUpdateEvidenceReport{}, err + } + evidenceIDs := []string{} + for _, scan := range l.scans { + if scan.TenantID == actor.TenantID && scan.ReleaseID == releaseID { + evidenceIDs = append(evidenceIDs, scan.EvidenceID) + } + } + fixedDecisions := []domain.VulnerabilityDecisionCustomerSummary{} + for _, decision := range l.decisions { + if decision.TenantID != actor.TenantID || decision.ReleaseID != releaseID || decision.SupersededBy != "" || decision.Status != decisionStatusFixed { + continue + } + fixedDecisions = append(fixedDecisions, customerDecisionSummary(decision)) + evidenceIDs = append(evidenceIDs, decisionEvidenceIDs(decision.EvidenceID, decision.EvidenceIDs)...) + if decision.VEXDocumentID != "" { + if vex, ok := l.vexDocuments[decision.VEXDocumentID]; ok && vex.TenantID == actor.TenantID { + evidenceIDs = append(evidenceIDs, vex.EvidenceID) + } + } + } + sort.Slice(fixedDecisions, func(i, j int) bool { return fixedDecisions[i].ID < fixedDecisions[j].ID }) + incidents := []domain.Incident{} + for _, incident := range l.incidents { + if incident.TenantID == actor.TenantID && incident.ProductID == productID && incident.ReleaseID == releaseID { + incidents = append(incidents, incident) + } + } + sort.Slice(incidents, func(i, j int) bool { return incidents[i].ID < incidents[j].ID }) + tasks := []domain.RemediationTask{} + for _, task := range l.tasks { + if task.TenantID != actor.TenantID { + continue + } + if task.ReleaseID == releaseID { + tasks = append(tasks, task) + evidenceIDs = append(evidenceIDs, task.EvidenceID) + continue + } + if task.IncidentID != "" { + if incident, ok := l.incidents[task.IncidentID]; ok && incident.TenantID == actor.TenantID && incident.ReleaseID == releaseID { + tasks = append(tasks, task) + evidenceIDs = append(evidenceIDs, task.EvidenceID) + } + } + } + sort.Slice(tasks, func(i, j int) bool { return tasks[i].ID < tasks[j].ID }) + evidenceIDs = sortedUniqueNonEmptyStrings(evidenceIDs) + summary := map[string]int{ + "fixed_decisions_total": len(fixedDecisions), + "incidents_total": len(incidents), + "remediation_tasks_total": len(tasks), + "linked_evidence_total": len(evidenceIDs), + "security_update_subjects": len(fixedDecisions) + len(incidents) + len(tasks), + } + return domain.SecurityUpdateEvidenceReport{ + ReportType: "security_update_evidence", + TemplateVersion: "security-update-evidence.v1.0.0", + ProductID: productID, + ReleaseID: releaseID, + Summary: summary, + FixedDecisions: fixedDecisions, + Incidents: incidents, + RemediationTasks: tasks, + EvidenceIDs: evidenceIDs, + Assumptions: []string{"This report summarizes recorded release evidence that may support security update review."}, + Limitations: []string{"Security update evidence is scoped to records in this Evydence tenant and does not prove legal sufficiency, customer notification completeness, or release security status."}, + GeneratedAt: l.now(), + }, nil +} + func (l *Ledger) controlCoverageReportLocked(tenantID string, in ControlCoverageReportInput) (domain.ControlCoverageReport, error) { if strings.TrimSpace(in.ProductID) != "" || strings.TrimSpace(in.ReleaseID) != "" { if err := l.ensureScopeLocked(tenantID, in.ProductID, "", in.ReleaseID); err != nil { @@ -490,6 +674,50 @@ func (l *Ledger) controlSubjectExistsLocked(tenantID, subjectType, subjectID, pr } } +func (l *Ledger) refsForControlEvidenceSubjectLocked(subjectType, subjectID, productID, releaseID string) resourceRefs { + refs := resourceRefs{ProductID: strings.TrimSpace(productID), ReleaseID: strings.TrimSpace(releaseID)} + switch strings.TrimSpace(subjectType) { + case "evidence", "evidence_item": + if item, ok := l.evidence[strings.TrimSpace(subjectID)]; ok { + refs.ProductID = nonEmpty(refs.ProductID, item.ProductID) + refs.ProjectID = item.ProjectID + refs.ReleaseID = nonEmpty(refs.ReleaseID, item.ReleaseID) + } + case "release": + refs.ReleaseID = nonEmpty(refs.ReleaseID, strings.TrimSpace(subjectID)) + case "artifact": + refs.ArtifactID = strings.TrimSpace(subjectID) + case "build": + refs.BuildID = strings.TrimSpace(subjectID) + case "build_attestation": + if attestation, ok := l.attestations[strings.TrimSpace(subjectID)]; ok { + refs.BuildID = attestation.BuildID + } + case "security_scan": + refs.SecurityScanID = strings.TrimSpace(subjectID) + case "vulnerability_scan": + if scan, ok := l.scans[strings.TrimSpace(subjectID)]; ok { + refs.ReleaseID = nonEmpty(refs.ReleaseID, scan.ReleaseID) + } + case "incident": + refs.IncidentID = strings.TrimSpace(subjectID) + case "deployment": + refs.DeploymentID = strings.TrimSpace(subjectID) + case "openapi_contract": + if contract, ok := l.contracts[strings.TrimSpace(subjectID)]; ok { + refs.ProductID = nonEmpty(refs.ProductID, contract.ProductID) + refs.ReleaseID = nonEmpty(refs.ReleaseID, contract.ReleaseID) + } + case "release_bundle": + if bundle, ok := l.bundles[strings.TrimSpace(subjectID)]; ok { + refs.ReleaseID = nonEmpty(refs.ReleaseID, bundle.ReleaseID) + } + case "customer_package": + refs.CustomerPackageID = strings.TrimSpace(subjectID) + } + return refs +} + func (l *Ledger) controlEvidenceTimeLocked(link domain.ControlEvidence) time.Time { switch link.SubjectType { case "evidence", "evidence_item": diff --git a/internal/app/controls_test.go b/internal/app/controls_test.go index 7378633..c74d752 100644 --- a/internal/app/controls_test.go +++ b/internal/app/controls_test.go @@ -96,6 +96,92 @@ func TestControlFrameworkControlEvidenceAndCoverageFlow(t *testing.T) { } } +func TestCRATemplateReportsForVulnerabilityHandlingAndSecurityUpdates(t *testing.T) { + ledger := NewLedger(Config{APIKeyPepper: "test-pepper", Now: fixedNow}) + ctx := context.Background() + actor, release, _ := setupReleaseRiskFixture(t, ledger) + scan, err := ledger.UploadVulnerabilityScan(ctx, actor, []byte(`{ + "scanner":"grype", + "target_ref":"pkg:oci/payments-api", + "release_id":"`+release.ID+`", + "findings":[{"vulnerability":"CVE-2026-2020","component":"pkg:apk/openssl@3.1.0","severity":"critical","state":"open"}] + }`)) + if err != nil { + t.Fatalf("scan: %v", err) + } + decision, err := ledger.CreateVulnerabilityDecision(ctx, actor, scan.Findings[0].ID, CreateVulnerabilityDecisionInput{ + Status: decisionStatusFixed, + Justification: "patched in the release build", + ImpactStatement: "The release contains the patched component version.", + ActionStatement: "Customers should deploy this release when they next update.", + CustomerVisible: true, + InternalNotes: "private triage details must not appear in CRA reports", + }) + if err != nil { + t.Fatalf("decision: %v", err) + } + exception, err := ledger.CreateException(ctx, actor, CreateExceptionInput{ReleaseID: release.ID, FindingID: scan.Findings[0].ID, Reason: "temporary customer rollout window", Owner: "security", ExpiresAt: fixedNow().Add(48 * time.Hour)}) + if err != nil { + t.Fatalf("exception: %v", err) + } + if _, err := ledger.ApproveException(ctx, actor, exception.ID); err != nil { + t.Fatalf("approve exception: %v", err) + } + supportingEvidence, err := ledger.CreateEvidence(ctx, actor, CreateEvidenceInput{ProductID: release.ProductID, ReleaseID: release.ID, Type: "security_review", Title: "Security update review", PayloadHash: sampleDigest("security-update")}) + if err != nil { + t.Fatalf("supporting evidence: %v", err) + } + incident, err := ledger.CreateIncident(ctx, actor, CreateIncidentInput{ProductID: release.ProductID, ReleaseID: release.ID, Title: "Security update coordination", Severity: "medium", OpenedAt: fixedNow()}) + if err != nil { + t.Fatalf("incident: %v", err) + } + task, err := ledger.CreateRemediationTask(ctx, actor, CreateRemediationTaskInput{IncidentID: incident.ID, ReleaseID: release.ID, Title: "Publish security update note", Owner: "security", EvidenceID: supportingEvidence.ID}) + if err != nil { + t.Fatalf("remediation task: %v", err) + } + + handling, err := ledger.CRAVulnerabilityHandlingReport(ctx, actor, release.ProductID, release.ID) + if err != nil { + t.Fatalf("CRA vulnerability handling report: %v", err) + } + if handling.ReportType != "cra_vulnerability_handling" || handling.Summary["findings_total"] != 1 || handling.Summary["decisions_total"] != 1 || handling.Summary["approved_exceptions_total"] != 1 { + t.Fatalf("unexpected vulnerability handling report: %#v", handling) + } + if len(handling.Decisions) != 1 || handling.Decisions[0].ID != decision.ID || len(handling.AcceptedExceptions) != 1 { + t.Fatalf("handling decisions/exceptions = %#v", handling) + } + if !stringSliceContains(handling.EvidenceIDs, scan.EvidenceID) { + t.Fatalf("handling evidence ids missing scan evidence %s: %#v", scan.EvidenceID, handling.EvidenceIDs) + } + updateReport, err := ledger.SecurityUpdateEvidenceReport(ctx, actor, release.ProductID, release.ID) + if err != nil { + t.Fatalf("security update evidence report: %v", err) + } + if updateReport.ReportType != "security_update_evidence" || updateReport.Summary["incidents_total"] != 1 || updateReport.Summary["remediation_tasks_total"] != 1 || updateReport.Summary["fixed_decisions_total"] != 1 { + t.Fatalf("unexpected security update report: %#v", updateReport) + } + if len(updateReport.Incidents) != 1 || updateReport.Incidents[0].ID != incident.ID || len(updateReport.RemediationTasks) != 1 || updateReport.RemediationTasks[0].ID != task.ID { + t.Fatalf("security update incident/task details = %#v", updateReport) + } + if !stringSliceContains(updateReport.EvidenceIDs, supportingEvidence.ID) || !stringSliceContains(updateReport.EvidenceIDs, scan.EvidenceID) { + t.Fatalf("security update evidence ids missing expected ids: %#v", updateReport.EvidenceIDs) + } + reportText := strings.ToLower(strings.Join(append(append(handling.Assumptions, handling.Limitations...), append(updateReport.Assumptions, updateReport.Limitations...)...), " ")) + for _, forbidden := range []string{"private triage details", "legally sufficient", "certified secure", "automatically compliant"} { + if strings.Contains(reportText, forbidden) { + t.Fatalf("CRA report text leaked forbidden phrase %q: handling=%#v update=%#v", forbidden, handling, updateReport) + } + } + + actorB, _, _ := setupReleaseRiskFixture(t, ledger) + if _, err := ledger.CRAVulnerabilityHandlingReport(ctx, actorB, release.ProductID, release.ID); !errors.Is(err, ErrNotFound) { + t.Fatalf("cross-tenant CRA vulnerability handling err=%v, want not found", err) + } + if _, err := ledger.SecurityUpdateEvidenceReport(ctx, actorB, release.ProductID, release.ID); !errors.Is(err, ErrNotFound) { + t.Fatalf("cross-tenant security update err=%v, want not found", err) + } +} + func TestControlValidationScopeAndWaivedCoverage(t *testing.T) { ledger := NewLedger(Config{APIKeyPepper: "test-pepper", Now: fixedNow}) ctx := context.Background() diff --git a/internal/app/coverage_gaps_test.go b/internal/app/coverage_gaps_test.go new file mode 100644 index 0000000..9b88995 --- /dev/null +++ b/internal/app/coverage_gaps_test.go @@ -0,0 +1,224 @@ +package app + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/aatuh/evydence/internal/domain" +) + +func TestAppReadListLifecycleAndReportGaps(t *testing.T) { + ledger := NewLedger(Config{APIKeyPepper: "test-pepper", Now: fixedNow}) + ctx := context.Background() + actor, release, artifact := setupReleaseRiskFixture(t, ledger) + + if !ledger.HasTenants() { + t.Fatal("bootstrap fixture should create a tenant") + } + if _, err := ledger.ApproveRelease(ctx, actor, release.ID); !errors.Is(err, ErrConflict) { + t.Fatalf("approve draft release err=%v, want conflict", err) + } + frozen, err := ledger.FreezeRelease(ctx, actor, release.ID) + if err != nil { + t.Fatalf("freeze release: %v", err) + } + if frozen.State != "frozen" || frozen.FrozenAt == nil { + t.Fatalf("frozen release = %#v", frozen) + } + approved, err := ledger.ApproveRelease(ctx, actor, release.ID) + if err != nil { + t.Fatalf("approve release: %v", err) + } + if approved.State != "approved" || approved.ApprovedAt == nil { + t.Fatalf("approved release = %#v", approved) + } + if _, err := ledger.FreezeRelease(ctx, actor, release.ID); !errors.Is(err, ErrConflict) { + t.Fatalf("freeze approved release err=%v, want conflict", err) + } + + original, err := ledger.CreateEvidence(ctx, actor, CreateEvidenceInput{ProductID: release.ProductID, ReleaseID: release.ID, Type: "security_review", Title: "Original", PayloadHash: sampleDigest("original")}) + if err != nil { + t.Fatalf("original evidence: %v", err) + } + replacement, err := ledger.CreateEvidence(ctx, actor, CreateEvidenceInput{ProductID: release.ProductID, Type: "security_review", Title: "Replacement", PayloadHash: sampleDigest("replacement")}) + if err != nil { + t.Fatalf("replacement evidence: %v", err) + } + linked, err := ledger.LinkEvidence(ctx, actor, replacement.ID, "release", release.ID) + if err != nil { + t.Fatalf("link evidence: %v", err) + } + if linked.ReleaseID != release.ID { + t.Fatalf("linked release_id=%q want %q", linked.ReleaseID, release.ID) + } + superseded, err := ledger.SupersedeEvidence(ctx, actor, original.ID, replacement.ID, "newer review") + if err != nil { + t.Fatalf("supersede evidence: %v", err) + } + if superseded.SupersededBy != replacement.ID { + t.Fatalf("superseded evidence = %#v", superseded) + } + if _, err := ledger.SupersedeEvidence(ctx, actor, original.ID, replacement.ID, "again"); !errors.Is(err, ErrConflict) { + t.Fatalf("repeat supersede err=%v, want conflict", err) + } + listed, err := ledger.ListEvidence(ctx, actor, release.ID, "security_review") + if err != nil { + t.Fatalf("list evidence: %v", err) + } + if len(listed) != 2 { + t.Fatalf("security_review evidence count=%d want 2: %#v", len(listed), listed) + } + + sbom, err := ledger.UploadSBOM(ctx, actor, release.ID, artifact.ID, []byte(`{"bomFormat":"CycloneDX","specVersion":"1.6","components":[{"name":"api","purl":"pkg:oci/api"}]}`)) + if err != nil { + t.Fatalf("sbom: %v", err) + } + if got, err := ledger.GetSBOM(ctx, actor, sbom.ID); err != nil || got.ID != sbom.ID { + t.Fatalf("get sbom=%#v err=%v", got, err) + } + scan, err := ledger.UploadVulnerabilityScan(ctx, actor, []byte(`{"scanner":"grype","target_ref":"pkg:oci/api","release_id":"`+release.ID+`","findings":[]}`)) + if err != nil { + t.Fatalf("scan: %v", err) + } + if got, err := ledger.GetVulnerabilityScan(ctx, actor, scan.ID); err != nil || got.ID != scan.ID { + t.Fatalf("get scan=%#v err=%v", got, err) + } + contract, err := ledger.UploadOpenAPIContract(ctx, actor, release.ProductID, release.ID, "v1", []byte(`{"openapi":"3.1.0","info":{"title":"API","version":"1"},"paths":{"/health":{"get":{"responses":{"200":{"description":"ok"}}}}}}`)) + if err != nil { + t.Fatalf("openapi: %v", err) + } + if got, err := ledger.GetOpenAPIContract(ctx, actor, contract.ID); err != nil || got.ID != contract.ID { + t.Fatalf("get contract=%#v err=%v", got, err) + } + vex, err := ledger.UploadVEX(ctx, actor, release.ID, artifact.ID, []byte(`{"@context":"https://openvex.dev/ns/v0.2.0","@id":"https://example.test/vex","author":"security@example.test","timestamp":"2026-05-28T12:00:00Z","version":1,"statements":[{"vulnerability":{"name":"CVE-2026-0000"},"products":[{"@id":"pkg:oci/api"}],"status":"fixed","justification":"fixed","impact_statement":"fixed","action_statement":"none"}]}`)) + if err != nil { + t.Fatalf("vex: %v", err) + } + if got, err := ledger.GetVEXDocument(ctx, actor, vex.ID); err != nil || got.ID != vex.ID { + t.Fatalf("get vex=%#v err=%v", got, err) + } + + bundle, err := ledger.CreateReleaseBundle(ctx, actor, release.ID) + if err != nil { + t.Fatalf("bundle: %v", err) + } + if got, err := ledger.GetReleaseBundle(ctx, actor, bundle.ID); err != nil || got.ID != bundle.ID { + t.Fatalf("get bundle=%#v err=%v", got, err) + } + report, err := ledger.MissingEvidenceReport(ctx, actor, release.ID) + if err != nil { + t.Fatalf("missing evidence report: %v", err) + } + if report["report_type"] != "missing_evidence" || report["release_id"] != release.ID { + t.Fatalf("missing evidence report = %#v", report) + } + + keysBefore, err := ledger.ListSigningKeys(ctx, actor) + if err != nil || len(keysBefore) == 0 { + t.Fatalf("signing keys before=%#v err=%v", keysBefore, err) + } + rotated, err := ledger.RotateSigningKey(ctx, actor, "scheduled rotation") + if err != nil { + t.Fatalf("rotate signing key: %v", err) + } + if rotated.Private != nil || rotated.Status != "active" { + t.Fatalf("public rotated key leaked private material: %#v", rotated) + } + revoked, err := ledger.RevokeSigningKey(ctx, actor, keysBefore[0].ID, "retire old key") + if err != nil { + t.Fatalf("revoke signing key: %v", err) + } + if revoked.Status != "revoked" || revoked.RevokedAt == nil { + t.Fatalf("revoked key = %#v", revoked) + } +} + +func TestAppListAndScopeHelperGaps(t *testing.T) { + ledger := NewLedger(Config{APIKeyPepper: "test-pepper", Now: fixedNow}) + ctx := context.Background() + actor, release, artifact := setupReleaseRiskFixture(t, ledger) + + collector, _, _, err := ledger.CreateCollector(ctx, actor, CreateCollectorInput{Name: "gha", Type: "github_actions", Version: "1.0.0"}) + if err != nil { + t.Fatalf("collector: %v", err) + } + collectors, err := ledger.ListCollectors(ctx, actor) + if err != nil || len(collectors) != 1 || collectors[0].ID != collector.ID { + t.Fatalf("collectors=%#v err=%v", collectors, err) + } + + framework, err := ledger.CreateControlFramework(ctx, actor, CreateControlFrameworkInput{Name: "Framework", Slug: "fw", Version: "1"}) + if err != nil { + t.Fatalf("framework: %v", err) + } + listedFrameworks, err := ledger.ListControlFrameworks(ctx, actor) + if err != nil || len(listedFrameworks) != 1 || listedFrameworks[0].ID != framework.ID { + t.Fatalf("frameworks=%#v err=%v", listedFrameworks, err) + } + control, err := ledger.CreateSecurityControl(ctx, actor, CreateSecurityControlInput{ + FrameworkID: framework.ID, + Code: "BUILD", + Title: "Build evidence", + Objective: "Build evidence exists.", + EvidenceRequirements: []domain.ControlEvidenceRequirement{{ + Type: "build", + Required: true, + }}, + }) + if err != nil { + t.Fatalf("control: %v", err) + } + buildProject, err := ledger.CreateProject(ctx, actor, release.ProductID, "control-build") + if err != nil { + t.Fatalf("project: %v", err) + } + build, err := ledger.CreateBuildRun(ctx, actor, CreateBuildRunInput{ + ProjectID: buildProject.ID, + ReleaseID: release.ID, + Provider: "generic_ci", + CommitSHA: "0123456789abcdef0123456789abcdef01234567", + Status: "passed", + StartedAt: fixedNow(), + Outputs: []domain.BuildOutput{{ArtifactID: artifact.ID, Digest: artifact.Digest}}, + }) + if err != nil { + t.Fatalf("build: %v", err) + } + link, err := ledger.LinkControlEvidence(ctx, actor, control.ID, LinkControlEvidenceInput{EvidenceType: "build", SubjectType: "build", SubjectID: build.ID, ProductID: release.ProductID, ReleaseID: release.ID, Confidence: "medium"}) + if err != nil { + t.Fatalf("link control evidence: %v", err) + } + links, err := ledger.ListControlEvidence(ctx, actor, control.ID, release.ProductID, release.ID) + if err != nil || len(links) != 1 || links[0].ID != link.ID { + t.Fatalf("control links=%#v err=%v", links, err) + } + if _, err := ledger.LinkControlEvidence(ctx, actor, control.ID, LinkControlEvidenceInput{EvidenceType: "build", SubjectType: "missing", SubjectID: "missing", ProductID: release.ProductID, ReleaseID: release.ID, Confidence: "unsupported"}); !errors.Is(err, ErrNotFound) { + t.Fatalf("missing subject err=%v, want not found", err) + } + + ex, err := ledger.CreateException(ctx, actor, CreateExceptionInput{ReleaseID: release.ID, ControlID: control.ID, Reason: "temporary control waiver", Owner: "security", ExpiresAt: fixedNow().Add(time.Hour)}) + if err != nil { + t.Fatalf("exception: %v", err) + } + exceptions, err := ledger.ListExceptions(ctx, actor, release.ID) + if err != nil || len(exceptions) != 1 || exceptions[0].ID != ex.ID { + t.Fatalf("exceptions=%#v err=%v", exceptions, err) + } + + if !(domain.Actor{Scopes: []string{"*"}}).HasScope("anything") { + t.Fatal("wildcard actor should satisfy arbitrary scope") + } + if (domain.Actor{Scopes: []string{"evidence:read"}}).HasScope("evidence:write") { + t.Fatal("read-only actor should not satisfy write scope") + } + for _, err := range []error{ErrValidation, ErrUnauthorized, ErrForbidden, ErrNotFound, ErrConflict, ErrIdempotencyConflict, ErrVerificationFailed, errors.New("raw sql detail")} { + if ProblemCode(err) == "" || StatusCode(err) == 0 || SafeErrorDetail(err) == "" { + t.Fatalf("problem mapping incomplete for %v", err) + } + } + if !IsValidation(ErrValidation) || IsValidation(ErrForbidden) { + t.Fatal("validation helper returned unexpected result") + } +} diff --git a/internal/app/coverage_more_test.go b/internal/app/coverage_more_test.go new file mode 100644 index 0000000..1fe2350 --- /dev/null +++ b/internal/app/coverage_more_test.go @@ -0,0 +1,301 @@ +package app + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/aatuh/evydence/internal/domain" +) + +func TestImplementationIncrementReadListAndHelperBranches(t *testing.T) { + ledger := NewLedger(Config{APIKeyPepper: "test-pepper", Now: fixedNow}) + ctx := context.Background() + actor, release, artifact := setupReleaseRiskFixture(t, ledger) + project, err := ledger.CreateProject(ctx, actor, release.ProductID, "api") + if err != nil { + t.Fatalf("project: %v", err) + } + build, err := ledger.CreateBuildRun(ctx, actor, CreateBuildRunInput{ + ProjectID: project.ID, + ReleaseID: release.ID, + Provider: "generic_ci", + CommitSHA: "0123456789abcdef0123456789abcdef01234567", + Status: "passed", + StartedAt: fixedNow(), + Outputs: []domain.BuildOutput{{ArtifactID: artifact.ID, Digest: artifact.Digest}}, + }) + if err != nil { + t.Fatalf("build: %v", err) + } + sbom, err := ledger.UploadSBOM(ctx, actor, release.ID, artifact.ID, []byte(`{"bomFormat":"CycloneDX","specVersion":"1.6","components":[{"name":"api","purl":"pkg:oci/api"}]}`)) + if err != nil { + t.Fatalf("sbom: %v", err) + } + scan, err := ledger.UploadVulnerabilityScan(ctx, actor, []byte(`{"scanner":"grype","target_ref":"pkg:oci/api","release_id":"`+release.ID+`","findings":[{"vulnerability":"CVE-2026-0001","component":"api","severity":"critical","state":"open"}]}`)) + if err != nil { + t.Fatalf("scan: %v", err) + } + vex, err := ledger.UploadVEX(ctx, actor, release.ID, artifact.ID, []byte(`{"@context":"https://openvex.dev/ns/v0.2.0","@id":"https://example.test/vex","author":"security@example.test","timestamp":"2026-05-28T12:00:00Z","version":1,"statements":[{"vulnerability":{"name":"CVE-2026-0001"},"products":[{"@id":"pkg:oci/api"}],"status":"under_investigation","justification":"triage","impact_statement":"reviewing","action_statement":"track"}]}`)) + if err != nil { + t.Fatalf("vex: %v", err) + } + contract, err := ledger.UploadOpenAPIContract(ctx, actor, release.ProductID, release.ID, "v1", []byte(`{"openapi":"3.1.0","info":{"title":"API","version":"1"},"paths":{"/health":{"get":{"responses":{"200":{"description":"ok"}}}}}}`)) + if err != nil { + t.Fatalf("contract: %v", err) + } + bundle, err := ledger.CreateReleaseBundle(ctx, actor, release.ID) + if err != nil { + t.Fatalf("bundle: %v", err) + } + candidate, err := ledger.CreateReleaseCandidate(ctx, actor, CreateReleaseCandidateInput{ + ReleaseID: release.ID, + Name: "rc-full", + BuildIDs: []string{build.ID}, + ArtifactIDs: []string{artifact.ID}, + SBOMIDs: []string{sbom.ID}, + ScanIDs: []string{scan.ID}, + VEXIDs: []string{vex.ID}, + ContractIDs: []string{contract.ID}, + BundleIDs: []string{bundle.ID}, + }) + if err != nil { + t.Fatalf("candidate with all refs: %v", err) + } + if got, err := ledger.GetReleaseCandidate(ctx, actor, candidate.ID); err != nil || got.ID != candidate.ID { + t.Fatalf("get candidate=%#v err=%v", got, err) + } + if listed, err := ledger.ListReleaseCandidates(ctx, actor, release.ID); err != nil || len(listed) != 1 { + t.Fatalf("list candidates=%#v err=%v", listed, err) + } + reject, err := ledger.CreateReleaseCandidate(ctx, actor, CreateReleaseCandidateInput{ReleaseID: release.ID, Name: "rc-reject"}) + if err != nil { + t.Fatalf("reject candidate: %v", err) + } + if rejected, err := ledger.UpdateReleaseCandidateState(ctx, actor, reject.ID, candidateRejected, "bad build"); err != nil || rejected.RejectedAt == nil { + t.Fatalf("rejected candidate=%#v err=%v", rejected, err) + } + if _, err := ledger.CreateReleaseCandidate(ctx, actor, CreateReleaseCandidateInput{ReleaseID: release.ID, Name: "bad", BuildIDs: []string{"missing"}}); !errors.Is(err, ErrNotFound) { + t.Fatalf("missing build candidate err=%v, want not found", err) + } + + sig, err := ledger.CreateArtifactSignature(ctx, actor, CreateArtifactSignatureInput{ArtifactID: artifact.ID, Algorithm: "cosign", Signature: "sig"}) + if err != nil { + t.Fatalf("artifact signature: %v", err) + } + if got, err := ledger.GetArtifactSignature(ctx, actor, sig.ID); err != nil || got.ID != sig.ID { + t.Fatalf("get artifact signature=%#v err=%v", got, err) + } + if duplicate, err := ledger.RegisterContainerImage(ctx, actor, RegisterContainerImageInput{ArtifactID: artifact.ID, Repository: "ghcr.io/example/api", Digest: artifact.Digest}); err != nil { + t.Fatalf("image: %v", err) + } else if again, err := ledger.RegisterContainerImage(ctx, actor, RegisterContainerImageInput{ArtifactID: artifact.ID, Repository: "ghcr.io/example/api", Digest: artifact.Digest}); err != nil || again.ID != duplicate.ID { + t.Fatalf("duplicate image=%#v err=%v", again, err) + } + + repo, err := ledger.CreateSourceRepository(ctx, actor, CreateRepositoryInput{ProjectID: project.ID, Provider: "github", FullName: "aatuh/evydence", DefaultBranch: "main"}) + if err != nil { + t.Fatalf("repo: %v", err) + } + if again, err := ledger.CreateSourceRepository(ctx, actor, CreateRepositoryInput{ProjectID: project.ID, Provider: "github", FullName: "aatuh/evydence"}); err != nil || again.ID != repo.ID { + t.Fatalf("duplicate repo=%#v err=%v", again, err) + } + commit, err := ledger.RecordSourceCommit(ctx, actor, RecordCommitInput{RepositoryID: repo.ID, SHA: "1123456789abcdef0123456789abcdef01234567", Message: "change"}) + if err != nil { + t.Fatalf("commit: %v", err) + } + branch, err := ledger.UpsertSourceBranch(ctx, actor, UpsertBranchInput{RepositoryID: repo.ID, Name: "main", HeadCommitID: commit.ID, Protected: true, ProtectionHash: sampleDigest("branch")}) + if err != nil { + t.Fatalf("branch: %v", err) + } + updated, err := ledger.UpsertSourceBranch(ctx, actor, UpsertBranchInput{RepositoryID: repo.ID, Name: "main", HeadCommitID: commit.ID, Protected: false}) + if err != nil || updated.ID != branch.ID || updated.Protected { + t.Fatalf("updated branch=%#v err=%v", updated, err) + } + if _, err := ledger.RecordPullRequest(ctx, actor, RecordPullRequestInput{RepositoryID: repo.ID, ProviderID: "1", Title: "Change", State: "closed", HeadCommitID: commit.ID}); err != nil { + t.Fatalf("pr closed: %v", err) + } + if listed, err := ledger.ListSourceRepositories(ctx, actor, project.ID); err != nil || len(listed) == 0 { + t.Fatalf("list repos=%#v err=%v", listed, err) + } + if _, err := ledger.UploadGitHubSourceSnapshot(ctx, actor, []byte(`{"project_id":"`+project.ID+`","repository":{"full_name":"aatuh/evydence","default_branch":"main"},"commit":{"sha":"2123456789abcdef0123456789abcdef01234567","author":"dev@example.test","message":"change","committed_at":"2026-05-28T12:00:00Z"},"branch":{"name":"main","protected":true,"protection_hash":"`+sampleDigest("protection")+`"},"pull_request":{"provider_id":"2","title":"Change 2","state":"open","source_branch":"feature","target_branch":"main","review_decision":"review_required"}}`)); err != nil { + t.Fatalf("github snapshot: %v", err) + } + + env, err := ledger.CreateDeploymentEnvironment(ctx, actor, CreateEnvironmentInput{ProductID: release.ProductID, Name: "prod", Kind: "production"}) + if err != nil { + t.Fatalf("env: %v", err) + } + if envAgain, err := ledger.CreateDeploymentEnvironment(ctx, actor, CreateEnvironmentInput{ProductID: release.ProductID, Name: "prod", Kind: "production"}); err != nil || envAgain.ID != env.ID { + t.Fatalf("duplicate env=%#v err=%v", envAgain, err) + } + if envs, err := ledger.ListDeploymentEnvironments(ctx, actor, release.ProductID); err != nil || len(envs) != 1 { + t.Fatalf("list envs=%#v err=%v", envs, err) + } + deployment, err := ledger.RecordDeployment(ctx, actor, RecordDeploymentInput{EnvironmentID: env.ID, ReleaseID: release.ID, ArtifactIDs: []string{artifact.ID}, Status: deploymentStatusFailed, StartedAt: fixedNow()}) + if err != nil { + t.Fatalf("deployment: %v", err) + } + rollback, err := ledger.RecordDeployment(ctx, actor, RecordDeploymentInput{EnvironmentID: env.ID, ReleaseID: release.ID, Status: deploymentStatusRolledBack, RollbackOf: deployment.ID}) + if err != nil { + t.Fatalf("rollback: %v", err) + } + if got, err := ledger.GetDeployment(ctx, actor, rollback.ID); err != nil || got.RollbackOf != deployment.ID { + t.Fatalf("get rollback=%#v err=%v", got, err) + } + if listed, err := ledger.ListDeployments(ctx, actor, release.ID, env.ID); err != nil || len(listed) != 2 { + t.Fatalf("list deployments=%#v err=%v", listed, err) + } +} + +func TestSearchAndControlHelperBranches(t *testing.T) { + item := domain.EvidenceItem{ + ID: "ev_1", + TenantID: "ten_1", + ProductID: "prod_1", + ProjectID: "proj_1", + ReleaseID: "rel_1", + BuildID: "build_1", + DeploymentID: "dep_1", + Type: "sbom", + Subtype: "cyclonedx", + SourceSystem: "github", + CollectorID: "collector_1", + VerificationStatus: "verified", + Tags: []string{"release"}, + SubjectRefs: []domain.SubjectRef{{Type: "artifact", ID: "art_1", Digest: sampleDigest("artifact")}}, + CreatedAt: fixedNow(), + } + matching := EvidenceSearchInput{ProductID: "prod_1", ProjectID: "proj_1", ReleaseID: "rel_1", BuildID: "build_1", DeploymentID: "dep_1", Type: "sbom", Subtype: "cyclonedx", SourceSystem: "github", CollectorID: "collector_1", VerificationStatus: "verified", SubjectType: "artifact", SubjectID: "art_1", Tag: "release", CreatedAfter: fixedNow().Add(-time.Hour), CreatedBefore: fixedNow().Add(time.Hour)} + if !matchesEvidenceSearch(item, matching) { + t.Fatal("expected all search fields to match") + } + negativeCases := []EvidenceSearchInput{ + {ProductID: "other"}, {ProjectID: "other"}, {ReleaseID: "other"}, {BuildID: "other"}, {DeploymentID: "other"}, + {Type: "scan"}, {Subtype: "spdx"}, {SourceSystem: "gitlab"}, {CollectorID: "other"}, {VerificationStatus: "failed"}, + {CreatedAfter: fixedNow().Add(time.Hour)}, {CreatedBefore: fixedNow().Add(-time.Hour)}, {Tag: "missing"}, + {SubjectType: "release"}, {SubjectID: "missing"}, + } + for _, in := range negativeCases { + if matchesEvidenceSearch(item, in) { + t.Fatalf("search should not match for %#v", in) + } + } + for _, action := range []string{lifecycleAmendment, lifecycleRedaction, lifecycleTombstone, lifecycleRetentionMarker} { + if !validLifecycleAction(action) { + t.Fatalf("valid lifecycle action rejected: %s", action) + } + } + if validLifecycleAction("delete") || validPullRequestState("draft") || validDeploymentStatus("unknown") { + t.Fatal("invalid helper values should be rejected") + } + for _, state := range []string{"open", "closed", "merged"} { + if !validPullRequestState(state) { + t.Fatalf("valid pull request state rejected: %s", state) + } + } + for _, status := range []string{deploymentStatusStarted, deploymentStatusSucceeded, deploymentStatusFailed, deploymentStatusRolledBack} { + if !validDeploymentStatus(status) { + t.Fatalf("valid deployment status rejected: %s", status) + } + } +} + +func TestControlSubjectResolutionCoversAllSupportedSubjects(t *testing.T) { + ledger := NewLedger(Config{APIKeyPepper: "test-pepper", Now: fixedNow}) + ctx := context.Background() + actor, release, artifact := setupReleaseRiskFixture(t, ledger) + project, err := ledger.CreateProject(ctx, actor, release.ProductID, "api") + if err != nil { + t.Fatalf("project: %v", err) + } + evidence, err := ledger.CreateEvidence(ctx, actor, CreateEvidenceInput{ProductID: release.ProductID, ProjectID: project.ID, ReleaseID: release.ID, Type: "manual", Title: "manual", PayloadHash: sampleDigest("evidence")}) + if err != nil { + t.Fatalf("evidence: %v", err) + } + sbom, err := ledger.UploadSBOM(ctx, actor, release.ID, artifact.ID, []byte(`{"bomFormat":"CycloneDX","specVersion":"1.6","components":[{"name":"api"}]}`)) + if err != nil { + t.Fatalf("sbom: %v", err) + } + scan, err := ledger.UploadVulnerabilityScan(ctx, actor, []byte(`{"scanner":"grype","target_ref":"pkg:oci/api","release_id":"`+release.ID+`","findings":[{"vulnerability":"CVE-2026-0001","component":"api","severity":"critical","state":"open"}]}`)) + if err != nil { + t.Fatalf("scan: %v", err) + } + decision, err := ledger.CreateVulnerabilityDecision(ctx, actor, scan.Findings[0].ID, CreateVulnerabilityDecisionInput{Status: decisionStatusFixed, Justification: "fixed"}) + if err != nil { + t.Fatalf("decision: %v", err) + } + vex, err := ledger.UploadVEX(ctx, actor, release.ID, artifact.ID, []byte(`{"@context":"https://openvex.dev/ns/v0.2.0","@id":"https://example.test/vex","author":"security@example.test","timestamp":"2026-05-28T12:00:00Z","version":1,"statements":[{"vulnerability":{"name":"CVE-2026-0001"},"products":[{"@id":"pkg:oci/api"}],"status":"fixed","justification":"fixed","impact_statement":"fixed","action_statement":"none"}]}`)) + if err != nil { + t.Fatalf("vex: %v", err) + } + exception, err := ledger.CreateException(ctx, actor, CreateExceptionInput{ReleaseID: release.ID, FindingID: scan.Findings[0].ID, Reason: "accepted", Owner: "security", ExpiresAt: fixedNow().Add(time.Hour)}) + if err != nil { + t.Fatalf("exception: %v", err) + } + build, err := ledger.CreateBuildRun(ctx, actor, CreateBuildRunInput{ProjectID: project.ID, ReleaseID: release.ID, Provider: "generic_ci", CommitSHA: "3123456789abcdef0123456789abcdef01234567", Status: "passed", StartedAt: fixedNow(), Outputs: []domain.BuildOutput{{ArtifactID: artifact.ID, Digest: artifact.Digest}}}) + if err != nil { + t.Fatalf("build: %v", err) + } + attestation, err := ledger.UploadBuildAttestation(ctx, actor, build.ID, dsseForDigest(t, artifact.Digest)) + if err != nil { + t.Fatalf("attestation: %v", err) + } + contract, err := ledger.UploadOpenAPIContract(ctx, actor, release.ProductID, release.ID, "v1", []byte(`{"openapi":"3.1.0","info":{"title":"API","version":"1"},"paths":{"/health":{"get":{"responses":{"200":{"description":"ok"}}}}}}`)) + if err != nil { + t.Fatalf("contract: %v", err) + } + bundle, err := ledger.CreateReleaseBundle(ctx, actor, release.ID) + if err != nil { + t.Fatalf("bundle: %v", err) + } + + ledger.mu.Lock() + defer ledger.mu.Unlock() + subjects := map[string]string{ + "evidence": evidence.ID, + "evidence_item": evidence.ID, + "product": release.ProductID, + "release": release.ID, + "artifact": artifact.ID, + "sbom": sbom.ID, + "vulnerability_scan": scan.ID, + "vex": vex.ID, + "vulnerability_decision": decision.ID, + "finding": scan.Findings[0].ID, + "vulnerability_finding": scan.Findings[0].ID, + "exception": exception.ID, + "build": build.ID, + "build_attestation": attestation.ID, + "openapi_contract": contract.ID, + "release_bundle": bundle.ID, + } + for subjectType, subjectID := range subjects { + if !ledger.controlSubjectExistsLocked(actor.TenantID, subjectType, subjectID, release.ProductID, release.ID) { + t.Fatalf("expected control subject %s/%s to exist", subjectType, subjectID) + } + } + if ledger.controlSubjectExistsLocked(actor.TenantID, "unknown", "id", release.ProductID, release.ID) { + t.Fatal("unknown control subject should not exist") + } + refs := []resourceRefs{ + ledger.refsForControlEvidenceSubjectLocked("evidence", evidence.ID, "", ""), + ledger.refsForControlEvidenceSubjectLocked("build_attestation", attestation.ID, "", ""), + ledger.refsForControlEvidenceSubjectLocked("vulnerability_scan", scan.ID, "", ""), + ledger.refsForControlEvidenceSubjectLocked("openapi_contract", contract.ID, "", ""), + ledger.refsForControlEvidenceSubjectLocked("release_bundle", bundle.ID, "", ""), + ledger.refsForControlEvidenceSubjectLocked("customer_package", "pkg_1", "", ""), + } + if refs[0].ProductID != release.ProductID || refs[1].BuildID != build.ID || refs[5].CustomerPackageID != "pkg_1" { + t.Fatalf("unexpected refs: %#v", refs) + } + for subjectType, subjectID := range map[string]string{ + "evidence": evidence.ID, "sbom": sbom.ID, "vulnerability_scan": scan.ID, "vex": vex.ID, + "vulnerability_decision": decision.ID, "exception": exception.ID, "build": build.ID, + "build_attestation": attestation.ID, "openapi_contract": contract.ID, "release_bundle": bundle.ID, + } { + if got := ledger.controlEvidenceTimeLocked(domain.ControlEvidence{SubjectType: subjectType, SubjectID: subjectID, CreatedAt: fixedNow().Add(-time.Hour)}); got.IsZero() { + t.Fatalf("zero evidence time for %s", subjectType) + } + } +} diff --git a/internal/app/critical_mutation_test.go b/internal/app/critical_mutation_test.go new file mode 100644 index 0000000..f9cfd25 --- /dev/null +++ b/internal/app/critical_mutation_test.go @@ -0,0 +1,675 @@ +package app + +import ( + "context" + "testing" + "time" + + "github.com/aatuh/evydence/internal/domain" +) + +type focusedStoreSpy struct { + saveCalls int + criticalCalls int + releaseCalls int + mutations []CriticalMutation + releases []ReleaseLedgerMutation +} + +func (s *focusedStoreSpy) LoadState(context.Context) (PersistedState, bool, error) { + return PersistedState{}, false, nil +} + +func (s *focusedStoreSpy) SaveState(context.Context, PersistedState) error { + s.saveCalls++ + return nil +} + +func (s *focusedStoreSpy) ApplyCriticalMutation(_ context.Context, mutation CriticalMutation) error { + s.criticalCalls++ + s.mutations = append(s.mutations, mutation) + return nil +} + +func (s *focusedStoreSpy) ApplyReleaseLedgerMutation(_ context.Context, mutation ReleaseLedgerMutation) error { + s.releaseCalls++ + s.releases = append(s.releases, mutation) + return nil +} + +func (s *focusedStoreSpy) reset() { + s.saveCalls = 0 + s.criticalCalls = 0 + s.releaseCalls = 0 + s.mutations = nil + s.releases = nil +} + +func TestCriticalMutationStoreAvoidsAggregateSaveForMigratedFlows(t *testing.T) { + ctx := context.Background() + store := &focusedStoreSpy{} + ledger := NewLedger(Config{APIKeyPepper: "test-pepper", Now: fixedNow, Store: store}) + + _, key, secret, err := ledger.BootstrapTenant(ctx, "Tenant", "admin", []string{"*"}) + if err != nil { + t.Fatalf("bootstrap: %v", err) + } + if store.saveCalls != 0 || store.criticalCalls != 1 { + t.Fatalf("bootstrap save=%d critical=%d", store.saveCalls, store.criticalCalls) + } + first := store.mutations[0] + if len(first.APIKeys) != 1 || first.APIKeys[0].Hash != "" || first.APIKeyHashes[key.ID] == "" { + t.Fatalf("api key mutation leaked or missed hash: %#v hashes=%#v", first.APIKeys, first.APIKeyHashes) + } + + actor, err := ledger.Authenticate(ctx, secret) + if err != nil { + t.Fatalf("authenticate: %v", err) + } + if store.saveCalls != 0 || store.criticalCalls != 2 { + t.Fatalf("authenticate save=%d critical=%d", store.saveCalls, store.criticalCalls) + } + + store.reset() + if _, _, err := ledger.CreateAPIKey(ctx, actor, "reader", []string{ScopeProductRead}, nil); err != nil { + t.Fatalf("create api key: %v", err) + } + if store.saveCalls != 0 || store.criticalCalls != 1 { + t.Fatalf("create api key save=%d critical=%d", store.saveCalls, store.criticalCalls) + } + + store.reset() + status, response, err := ledger.WithIdempotency(ctx, actor, "POST", "/v1/products", "idem-key", []byte(`{"name":"x"}`), func() (int, any, error) { + return 201, map[string]any{"ok": true}, nil + }) + if err != nil || status != 201 || response == nil { + t.Fatalf("idempotency status=%d response=%#v err=%v", status, response, err) + } + if store.saveCalls != 0 || store.criticalCalls != 1 || len(store.mutations[0].Idempotency) != 1 { + t.Fatalf("idempotency save=%d critical=%d mutation=%#v", store.saveCalls, store.criticalCalls, store.mutations) + } +} + +func TestCriticalMutationStoreCoversBundleVerificationAndDecision(t *testing.T) { + ctx := context.Background() + store := &focusedStoreSpy{} + ledger := NewLedger(Config{APIKeyPepper: "test-pepper", Now: fixedNow, Store: store}) + actor, release, artifact := setupReleaseRiskFixture(t, ledger) + _ = artifact + + store.reset() + bundle, err := ledger.CreateReleaseBundle(ctx, actor, release.ID) + if err != nil { + t.Fatalf("bundle: %v", err) + } + if store.saveCalls != 0 || store.criticalCalls != 1 { + t.Fatalf("bundle save=%d critical=%d", store.saveCalls, store.criticalCalls) + } + bundleMutation := store.mutations[0] + if len(bundleMutation.ReleaseBundles) == 0 || len(bundleMutation.Signatures) == 0 || len(bundleMutation.AuditChainEntries) == 0 || len(bundleMutation.OutboxJobs) != 1 { + t.Fatalf("bundle mutation incomplete: %#v", bundleMutation) + } + + store.reset() + if _, err := ledger.VerifySubject(ctx, actor, "release_bundle", bundle.ID); err != nil { + t.Fatalf("verify bundle: %v", err) + } + if store.saveCalls != 0 || store.criticalCalls != 1 || len(store.mutations[0].VerificationResults) == 0 || len(store.mutations[0].OutboxJobs) != 1 { + t.Fatalf("verify mutation incomplete save=%d critical=%d mutation=%#v", store.saveCalls, store.criticalCalls, store.mutations) + } + + scan := domain.VulnerabilityScan{ID: "scan_test", TenantID: actor.TenantID, ReleaseID: release.ID, Findings: []domain.VulnerabilityFinding{{ID: "finding_test", Vulnerability: "CVE-0000-0001", Severity: "critical", Component: "lib"}}} + ledger.scans[scan.ID] = scan + store.reset() + if _, err := ledger.CreateVulnerabilityDecision(ctx, actor, "finding_test", CreateVulnerabilityDecisionInput{Status: decisionStatusNotAffected, Justification: "component is not present"}); err != nil { + t.Fatalf("decision: %v", err) + } + if store.saveCalls != 0 || store.criticalCalls != 1 || len(store.mutations[0].VulnerabilityDecisions) == 0 { + t.Fatalf("decision mutation incomplete save=%d critical=%d mutation=%#v", store.saveCalls, store.criticalCalls, store.mutations) + } +} + +func TestCriticalMutationStoreCoversSSOAndPortalSecrets(t *testing.T) { + ctx := context.Background() + store := &focusedStoreSpy{} + ledger := NewLedger(Config{APIKeyPepper: "test-pepper", Now: fixedNow, Store: store}) + actor := domain.Actor{TenantID: "ten_test", KeyID: "key_test", Scopes: []string{"*"}} + ledger.tenants[actor.TenantID] = domain.Tenant{ID: actor.TenantID, Name: "Tenant", CreatedAt: fixedNow()} + ledger.users["user_test"] = domain.HumanUser{ + ID: "user_test", TenantID: actor.TenantID, OrganizationID: "org_test", + Email: "user@example.test", Status: "active", SchemaVersion: domain.HumanUserSchemaVersion, + CreatedAt: fixedNow(), + } + ledger.ssoProviders["sso_test"] = domain.SSOProvider{ + ID: "sso_test", TenantID: actor.TenantID, Name: "OIDC", Type: "oidc", + Issuer: "https://idp.example.test", ClientID: "client", Status: "active", + SchemaVersion: domain.SSOProviderSchemaVersion, CreatedAt: fixedNow(), + } + ledger.customerPackages["pkg_test"] = domain.CustomerSecurityPackage{ + ID: "pkg_test", TenantID: actor.TenantID, Title: "Customer package", + ManifestHash: "sha256:test", SchemaVersion: domain.CustomerPackageSchemaVersion, + CreatedAt: fixedNow(), + } + + store.reset() + session, secret, err := ledger.CreateSSOSession(ctx, actor, CreateSSOSessionInput{ + UserID: "user_test", ProviderID: "sso_test", ExpiresAt: fixedNow().Add(time.Hour), + }) + if err != nil { + t.Fatalf("create sso session: %v", err) + } + if secret == "" || session.Hash != "" { + t.Fatalf("sso session leaked hash or missed secret: session=%#v secret=%q", session, secret) + } + if store.saveCalls != 0 || store.criticalCalls != 1 || len(store.mutations[0].SSOSessions) == 0 || store.mutations[0].SSOSessionHashes[session.ID] == "" { + t.Fatalf("sso mutation incomplete save=%d critical=%d mutation=%#v", store.saveCalls, store.criticalCalls, store.mutations) + } + if store.mutations[0].SSOSessions[0].Hash != "" { + t.Fatalf("sso mutation exposed hash on resource: %#v", store.mutations[0].SSOSessions[0]) + } + + store.reset() + access, portalSecret, err := ledger.CreateCustomerPortalAccess(ctx, actor, CreateCustomerPortalAccessInput{ + PackageID: "pkg_test", CustomerName: "Customer", ExpiresAt: fixedNow().Add(time.Hour), + }) + if err != nil { + t.Fatalf("create portal access: %v", err) + } + if portalSecret == "" || access.Hash != "" { + t.Fatalf("portal access leaked hash or missed secret: access=%#v secret=%q", access, portalSecret) + } + if store.saveCalls != 0 || store.criticalCalls != 1 || len(store.mutations[0].CustomerPortalAccess) == 0 || store.mutations[0].CustomerPortalHashes[access.ID] == "" { + t.Fatalf("portal mutation incomplete save=%d critical=%d mutation=%#v", store.saveCalls, store.criticalCalls, store.mutations) + } + if store.mutations[0].CustomerPortalAccess[0].Hash != "" { + t.Fatalf("portal mutation exposed hash on resource: %#v", store.mutations[0].CustomerPortalAccess[0]) + } +} + +func TestReleaseLedgerMutationStoreAvoidsAggregateSaveForCoreFlows(t *testing.T) { + ctx := context.Background() + store := &focusedStoreSpy{} + ledger := NewLedger(Config{APIKeyPepper: "test-pepper", Now: fixedNow, Store: store}) + _, _, secret, err := ledger.BootstrapTenant(ctx, "Tenant", "admin", []string{"*"}) + if err != nil { + t.Fatalf("bootstrap: %v", err) + } + actor, err := ledger.Authenticate(ctx, secret) + if err != nil { + t.Fatalf("authenticate: %v", err) + } + + store.reset() + product, err := ledger.CreateProduct(ctx, actor, "Payments API", "payments-core") + if err != nil { + t.Fatalf("create product: %v", err) + } + if store.saveCalls != 0 || store.releaseCalls != 1 || len(store.releases[0].Products) == 0 || len(store.releases[0].AuditChainEntries) == 0 { + t.Fatalf("product mutation save=%d release=%d mutation=%#v", store.saveCalls, store.releaseCalls, store.releases) + } + + store.reset() + project, err := ledger.CreateProject(ctx, actor, product.ID, "API") + if err != nil { + t.Fatalf("create project: %v", err) + } + release, err := ledger.CreateRelease(ctx, actor, product.ID, "1.0.0") + if err != nil { + t.Fatalf("create release: %v", err) + } + if _, err := ledger.FreezeRelease(ctx, actor, release.ID); err != nil { + t.Fatalf("freeze release: %v", err) + } + if _, err := ledger.ApproveRelease(ctx, actor, release.ID); err != nil { + t.Fatalf("approve release: %v", err) + } + if store.saveCalls != 0 || store.releaseCalls != 4 { + t.Fatalf("project/release save=%d release=%d mutations=%#v", store.saveCalls, store.releaseCalls, store.releases) + } + lastReleaseMutation := store.releases[len(store.releases)-1] + if len(lastReleaseMutation.Releases) == 0 || len(lastReleaseMutation.AuditChainEntries) == 0 { + t.Fatalf("release mutation incomplete: %#v", lastReleaseMutation) + } + + store.reset() + artifact, err := ledger.RegisterArtifact(ctx, actor, "api.tar.gz", "application/gzip", sampleDigest("artifact"), 42) + if err != nil { + t.Fatalf("register artifact: %v", err) + } + item, err := ledger.CreateEvidence(ctx, actor, CreateEvidenceInput{ + ProductID: product.ID, + ProjectID: project.ID, + ReleaseID: release.ID, + Type: "build", + Title: "Build evidence", + PayloadHash: sampleDigest("build"), + SubjectRefs: []domain.SubjectRef{{Type: "artifact", ID: artifact.ID}}, + }) + if err != nil { + t.Fatalf("create evidence: %v", err) + } + if _, err := ledger.RecordEvidenceLifecycleEvent(ctx, actor, item.ID, RecordEvidenceLifecycleInput{Action: lifecycleAmendment, Reason: "corrected metadata"}); err != nil { + t.Fatalf("record lifecycle: %v", err) + } + if store.saveCalls != 0 || store.releaseCalls != 3 { + t.Fatalf("artifact/evidence save=%d release=%d mutations=%#v", store.saveCalls, store.releaseCalls, store.releases) + } + last := store.releases[len(store.releases)-1] + if len(last.Evidence) == 0 || len(last.EvidenceLifecycle) == 0 || len(last.AuditChainEntries) == 0 { + t.Fatalf("evidence lifecycle mutation incomplete: %#v", last) + } +} + +func TestReleaseLedgerMutationStoreCoversParserMetadataAndOutbox(t *testing.T) { + ctx := context.Background() + store := &focusedStoreSpy{} + ledger := NewLedger(Config{APIKeyPepper: "test-pepper", Now: fixedNow, Store: store, WorkerOwnedParserSideEffects: true}) + actor, release, artifact := setupReleaseRiskFixture(t, ledger) + + store.reset() + if _, err := ledger.UploadSBOM(ctx, actor, release.ID, artifact.ID, []byte(`{"bomFormat":"CycloneDX","specVersion":"1.5","components":[{"name":"lib","version":"1.0.0"}]}`)); err != nil { + t.Fatalf("upload sbom: %v", err) + } + if store.saveCalls != 0 || store.releaseCalls == 0 || !releaseMutationsContainSBOMAndOutbox(store.releases, "parse_sbom") { + t.Fatalf("sbom release mutations save=%d release=%d mutations=%#v", store.saveCalls, store.releaseCalls, store.releases) + } + + store.reset() + if _, err := ledger.UploadVulnerabilityScan(ctx, actor, []byte(`{"scanner":"generic","target_ref":"api","release_id":"`+release.ID+`","findings":[{"vulnerability":"CVE-0000-0001","component":"lib","severity":"critical"}]}`)); err != nil { + t.Fatalf("upload scan: %v", err) + } + if store.saveCalls != 0 || store.releaseCalls == 0 || !releaseMutationsContainScanAndOutbox(store.releases, "parse_vulnerability_scan") { + t.Fatalf("scan release mutations save=%d release=%d mutations=%#v", store.saveCalls, store.releaseCalls, store.releases) + } + + store.reset() + rawOpenAPI := []byte(`{"openapi":"3.1.0","info":{"title":"API","version":"1.0.0"},"paths":{"/health":{"get":{"responses":{"200":{"description":"ok"}}}}}}`) + if _, err := ledger.UploadOpenAPIContract(ctx, actor, release.ProductID, release.ID, "1.0.0", rawOpenAPI); err != nil { + t.Fatalf("upload openapi: %v", err) + } + if store.saveCalls != 0 || store.releaseCalls == 0 || !releaseMutationsContainContractAndOutbox(store.releases, "parse_openapi_contract") { + t.Fatalf("openapi release mutations save=%d release=%d mutations=%#v", store.saveCalls, store.releaseCalls, store.releases) + } + + store.reset() + rawVEX := []byte(`{"@context":"https://openvex.dev/ns/v0.2.0","@id":"https://example.test/vex","author":"security","timestamp":"2026-01-01T00:00:00Z","statements":[{"vulnerability":{"name":"CVE-0000-0001"},"products":[{"@id":"lib"}],"status":"not_affected","justification":"component_not_present","impact_statement":"not shipped"}]}`) + if _, err := ledger.UploadVEX(ctx, actor, release.ID, artifact.ID, rawVEX); err != nil { + t.Fatalf("upload vex: %v", err) + } + if store.saveCalls != 0 || store.releaseCalls == 0 || !releaseMutationsContainVEXAndOutbox(store.releases, "parse_vex") { + t.Fatalf("vex release mutations save=%d release=%d mutations=%#v", store.saveCalls, store.releaseCalls, store.releases) + } +} + +func TestCriticalMutationFallsBackToAggregateSave(t *testing.T) { + ctx := context.Background() + store := &focusedFallbackStore{} + ledger := NewLedger(Config{APIKeyPepper: "test-pepper", Now: fixedNow, Store: store}) + if _, _, _, err := ledger.BootstrapTenant(ctx, "Tenant", "admin", []string{"*"}); err != nil { + t.Fatalf("bootstrap: %v", err) + } + if store.saveCalls != 1 { + t.Fatalf("fallback save calls = %d", store.saveCalls) + } +} + +func TestReleaseLedgerMutationFallsBackToAggregateSave(t *testing.T) { + ctx := context.Background() + store := &focusedFallbackStore{} + ledger := NewLedger(Config{APIKeyPepper: "test-pepper", Now: fixedNow, Store: store}) + _, _, secret, err := ledger.BootstrapTenant(ctx, "Tenant", "admin", []string{"*"}) + if err != nil { + t.Fatalf("bootstrap: %v", err) + } + actor, err := ledger.Authenticate(ctx, secret) + if err != nil { + t.Fatalf("authenticate: %v", err) + } + store.saveCalls = 0 + if _, err := ledger.CreateProduct(ctx, actor, "Payments API", "payments-fallback"); err != nil { + t.Fatalf("create product: %v", err) + } + if store.saveCalls != 1 { + t.Fatalf("release fallback save calls = %d", store.saveCalls) + } +} + +func releaseMutationsContainSBOMAndOutbox(mutations []ReleaseLedgerMutation, kind string) bool { + for _, mutation := range mutations { + if len(mutation.SBOMs) > 0 && releaseMutationHasOutbox(mutation, kind) { + return true + } + } + return false +} + +func releaseMutationsContainScanAndOutbox(mutations []ReleaseLedgerMutation, kind string) bool { + for _, mutation := range mutations { + if len(mutation.Scans) > 0 && releaseMutationHasOutbox(mutation, kind) { + return true + } + } + return false +} + +func releaseMutationsContainContractAndOutbox(mutations []ReleaseLedgerMutation, kind string) bool { + for _, mutation := range mutations { + if len(mutation.Contracts) > 0 && releaseMutationHasOutbox(mutation, kind) { + return true + } + } + return false +} + +func releaseMutationsContainVEXAndOutbox(mutations []ReleaseLedgerMutation, kind string) bool { + for _, mutation := range mutations { + if len(mutation.VEXDocuments) > 0 && releaseMutationHasOutbox(mutation, kind) { + return true + } + } + return false +} + +func releaseMutationHasOutbox(mutation ReleaseLedgerMutation, kind string) bool { + for _, job := range mutation.OutboxJobs { + if job.Kind == kind { + return true + } + } + return false +} + +type focusedFallbackStore struct { + saveCalls int +} + +func (s *focusedFallbackStore) LoadState(context.Context) (PersistedState, bool, error) { + return PersistedState{}, false, nil +} + +func (s *focusedFallbackStore) SaveState(context.Context, PersistedState) error { + s.saveCalls++ + return nil +} + +type fullRelationalStoreSpy struct { + saveCalls int + relationalCalls int + criticalCalls int + releaseCalls int + states []PersistedState +} + +func (s *fullRelationalStoreSpy) LoadState(context.Context) (PersistedState, bool, error) { + return PersistedState{}, false, nil +} + +func (s *fullRelationalStoreSpy) SaveState(context.Context, PersistedState) error { + s.saveCalls++ + return nil +} + +func (s *fullRelationalStoreSpy) SaveRelationalState(_ context.Context, state PersistedState) error { + s.relationalCalls++ + s.states = append(s.states, state) + return nil +} + +func (s *fullRelationalStoreSpy) ApplyCriticalMutation(context.Context, CriticalMutation) error { + s.criticalCalls++ + return nil +} + +func (s *fullRelationalStoreSpy) ApplyReleaseLedgerMutation(context.Context, ReleaseLedgerMutation) error { + s.releaseCalls++ + return nil +} + +func (s *fullRelationalStoreSpy) reset() { + s.saveCalls = 0 + s.relationalCalls = 0 + s.criticalCalls = 0 + s.releaseCalls = 0 + s.states = nil +} + +func TestRelationalStateStoreAvoidsAggregateSaveForRemainingFamilies(t *testing.T) { + ctx := context.Background() + store := &fullRelationalStoreSpy{} + ledger := NewLedger(Config{APIKeyPepper: "test-pepper", Now: fixedNow, Store: store}) + _, _, secret, err := ledger.BootstrapTenant(ctx, "Tenant", "admin", []string{"*"}) + if err != nil { + t.Fatalf("bootstrap: %v", err) + } + actor, err := ledger.Authenticate(ctx, secret) + if err != nil { + t.Fatalf("authenticate: %v", err) + } + + store.reset() + framework, err := ledger.CreateControlFramework(ctx, actor, CreateControlFrameworkInput{Name: "CRA Readiness", Slug: "cra-readiness", Version: "1.0.0"}) + if err != nil { + t.Fatalf("create control framework: %v", err) + } + if store.saveCalls != 0 || store.relationalCalls != 1 || store.criticalCalls != 0 || store.releaseCalls != 0 { + t.Fatalf("framework persistence save=%d relational=%d critical=%d release=%d", store.saveCalls, store.relationalCalls, store.criticalCalls, store.releaseCalls) + } + if got := store.states[0].ControlFrameworks[framework.ID]; got.ID != framework.ID || got.TenantID != actor.TenantID { + t.Fatalf("relational state missed framework: %#v", got) + } + + product, err := ledger.CreateProduct(ctx, actor, "Payments", "payments-relational-state") + if err != nil { + t.Fatalf("create product: %v", err) + } + release, err := ledger.CreateRelease(ctx, actor, product.ID, "1.0.0") + if err != nil { + t.Fatalf("create release: %v", err) + } + store.reset() + incident, err := ledger.CreateIncident(ctx, actor, CreateIncidentInput{ProductID: product.ID, ReleaseID: release.ID, Title: "Incident", Severity: "high"}) + if err != nil { + t.Fatalf("create incident: %v", err) + } + if store.saveCalls != 0 || store.relationalCalls != 1 { + t.Fatalf("incident persistence save=%d relational=%d", store.saveCalls, store.relationalCalls) + } + if got := store.states[0].Incidents[incident.ID]; got.ID != incident.ID || got.TenantID != actor.TenantID { + t.Fatalf("relational state missed incident: %#v", got) + } + + store.reset() + profile, err := ledger.CreateRedactionProfile(ctx, actor, CreateRedactionProfileInput{Name: "Customer Safe", AllowedTypes: []string{"sbom", "vulnerability_scan"}}) + if err != nil { + t.Fatalf("create redaction profile: %v", err) + } + if store.saveCalls != 0 || store.relationalCalls != 1 { + t.Fatalf("redaction persistence save=%d relational=%d", store.saveCalls, store.relationalCalls) + } + if got := store.states[0].RedactionProfiles[profile.ID]; got.ID != profile.ID || got.TenantID != actor.TenantID { + t.Fatalf("relational state missed redaction profile: %#v", got) + } + + store.reset() + pkg, err := ledger.CreateCustomerSecurityPackage(ctx, actor, CreateCustomerPackageInput{ + ProductID: product.ID, + ReleaseID: release.ID, + RedactionProfileID: profile.ID, + Title: "Customer package", + ExpiresAt: fixedNow().Add(time.Hour), + }) + if err != nil { + t.Fatalf("create customer package: %v", err) + } + if store.saveCalls != 0 || store.relationalCalls != 1 { + t.Fatalf("customer package persistence save=%d relational=%d", store.saveCalls, store.relationalCalls) + } + if got := store.states[0].CustomerPackages[pkg.ID]; got.ID != pkg.ID || got.TenantID != actor.TenantID { + t.Fatalf("relational state missed customer package: %#v", got) + } +} + +func TestRelationalStateStoreCoversPackageAndReportWriteFamilies(t *testing.T) { + ctx := context.Background() + store := &fullRelationalStoreSpy{} + ledger := NewLedger(Config{APIKeyPepper: "test-pepper", Now: fixedNow, Store: store}) + _, _, secret, err := ledger.BootstrapTenant(ctx, "Tenant", "admin", []string{"*"}) + if err != nil { + t.Fatalf("bootstrap: %v", err) + } + actor, err := ledger.Authenticate(ctx, secret) + if err != nil { + t.Fatalf("authenticate: %v", err) + } + product, err := ledger.CreateProduct(ctx, actor, "Payments", "payments-package-report-state") + if err != nil { + t.Fatalf("create product: %v", err) + } + release, err := ledger.CreateRelease(ctx, actor, product.ID, "1.0.0") + if err != nil { + t.Fatalf("create release: %v", err) + } + evidence, err := ledger.CreateEvidence(ctx, actor, CreateEvidenceInput{ + ProductID: product.ID, + ReleaseID: release.ID, + Type: "security_review", + Title: "Security review", + PayloadHash: sampleDigest("package-report-evidence"), + }) + if err != nil { + t.Fatalf("create evidence: %v", err) + } + profile, err := ledger.CreateRedactionProfile(ctx, actor, CreateRedactionProfileInput{Name: "Customer Safe", AllowedTypes: []string{"security_review"}}) + if err != nil { + t.Fatalf("create redaction profile: %v", err) + } + pkg, err := ledger.CreateCustomerSecurityPackage(ctx, actor, CreateCustomerPackageInput{ + ProductID: product.ID, + ReleaseID: release.ID, + RedactionProfileID: profile.ID, + Title: "Customer package", + ExpiresAt: fixedNow().Add(time.Hour), + }) + if err != nil { + t.Fatalf("create customer package: %v", err) + } + if pkg.TenantID != actor.TenantID || pkg.ReleaseID != release.ID { + t.Fatalf("customer package setup = %#v", pkg) + } + if _, err := ledger.CreateControlFramework(ctx, actor, CreateControlFrameworkInput{Name: "CRA Readiness", Slug: "cra-readiness-package-report", Version: "1.0.0"}); err != nil { + t.Fatalf("create control framework: %v", err) + } + + store.reset() + htmlReport, err := ledger.CRAReadinessHTMLPackage(ctx, actor, product.ID, release.ID) + if err != nil { + t.Fatalf("cra html report: %v", err) + } + if store.saveCalls != 0 || store.relationalCalls != 1 { + t.Fatalf("html report persistence save=%d relational=%d", store.saveCalls, store.relationalCalls) + } + if got := store.states[0].HTMLReports[htmlReport.ID]; got.ID != htmlReport.ID || got.TenantID != actor.TenantID || got.ReleaseID != release.ID { + t.Fatalf("relational state missed html report: %#v", got) + } + if len(store.states[0].Chain[actor.TenantID]) == 0 { + t.Fatal("html report relational state missed audit chain") + } + + store.reset() + template, err := ledger.CreateCustomReportTemplate(ctx, actor, CreateReportTemplateInput{ + Name: "Reviewer report", Version: "1", ReportType: "reviewer", AllowedFields: []string{"subject_type", "subject_id"}, + }) + if err != nil { + t.Fatalf("create report template: %v", err) + } + rendered, err := ledger.RenderCustomReport(ctx, actor, RenderReportInput{TemplateID: template.ID, SubjectType: "release", SubjectID: release.ID}) + if err != nil { + t.Fatalf("render report: %v", err) + } + if store.saveCalls != 0 || store.relationalCalls != 2 { + t.Fatalf("custom report persistence save=%d relational=%d", store.saveCalls, store.relationalCalls) + } + lastCustomReportState := store.states[len(store.states)-1] + if got := lastCustomReportState.ReportTemplates[template.ID]; got.ID != template.ID || got.TenantID != actor.TenantID { + t.Fatalf("relational state missed report template: %#v", got) + } + if got := lastCustomReportState.RenderedReports[rendered.ID]; got.ID != rendered.ID || got.TenantID != actor.TenantID { + t.Fatalf("relational state missed rendered report: %#v", got) + } + + store.reset() + bundle, err := ledger.ExportEvidenceBundle(ctx, actor, release.ID, []string{evidence.ID}) + if err != nil { + t.Fatalf("export evidence bundle: %v", err) + } + imported, err := ledger.ImportEvidenceBundle(ctx, actor, bundle) + if err != nil { + t.Fatalf("import evidence bundle: %v", err) + } + if store.saveCalls != 0 || store.relationalCalls != 2 { + t.Fatalf("evidence bundle persistence save=%d relational=%d", store.saveCalls, store.relationalCalls) + } + lastBundleState := store.states[len(store.states)-1] + if got := lastBundleState.EvidenceBundles[bundle.ID]; got.ID != bundle.ID || got.TenantID != actor.TenantID || len(got.SignatureRefs) == 0 { + t.Fatalf("relational state missed evidence bundle: %#v", got) + } + if got := lastBundleState.BundleImports[imported.ID]; got.ID != imported.ID || got.TenantID != actor.TenantID { + t.Fatalf("relational state missed bundle import: %#v", got) + } + if len(lastBundleState.SigningKeyPrivate) == 0 || len(lastBundleState.Signatures) == 0 { + t.Fatalf("relational state missed bundle signing material: keys=%#v signatures=%#v", lastBundleState.SigningKeyPrivate, lastBundleState.Signatures) + } + + store.reset() + summary, err := ledger.CreateEvidenceSummary(ctx, actor, CreateEvidenceSummaryInput{SubjectType: "release", SubjectID: release.ID, EvidenceIDs: []string{evidence.ID}}) + if err != nil { + t.Fatalf("create evidence summary: %v", err) + } + pdf, err := ledger.CreatePDFReportPackage(ctx, actor, CreatePDFReportPackageInput{ReportType: "customer_review", ProductID: product.ID, ReleaseID: release.ID, Title: "Customer review"}) + if err != nil { + t.Fatalf("create pdf report: %v", err) + } + anomaly, err := ledger.GenerateAnomalyReport(ctx, actor, AnomalyReportInput{SubjectType: "release", SubjectID: release.ID}) + if err != nil { + t.Fatalf("generate anomaly report: %v", err) + } + if store.saveCalls != 0 || store.relationalCalls != 3 { + t.Fatalf("future report persistence save=%d relational=%d", store.saveCalls, store.relationalCalls) + } + lastFutureReportState := store.states[len(store.states)-1] + if got := lastFutureReportState.EvidenceSummaries[summary.ID]; got.ID != summary.ID || got.TenantID != actor.TenantID { + t.Fatalf("relational state missed evidence summary: %#v", got) + } + if got := lastFutureReportState.PDFReports[pdf.ID]; got.ID != pdf.ID || got.TenantID != actor.TenantID || got.PayloadHash == "" { + t.Fatalf("relational state missed pdf report: %#v", got) + } + if got := lastFutureReportState.AnomalyReports[anomaly.ID]; got.ID != anomaly.ID || got.TenantID != actor.TenantID { + t.Fatalf("relational state missed anomaly report: %#v", got) + } + + store.reset() + questionnaire, err := ledger.CreateQuestionnaireTemplate(ctx, actor, CreateQuestionnaireTemplateInput{ + Name: "Customer questionnaire", + Version: "1", + Questions: []domain.QuestionnaireQuestion{{ + ID: "q1", Prompt: "Is review evidence available?", EvidenceType: "security_review", + }}, + }) + if err != nil { + t.Fatalf("create questionnaire template: %v", err) + } + draft, err := ledger.CreateQuestionnaireDraft(ctx, actor, CreateQuestionnaireDraftInput{TemplateID: questionnaire.ID, ProductID: product.ID, ReleaseID: release.ID}) + if err != nil { + t.Fatalf("create questionnaire draft: %v", err) + } + if store.saveCalls != 0 || store.relationalCalls != 2 { + t.Fatalf("questionnaire persistence save=%d relational=%d", store.saveCalls, store.relationalCalls) + } + lastQuestionnaireState := store.states[len(store.states)-1] + if got := lastQuestionnaireState.QuestionnaireTemplates[questionnaire.ID]; got.ID != questionnaire.ID || got.TenantID != actor.TenantID { + t.Fatalf("relational state missed questionnaire template: %#v", got) + } + if got := lastQuestionnaireState.QuestionnaireDrafts[draft.ID]; got.ID != draft.ID || got.TenantID != actor.TenantID || got.ReleaseID != release.ID { + t.Fatalf("relational state missed questionnaire draft: %#v", got) + } +} diff --git a/internal/app/customer_package_golden_test.go b/internal/app/customer_package_golden_test.go new file mode 100644 index 0000000..145a82f --- /dev/null +++ b/internal/app/customer_package_golden_test.go @@ -0,0 +1,89 @@ +package app + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "os" + "strings" + "testing" + + "github.com/aatuh/evydence/internal/domain" +) + +func TestSampleCustomerPackageManifestGolden(t *testing.T) { + body, err := os.ReadFile("../../examples/end-to-end-release-evidence/sample-customer-package-manifest.json") + if err != nil { + t.Fatalf("read manifest fixture: %v", err) + } + expectedBody, err := os.ReadFile("../../examples/end-to-end-release-evidence/sample-customer-package-manifest.sha256") + if err != nil { + t.Fatalf("read manifest checksum fixture: %v", err) + } + expectedHash := strings.Fields(string(expectedBody))[0] + sum := sha256.Sum256(body) + if got := hex.EncodeToString(sum[:]); got != expectedHash { + t.Fatalf("sample customer package manifest drifted: got sha256 %s want %s; update the fixture and checksum intentionally", got, expectedHash) + } + + var manifest map[string]any + if err := json.Unmarshal(body, &manifest); err != nil { + t.Fatalf("decode manifest fixture: %v", err) + } + for _, key := range []string{ + "schema_version", + "package_version", + "package_id", + "product", + "release", + "redaction_profile", + "readiness_summary", + "verification_material", + "reviewer_checklist", + "customer_decision_export", + "limitations", + "non_claims", + } { + if _, ok := manifest[key]; !ok { + t.Fatalf("manifest fixture missing required key %q", key) + } + } + if manifest["schema_version"] != domain.CustomerPackageSchemaVersion || manifest["package_version"] != domain.CustomerPackageSchemaVersion { + t.Fatalf("manifest fixture schema version = schema:%v package:%v want %s", manifest["schema_version"], manifest["package_version"], domain.CustomerPackageSchemaVersion) + } + checklist, ok := manifest["reviewer_checklist"].([]any) + if !ok || len(checklist) < 6 { + t.Fatalf("manifest fixture reviewer checklist missing expected proof-path entries: %#v", manifest["reviewer_checklist"]) + } + seen := map[string]bool{} + for _, item := range checklist { + entry, ok := item.(map[string]any) + if !ok { + t.Fatalf("manifest fixture reviewer checklist entry is not an object: %#v", item) + } + id, _ := entry["id"].(string) + seen[id] = true + } + for _, id := range []string{"package_scope", "included_evidence", "excluded_evidence", "hash_signature_verification", "non_claims", "escalation_path"} { + if !seen[id] { + t.Fatalf("manifest fixture reviewer checklist missing %q: %#v", id, checklist) + } + } + decisions, ok := manifest["vulnerability_decisions"].([]any) + if !ok || len(decisions) == 0 { + t.Fatalf("manifest fixture missing vulnerability decisions: %#v", manifest["vulnerability_decisions"]) + } + firstDecision, ok := decisions[0].(map[string]any) + if !ok { + t.Fatalf("manifest fixture decision is not an object: %#v", decisions[0]) + } + for _, key := range []string{"reviewed_at", "review_due_at", "sbom_id", "sbom_component_purl", "sbom_component_name", "supporting_refs"} { + if firstDecision[key] == "" || firstDecision[key] == nil { + t.Fatalf("manifest fixture decision missing %q: %#v", key, firstDecision) + } + } + export, ok := manifest["customer_decision_export"].(map[string]any) + if !ok || export["file"] != "vulnerability-decisions.json" || export["schema_version"] != "customer-vulnerability-decisions.v1.0.0" { + t.Fatalf("manifest fixture customer decision export missing expected metadata: %#v", manifest["customer_decision_export"]) + } +} diff --git a/internal/app/enterprise.go b/internal/app/enterprise.go index 36c6c7f..ee9ebc4 100644 --- a/internal/app/enterprise.go +++ b/internal/app/enterprise.go @@ -2,6 +2,13 @@ package app import ( "context" + "crypto/rsa" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "fmt" + "sort" "strings" "time" @@ -28,12 +35,19 @@ type CreateRoleBindingInput struct { } type CreateSSOProviderInput struct { - Name string - Type string - Issuer string - ClientID string - GroupsClaim string - RoleMapping map[string]string + Name string + Type string + Issuer string + ClientID string + GroupsClaim string + RoleMapping map[string]string + JWKS map[string]any + SAMLSigningCertificates []string +} + +type UpdateSSOProviderTrustMaterialInput struct { + JWKS map[string]any + SAMLSigningCertificates []string } type LinkSSOIdentityInput struct { @@ -50,6 +64,14 @@ type CreateSSOSessionInput struct { ExpiresAt time.Time } +type ExchangeSSOCredentialInput struct { + ProviderID string + Subject string + IDToken string + SAMLAssertion string + ExpiresAt time.Time +} + type CreateLegalHoldInput struct { ScopeType string ScopeID string @@ -66,9 +88,18 @@ type CreateRetentionOverrideInput struct { } type CreateCustomerPortalAccessInput struct { - PackageID string - CustomerName string - ExpiresAt time.Time + PackageID string + CustomerName string + ReviewerName string + ReviewerEmail string + RequireNDA bool + Watermark string + ExpiresAt time.Time +} + +type CustomerPortalAcceptanceInput struct { + NDAAccepted bool + NDAAcceptedBy string } type CreateQuestionnaireTemplateInput struct { @@ -84,6 +115,23 @@ type CreateQuestionnairePackageInput struct { ReleaseID string } +type CreateQuestionnaireAnswerLibraryEntryInput struct { + QuestionID string + EvidenceType string + ControlID string + ProductID string + ReleaseID string + Answer string + EvidenceIDs []string + Limitations []string +} + +type ListQuestionnaireAnswerLibraryInput struct { + QuestionID string + ProductID string + ReleaseID string +} + type CreateCommercialCollectorInput struct { Name string Provider string @@ -92,7 +140,8 @@ type CreateCommercialCollectorInput struct { AllowedScopes []string } -func (l *Ledger) CreateOrganization(ctx context.Context, actor domain.Actor, in CreateOrganizationInput) (domain.Organization, error) { +func (s identityService) CreateOrganization(ctx context.Context, actor domain.Actor, in CreateOrganizationInput) (domain.Organization, error) { + l := s.ledger if err := ctx.Err(); err != nil { return domain.Organization{}, err } @@ -119,7 +168,8 @@ func (l *Ledger) CreateOrganization(ctx context.Context, actor domain.Actor, in return org, nil } -func (l *Ledger) CreateUser(ctx context.Context, actor domain.Actor, in CreateUserInput) (domain.HumanUser, error) { +func (s identityService) CreateUser(ctx context.Context, actor domain.Actor, in CreateUserInput) (domain.HumanUser, error) { + l := s.ledger if err := ctx.Err(); err != nil { return domain.HumanUser{}, err } @@ -153,7 +203,8 @@ func (l *Ledger) CreateUser(ctx context.Context, actor domain.Actor, in CreateUs return user, nil } -func (l *Ledger) DeactivateUser(ctx context.Context, actor domain.Actor, id string) (domain.HumanUser, error) { +func (s identityService) DeactivateUser(ctx context.Context, actor domain.Actor, id string) (domain.HumanUser, error) { + l := s.ledger if err := ctx.Err(); err != nil { return domain.HumanUser{}, err } @@ -180,7 +231,8 @@ func (l *Ledger) DeactivateUser(ctx context.Context, actor domain.Actor, id stri return user, nil } -func (l *Ledger) CreateRoleBinding(ctx context.Context, actor domain.Actor, in CreateRoleBindingInput) (domain.RoleBinding, error) { +func (s identityService) CreateRoleBinding(ctx context.Context, actor domain.Actor, in CreateRoleBindingInput) (domain.RoleBinding, error) { + l := s.ledger if err := ctx.Err(); err != nil { return domain.RoleBinding{}, err } @@ -197,6 +249,9 @@ func (l *Ledger) CreateRoleBinding(ctx context.Context, actor domain.Actor, in C if err := l.ensureRoleSubjectLocked(actor.TenantID, in.SubjectType, in.SubjectID); err != nil { return domain.RoleBinding{}, err } + if err := l.ensureRoleResourceLocked(actor.TenantID, in.ResourceType, in.ResourceID); err != nil { + return domain.RoleBinding{}, err + } binding := domain.RoleBinding{ID: newID("rbac"), TenantID: actor.TenantID, SubjectType: in.SubjectType, SubjectID: in.SubjectID, Role: in.Role, ResourceType: in.ResourceType, ResourceID: in.ResourceID, SchemaVersion: domain.RoleBindingSchemaVersion, CreatedAt: l.now()} l.roleBindings[binding.ID] = binding _, _ = l.appendChainLocked(actor.TenantID, "role_binding.created", "role_binding", binding.ID, actorType(actor), actorID(actor), "", "") @@ -206,7 +261,8 @@ func (l *Ledger) CreateRoleBinding(ctx context.Context, actor domain.Actor, in C return binding, nil } -func (l *Ledger) ListRoleBindings(ctx context.Context, actor domain.Actor) ([]domain.RoleBinding, error) { +func (s identityService) ListRoleBindings(ctx context.Context, actor domain.Actor) ([]domain.RoleBinding, error) { + l := s.ledger if err := ctx.Err(); err != nil { return nil, err } @@ -224,7 +280,8 @@ func (l *Ledger) ListRoleBindings(ctx context.Context, actor domain.Actor) ([]do return out, nil } -func (l *Ledger) CreateSSOProvider(ctx context.Context, actor domain.Actor, in CreateSSOProviderInput) (domain.SSOProvider, error) { +func (s identityService) CreateSSOProvider(ctx context.Context, actor domain.Actor, in CreateSSOProviderInput) (domain.SSOProvider, error) { + l := s.ledger if err := ctx.Err(); err != nil { return domain.SSOProvider{}, err } @@ -237,7 +294,15 @@ func (l *Ledger) CreateSSOProvider(ctx context.Context, actor domain.Actor, in C } l.mu.Lock() defer l.mu.Unlock() - provider := domain.SSOProvider{ID: newID("sso"), TenantID: actor.TenantID, Name: in.Name, Type: in.Type, Issuer: in.Issuer, ClientID: in.ClientID, GroupsClaim: strings.TrimSpace(in.GroupsClaim), RoleMapping: cloneStringMap(in.RoleMapping), Status: "active", SchemaVersion: domain.SSOProviderSchemaVersion, CreatedAt: l.now()} + jwks, err := normalizeJWKS(in.JWKS) + if err != nil { + return domain.SSOProvider{}, ErrValidation + } + samlCerts, err := normalizeSAMLSigningCertificates(in.SAMLSigningCertificates) + if err != nil { + return domain.SSOProvider{}, ErrValidation + } + provider := domain.SSOProvider{ID: newID("sso"), TenantID: actor.TenantID, Name: in.Name, Type: in.Type, Issuer: in.Issuer, ClientID: in.ClientID, GroupsClaim: strings.TrimSpace(in.GroupsClaim), RoleMapping: cloneStringMap(in.RoleMapping), JWKS: jwks, SAMLSigningCertificates: samlCerts, Status: "active", SchemaVersion: domain.SSOProviderSchemaVersion, CreatedAt: l.now()} l.ssoProviders[provider.ID] = provider _, _ = l.appendChainLocked(actor.TenantID, "sso_provider.created", "sso_provider", provider.ID, actorType(actor), actorID(actor), "", "") if err := l.persistLocked(ctx); err != nil { @@ -246,7 +311,152 @@ func (l *Ledger) CreateSSOProvider(ctx context.Context, actor domain.Actor, in C return provider, nil } -func (l *Ledger) LinkSSOIdentity(ctx context.Context, actor domain.Actor, in LinkSSOIdentityInput) (domain.UserIdentityLink, error) { +func (s identityService) UpdateSSOProviderTrustMaterial(ctx context.Context, actor domain.Actor, id string, in UpdateSSOProviderTrustMaterialInput) (domain.SSOProvider, error) { + l := s.ledger + if err := ctx.Err(); err != nil { + return domain.SSOProvider{}, err + } + if err := require(actor, ScopeIdentityAdmin); err != nil { + return domain.SSOProvider{}, err + } + id = strings.TrimSpace(id) + if id == "" { + return domain.SSOProvider{}, ErrValidation + } + jwks, err := normalizeJWKS(in.JWKS) + if err != nil { + return domain.SSOProvider{}, ErrValidation + } + samlCerts, err := normalizeSAMLSigningCertificates(in.SAMLSigningCertificates) + if err != nil { + return domain.SSOProvider{}, ErrValidation + } + l.mu.Lock() + defer l.mu.Unlock() + provider, ok := l.ssoProviders[id] + if !ok || provider.TenantID != actor.TenantID { + return domain.SSOProvider{}, ErrNotFound + } + switch provider.Type { + case "oidc": + if len(jwks) == 0 || len(samlCerts) != 0 { + return domain.SSOProvider{}, ErrValidation + } + provider.JWKS = jwks + provider.SAMLSigningCertificates = nil + case "saml": + if len(samlCerts) == 0 || len(jwks) != 0 { + return domain.SSOProvider{}, ErrValidation + } + provider.SAMLSigningCertificates = samlCerts + provider.JWKS = nil + default: + return domain.SSOProvider{}, ErrValidation + } + now := l.now() + provider.TrustMaterialUpdatedAt = &now + materialHash, err := canonicalAnyHash(struct { + ProviderID string `json:"provider_id"` + JWKS map[string]any `json:"jwks,omitempty"` + SAMLSigningCertificates []string `json:"saml_signing_certificates,omitempty"` + UpdatedAt string `json:"updated_at"` + }{ + ProviderID: provider.ID, + JWKS: provider.JWKS, + SAMLSigningCertificates: provider.SAMLSigningCertificates, + UpdatedAt: now.Format(time.RFC3339Nano), + }) + if err != nil { + return domain.SSOProvider{}, err + } + l.ssoProviders[provider.ID] = provider + _, _ = l.appendChainLocked(actor.TenantID, "sso_provider.trust_material_updated", "sso_provider", provider.ID, actorType(actor), actorID(actor), materialHash, "") + if err := l.persistLocked(ctx); err != nil { + return domain.SSOProvider{}, err + } + return provider, nil +} + +func (s identityService) RefreshSSOProviderOIDCTrustMaterial(ctx context.Context, actor domain.Actor, id string) (domain.SSOProvider, error) { + l := s.ledger + if err := ctx.Err(); err != nil { + return domain.SSOProvider{}, err + } + if err := require(actor, ScopeIdentityAdmin); err != nil { + return domain.SSOProvider{}, err + } + id = strings.TrimSpace(id) + if id == "" { + return domain.SSOProvider{}, ErrValidation + } + discovery := l.oidc + if discovery == nil { + return domain.SSOProvider{}, ErrValidation + } + l.mu.Lock() + provider, ok := l.ssoProviders[id] + if !ok || provider.TenantID != actor.TenantID { + l.mu.Unlock() + return domain.SSOProvider{}, ErrNotFound + } + if provider.Type != "oidc" { + l.mu.Unlock() + return domain.SSOProvider{}, ErrValidation + } + l.mu.Unlock() + + result, err := discovery.FetchOIDCTrustMaterial(ctx, OIDCDiscoveryRequest{TenantID: actor.TenantID, ProviderID: provider.ID, Issuer: provider.Issuer}) + if err != nil { + return domain.SSOProvider{}, ErrVerificationFailed + } + if strings.TrimRight(strings.TrimSpace(result.Issuer), "/") != strings.TrimRight(provider.Issuer, "/") { + return domain.SSOProvider{}, ErrVerificationFailed + } + jwks, err := normalizeJWKS(result.JWKS) + if err != nil { + return domain.SSOProvider{}, ErrVerificationFailed + } + if len(jwks) == 0 { + return domain.SSOProvider{}, ErrVerificationFailed + } + + l.mu.Lock() + defer l.mu.Unlock() + current, ok := l.ssoProviders[id] + if !ok || current.TenantID != actor.TenantID { + return domain.SSOProvider{}, ErrNotFound + } + if current.Type != "oidc" || current.Issuer != provider.Issuer { + return domain.SSOProvider{}, ErrVerificationFailed + } + now := l.now() + current.JWKS = jwks + current.SAMLSigningCertificates = nil + current.TrustMaterialUpdatedAt = &now + materialHash, err := canonicalAnyHash(struct { + ProviderID string `json:"provider_id"` + Issuer string `json:"issuer"` + JWKS map[string]any `json:"jwks"` + UpdatedAt string `json:"updated_at"` + }{ + ProviderID: current.ID, + Issuer: current.Issuer, + JWKS: current.JWKS, + UpdatedAt: now.Format(time.RFC3339Nano), + }) + if err != nil { + return domain.SSOProvider{}, err + } + l.ssoProviders[current.ID] = current + _, _ = l.appendChainLocked(actor.TenantID, "sso_provider.oidc_trust_material_refreshed", "sso_provider", current.ID, actorType(actor), actorID(actor), materialHash, "") + if err := l.persistLocked(ctx); err != nil { + return domain.SSOProvider{}, err + } + return current, nil +} + +func (s identityService) LinkSSOIdentity(ctx context.Context, actor domain.Actor, in LinkSSOIdentityInput) (domain.UserIdentityLink, error) { + l := s.ledger if err := ctx.Err(); err != nil { return domain.UserIdentityLink{}, err } @@ -277,7 +487,8 @@ func (l *Ledger) LinkSSOIdentity(ctx context.Context, actor domain.Actor, in Lin return link, nil } -func (l *Ledger) CreateSSOSession(ctx context.Context, actor domain.Actor, in CreateSSOSessionInput) (domain.SSOSession, string, error) { +func (s identityService) CreateSSOSession(ctx context.Context, actor domain.Actor, in CreateSSOSessionInput) (domain.SSOSession, string, error) { + l := s.ledger if err := ctx.Err(); err != nil { return domain.SSOSession{}, "", err } @@ -301,14 +512,134 @@ func (l *Ledger) CreateSSOSession(ctx context.Context, actor domain.Actor, in Cr session := domain.SSOSession{ID: newID("sess"), TenantID: actor.TenantID, UserID: user.ID, ProviderID: provider.ID, Prefix: secretPrefix(secret), ExpiresAt: in.ExpiresAt.UTC(), SchemaVersion: domain.SSOSessionSchemaVersion, CreatedAt: l.now(), Hash: l.hashSecret(secret)} l.ssoSessions[session.ID] = session _, _ = l.appendChainLocked(actor.TenantID, "sso_session.created", "human_user", user.ID, actorType(actor), actorID(actor), "", "") - if err := l.persistLocked(ctx); err != nil { + if err := l.persistCriticalLocked(ctx, l.criticalMutationLocked()); err != nil { return domain.SSOSession{}, "", err } session.Hash = "" return session, secret, nil } -func (l *Ledger) RevokeSSOSession(ctx context.Context, actor domain.Actor, id string) (domain.SSOSession, error) { +func (s identityService) ExchangeSSOCredential(ctx context.Context, in ExchangeSSOCredentialInput) (domain.ProviderVerification, domain.SSOSession, string, error) { + l := s.ledger + if err := ctx.Err(); err != nil { + return domain.ProviderVerification{}, domain.SSOSession{}, "", err + } + providerID, subject := strings.TrimSpace(in.ProviderID), strings.TrimSpace(in.Subject) + idToken, samlAssertion := strings.TrimSpace(in.IDToken), strings.TrimSpace(in.SAMLAssertion) + if providerID == "" || subject == "" || (idToken == "" && samlAssertion == "") || (idToken != "" && samlAssertion != "") { + return domain.ProviderVerification{}, domain.SSOSession{}, "", ErrValidation + } + expiresAt := in.ExpiresAt.UTC() + now := l.now() + if expiresAt.IsZero() { + expiresAt = now.Add(8 * time.Hour) + } + if !expiresAt.After(now) || expiresAt.After(now.Add(12*time.Hour)) { + return domain.ProviderVerification{}, domain.SSOSession{}, "", ErrValidation + } + + l.mu.Lock() + defer l.mu.Unlock() + provider, ok := l.ssoProviders[providerID] + if !ok || provider.Status != "active" { + return domain.ProviderVerification{}, domain.SSOSession{}, "", ErrNotFound + } + if (provider.Type == "oidc" && samlAssertion != "") || (provider.Type == "saml" && idToken != "") { + return domain.ProviderVerification{}, domain.SSOSession{}, "", ErrValidation + } + checks := []domain.VerifyCheck{} + result := "passed" + if idToken != "" { + tokenChecks, err := verifyOIDCIDToken(provider, subject, idToken, now) + checks = append(checks, tokenChecks...) + if err != nil { + result = "failed" + } + } + if samlAssertion != "" { + assertionChecks, err := verifySAMLAssertion(provider, subject, samlAssertion, now) + checks = append(checks, assertionChecks...) + if err != nil { + result = "failed" + } + } + + var link domain.UserIdentityLink + for _, candidate := range l.identityLinks { + if candidate.TenantID == provider.TenantID && candidate.ProviderID == provider.ID && candidate.Subject == subject && candidate.Verified { + link = candidate + break + } + } + if link.ID == "" { + result = "failed" + checks = append(checks, domain.VerifyCheck{Name: "verified_identity_link", Result: "failed"}) + } else { + checks = append(checks, domain.VerifyCheck{Name: "verified_identity_link", Result: "passed"}) + } + + verification := domain.ProviderVerification{ + ID: newID("pvr"), + TenantID: provider.TenantID, + ProviderType: provider.Type, + ProviderID: provider.ID, + Subject: subject, + Result: result, + Checks: checks, + Limitations: []string{"Credential exchange uses configured local token/assertion trust roots and verified identity links; no live provider API or group synchronization call is made."}, + SchemaVersion: domain.ProviderVerificationVersion, + CreatedAt: now, + } + l.providerVerifications[verification.ID] = verification + _, _ = l.appendChainLocked(provider.TenantID, "provider_identity.verified", "provider_identity", verification.ID, "sso_provider", provider.ID, "", "") + if result != "passed" { + if err := l.persistLocked(ctx); err != nil { + return domain.ProviderVerification{}, domain.SSOSession{}, "", err + } + return verification, domain.SSOSession{}, "", ErrVerificationFailed + } + user, ok := l.users[link.UserID] + if !ok || user.TenantID != provider.TenantID || user.Status != "active" { + verification.Result = "failed" + verification.Checks = append(verification.Checks, domain.VerifyCheck{Name: "active_user", Result: "failed"}) + l.providerVerifications[verification.ID] = verification + if err := l.persistLocked(ctx); err != nil { + return domain.ProviderVerification{}, domain.SSOSession{}, "", err + } + return verification, domain.SSOSession{}, "", ErrVerificationFailed + } + verification.Checks = append(verification.Checks, domain.VerifyCheck{Name: "active_user", Result: "passed"}) + l.providerVerifications[verification.ID] = verification + + groups := oidcGroupsFromVerifiedToken(provider, idToken) + grants := append(l.resourceGrantsForUserLocked(user.ID), resourceGrantsForProviderGroups(provider, groups)...) + if len(scopesFromResourceGrants(grants)) == 0 { + verification.Result = "failed" + verification.Checks = append(verification.Checks, domain.VerifyCheck{Name: "authorization_grant", Result: "failed"}) + l.providerVerifications[verification.ID] = verification + if err := l.persistLocked(ctx); err != nil { + return domain.ProviderVerification{}, domain.SSOSession{}, "", err + } + return verification, domain.SSOSession{}, "", ErrForbidden + } + if len(groups) > 0 && len(resourceGrantsForProviderGroups(provider, groups)) > 0 { + verification.Checks = append(verification.Checks, domain.VerifyCheck{Name: "mapped_group_roles", Result: "passed", Detail: fmt.Sprintf("%d session-scoped provider group role mapping(s) applied", len(resourceGrantsForProviderGroups(provider, groups)))}) + } + l.providerVerifications[verification.ID] = verification + + secret := "evysso_" + randomToken(32) + session := domain.SSOSession{ID: newID("sess"), TenantID: provider.TenantID, UserID: user.ID, ProviderID: provider.ID, Prefix: secretPrefix(secret), Groups: groups, ExpiresAt: expiresAt, SchemaVersion: domain.SSOSessionSchemaVersion, CreatedAt: now, Hash: l.hashSecret(secret)} + l.ssoSessions[session.ID] = session + _, _ = l.appendChainLocked(provider.TenantID, "sso_session.created", "human_user", user.ID, "sso_provider", provider.ID, "", "") + if err := l.persistCriticalLocked(ctx, l.criticalMutationLocked()); err != nil { + return domain.ProviderVerification{}, domain.SSOSession{}, "", err + } + session.Hash = "" + return verification, session, secret, nil +} + +func (s identityService) RevokeSSOSession(ctx context.Context, actor domain.Actor, id string) (domain.SSOSession, error) { + l := s.ledger if err := ctx.Err(); err != nil { return domain.SSOSession{}, err } @@ -328,7 +659,35 @@ func (l *Ledger) RevokeSSOSession(ctx context.Context, actor domain.Actor, id st session.RevokedAt = &now l.ssoSessions[session.ID] = session _, _ = l.appendChainLocked(actor.TenantID, "sso_session.revoked", "sso_session", session.ID, actorType(actor), actorID(actor), "", "") - if err := l.persistLocked(ctx); err != nil { + if err := l.persistCriticalLocked(ctx, l.criticalMutationLocked()); err != nil { + return domain.SSOSession{}, err + } + session.Hash = "" + return session, nil +} + +func (s identityService) RevokeCurrentSSOSession(ctx context.Context, actor domain.Actor) (domain.SSOSession, error) { + l := s.ledger + if err := ctx.Err(); err != nil { + return domain.SSOSession{}, err + } + if strings.TrimSpace(actor.UserID) == "" || strings.TrimSpace(actor.SessionID) == "" { + return domain.SSOSession{}, ErrForbidden + } + l.mu.Lock() + defer l.mu.Unlock() + session, ok := l.ssoSessions[strings.TrimSpace(actor.SessionID)] + if !ok || session.TenantID != actor.TenantID || session.UserID != actor.UserID { + return domain.SSOSession{}, ErrNotFound + } + if session.RevokedAt != nil { + return domain.SSOSession{}, ErrConflict + } + now := l.now() + session.RevokedAt = &now + l.ssoSessions[session.ID] = session + _, _ = l.appendChainLocked(actor.TenantID, "sso_session.revoked", "sso_session", session.ID, actorType(actor), actorID(actor), "", "") + if err := l.persistCriticalLocked(ctx, l.criticalMutationLocked()); err != nil { return domain.SSOSession{}, err } session.Hash = "" @@ -397,7 +756,8 @@ func (l *Ledger) CreateRetentionOverride(ctx context.Context, actor domain.Actor return override, nil } -func (l *Ledger) RetentionReport(ctx context.Context, actor domain.Actor, scopeType, scopeID string) (domain.RetentionReport, error) { +func (s packageReportService) RetentionReport(ctx context.Context, actor domain.Actor, scopeType, scopeID string) (domain.RetentionReport, error) { + l := s.ledger if err := ctx.Err(); err != nil { return domain.RetentionReport{}, err } @@ -421,17 +781,24 @@ func (l *Ledger) RetentionReport(ctx context.Context, actor domain.Actor, scopeT return domain.RetentionReport{ReportType: "retention", ScopeType: scopeType, ScopeID: scopeID, LegalHolds: holds, RetentionOverrides: overrides, Limitations: []string{"Retention reports describe Evydence records and do not replace external storage lifecycle verification."}, GeneratedAt: l.now()}, nil } -func (l *Ledger) CreateCustomerPortalAccess(ctx context.Context, actor domain.Actor, in CreateCustomerPortalAccessInput) (domain.CustomerPortalAccess, string, error) { +func (s identityService) CreateCustomerPortalAccess(ctx context.Context, actor domain.Actor, in CreateCustomerPortalAccessInput) (domain.CustomerPortalAccess, string, error) { + l := s.ledger if err := ctx.Err(); err != nil { return domain.CustomerPortalAccess{}, "", err } if err := require(actor, ScopePackageWrite); err != nil { return domain.CustomerPortalAccess{}, "", err } - in.PackageID, in.CustomerName = strings.TrimSpace(in.PackageID), strings.TrimSpace(in.CustomerName) + in.PackageID, in.CustomerName = strings.TrimSpace(in.PackageID), cleanExternalLabel(in.CustomerName) + in.ReviewerName = cleanExternalLabel(in.ReviewerName) + in.ReviewerEmail = cleanReviewerEmail(in.ReviewerEmail) + in.Watermark = cleanExternalLabel(in.Watermark) if in.PackageID == "" || in.CustomerName == "" || !in.ExpiresAt.After(l.now()) { return domain.CustomerPortalAccess{}, "", ErrValidation } + if in.ReviewerEmail != "" && !validReviewerEmail(in.ReviewerEmail) { + return domain.CustomerPortalAccess{}, "", ErrValidation + } l.mu.Lock() defer l.mu.Unlock() pkg, ok := l.customerPackages[in.PackageID] @@ -439,17 +806,96 @@ func (l *Ledger) CreateCustomerPortalAccess(ctx context.Context, actor domain.Ac return domain.CustomerPortalAccess{}, "", ErrNotFound } secret := "evycp_" + randomToken(32) - access := domain.CustomerPortalAccess{ID: newID("cpa"), TenantID: actor.TenantID, PackageID: pkg.ID, CustomerName: in.CustomerName, Prefix: secretPrefix(secret), ExpiresAt: in.ExpiresAt.UTC(), SchemaVersion: domain.CustomerPortalAccessVersion, CreatedAt: l.now(), Hash: l.hashSecret(secret)} + accessID := newID("cpa") + watermark := in.Watermark + if watermark == "" { + watermark = packageDistributionWatermark(pkg, portalReviewerLabel(in.CustomerName, in.ReviewerName, in.ReviewerEmail), accessID) + } + access := domain.CustomerPortalAccess{ID: accessID, TenantID: actor.TenantID, PackageID: pkg.ID, CustomerName: in.CustomerName, ReviewerName: in.ReviewerName, ReviewerEmail: in.ReviewerEmail, RequireNDA: in.RequireNDA, Watermark: watermark, Prefix: secretPrefix(secret), ExpiresAt: in.ExpiresAt.UTC(), SchemaVersion: domain.CustomerPortalAccessVersion, CreatedAt: l.now(), Hash: l.hashSecret(secret)} l.portalAccess[access.ID] = access _, _ = l.appendChainLocked(actor.TenantID, "customer_portal_access.created", "customer_security_package", pkg.ID, actorType(actor), actorID(actor), "", "") - if err := l.persistLocked(ctx); err != nil { + if err := l.persistCriticalLocked(ctx, l.criticalMutationLocked()); err != nil { return domain.CustomerPortalAccess{}, "", err } access.Hash = "" return access, secret, nil } -func (l *Ledger) AccessCustomerPortalPackage(ctx context.Context, token string) (domain.CustomerSecurityPackage, error) { +func (s identityService) ListCustomerPortalAccess(ctx context.Context, actor domain.Actor, packageID string) ([]domain.CustomerPortalAccess, error) { + l := s.ledger + if err := ctx.Err(); err != nil { + return nil, err + } + if err := require(actor, ScopePackageRead); err != nil { + return nil, err + } + packageID = strings.TrimSpace(packageID) + l.mu.Lock() + defer l.mu.Unlock() + if packageID != "" { + pkg, ok := l.customerPackages[packageID] + if !ok || pkg.TenantID != actor.TenantID { + return nil, ErrNotFound + } + } + accesses := []domain.CustomerPortalAccess{} + for _, access := range l.portalAccess { + if access.TenantID != actor.TenantID || (packageID != "" && access.PackageID != packageID) { + continue + } + access.Hash = "" + accesses = append(accesses, access) + } + sort.Slice(accesses, func(i, j int) bool { + if accesses[i].CreatedAt.Equal(accesses[j].CreatedAt) { + return accesses[i].ID < accesses[j].ID + } + return accesses[i].CreatedAt.Before(accesses[j].CreatedAt) + }) + return accesses, nil +} + +func (s identityService) RevokeCustomerPortalAccess(ctx context.Context, actor domain.Actor, id string) (domain.CustomerPortalAccess, error) { + l := s.ledger + if err := ctx.Err(); err != nil { + return domain.CustomerPortalAccess{}, err + } + if err := require(actor, ScopePackageWrite); err != nil { + return domain.CustomerPortalAccess{}, err + } + id = strings.TrimSpace(id) + if id == "" { + return domain.CustomerPortalAccess{}, ErrValidation + } + l.mu.Lock() + defer l.mu.Unlock() + access, ok := l.portalAccess[id] + if !ok || access.TenantID != actor.TenantID { + return domain.CustomerPortalAccess{}, ErrNotFound + } + if access.RevokedAt == nil { + now := l.now() + access.RevokedAt = &now + l.portalAccess[id] = access + _, _ = l.appendChainLocked(access.TenantID, "customer_portal_access.revoked", "customer_portal_access", access.ID, actorType(actor), actorID(actor), "", "") + if err := l.persistCriticalLocked(ctx, l.criticalMutationLocked()); err != nil { + return domain.CustomerPortalAccess{}, err + } + } + access.Hash = "" + return access, nil +} + +func (s identityService) AccessCustomerPortalPackage(ctx context.Context, token string) (domain.CustomerSecurityPackage, error) { + return s.AccessCustomerPortalPackageWithAcceptance(ctx, token, CustomerPortalAcceptanceInput{}) +} + +func (s identityService) AccessCustomerPortalPackageWithAcceptance(ctx context.Context, token string, in CustomerPortalAcceptanceInput) (domain.CustomerSecurityPackage, error) { + return s.accessCustomerPortalPackage(ctx, token, in, "customer_portal_package.accessed") +} + +func (s identityService) accessCustomerPortalPackage(ctx context.Context, token string, in CustomerPortalAcceptanceInput, successEntryType string) (domain.CustomerSecurityPackage, error) { + l := s.ledger if err := ctx.Err(); err != nil { return domain.CustomerSecurityPackage{}, err } @@ -457,29 +903,77 @@ func (l *Ledger) AccessCustomerPortalPackage(ctx context.Context, token string) if token == "" { return domain.CustomerSecurityPackage{}, ErrUnauthorized } + prefix := secretPrefix(token) hash := l.hashSecret(token) l.mu.Lock() defer l.mu.Unlock() for id, access := range l.portalAccess { - if access.Hash != hash || access.RevokedAt != nil || !access.ExpiresAt.After(l.now()) { + if !secretHashEqual(access.Hash, hash) || access.RevokedAt != nil || !access.ExpiresAt.After(l.now()) { + if access.Prefix == prefix && access.RevokedAt == nil && access.ExpiresAt.After(l.now()) { + now := l.now() + access.FailedAccessCount++ + if access.RevokedAt == nil && access.FailedAccessCount >= customerPortalFailedAccessLimit { + access.RevokedAt = &now + } + access.LastFailedAt = &now + l.portalAccess[id] = access + _, _ = l.appendChainLocked(access.TenantID, "customer_portal_package.access_failed", "customer_portal_access", access.ID, "customer_portal", "unverified", "", "") + if access.RevokedAt != nil && access.FailedAccessCount == customerPortalFailedAccessLimit { + _, _ = l.appendChainLocked(access.TenantID, "customer_portal_access.revoked_after_failed_access", "customer_portal_access", access.ID, "customer_portal", "unverified", "", "") + } + _ = l.persistCriticalLocked(ctx, l.criticalMutationLocked()) + } continue } pkg, ok := l.customerPackages[access.PackageID] if !ok || pkg.TenantID != access.TenantID || !pkg.ExpiresAt.After(l.now()) { return domain.CustomerSecurityPackage{}, ErrNotFound } + if access.RequireNDA && access.NDAAcceptedAt == nil { + acceptedBy := cleanExternalLabel(in.NDAAcceptedBy) + if !in.NDAAccepted || acceptedBy == "" { + _, _ = l.appendChainLocked(access.TenantID, "customer_portal_package.nda_required", "customer_portal_access", access.ID, "customer_portal", access.ID, pkg.ManifestHash, "") + _ = l.persistCriticalLocked(ctx, l.criticalMutationLocked()) + return domain.CustomerSecurityPackage{}, ErrForbidden + } + now := l.now() + access.NDAAcceptedAt = &now + access.NDAAcceptedBy = acceptedBy + l.portalAccess[id] = access + _, _ = l.appendChainLocked(access.TenantID, "customer_portal_package.nda_accepted", "customer_portal_access", access.ID, "customer_portal", access.ID, pkg.ManifestHash, "") + } access.AccessCount++ + now := l.now() + access.LastAccessedAt = &now l.portalAccess[id] = access - _, _ = l.appendChainLocked(access.TenantID, "customer_portal_package.accessed", "customer_security_package", pkg.ID, "customer_portal", access.ID, pkg.ManifestHash, "") - if err := l.persistLocked(ctx); err != nil { + l.appendCustomerPortalAccessEventLocked(successEntryType, access, pkg) + if err := l.persistCriticalLocked(ctx, l.criticalMutationLocked()); err != nil { return domain.CustomerSecurityPackage{}, err } - return pkg, nil + return packageWithDistributionWatermark(pkg, access), nil } return domain.CustomerSecurityPackage{}, ErrUnauthorized } -func (l *Ledger) CreateQuestionnaireTemplate(ctx context.Context, actor domain.Actor, in CreateQuestionnaireTemplateInput) (domain.QuestionnaireTemplate, error) { +func (l *Ledger) appendCustomerPortalAccessEventLocked(entryType string, access domain.CustomerPortalAccess, pkg domain.CustomerSecurityPackage) { + _, _ = l.appendChainLocked(access.TenantID, entryType, "customer_portal_access", access.ID, "customer_portal", access.ID, pkg.ManifestHash, "") + _, _ = l.appendChainLocked(access.TenantID, entryType, "customer_security_package", pkg.ID, "customer_portal", access.ID, pkg.ManifestHash, "") +} + +func cleanReviewerEmail(value string) string { + return strings.ToLower(cleanExternalLabel(value)) +} + +func validReviewerEmail(value string) bool { + if strings.ContainsAny(value, " \t\r\n") { + return false + } + at := strings.IndexByte(value, '@') + return at > 0 && at < len(value)-1 && strings.Contains(value[at+1:], ".") +} + +func (s packageReportService) CreateQuestionnaireTemplate(ctx context.Context, actor domain.Actor, in CreateQuestionnaireTemplateInput) (domain.QuestionnaireTemplate, error) { + l := s.ledger if err := ctx.Err(); err != nil { return domain.QuestionnaireTemplate{}, err } @@ -511,7 +1005,8 @@ func (l *Ledger) CreateQuestionnaireTemplate(ctx context.Context, actor domain.A return tpl, nil } -func (l *Ledger) CreateQuestionnairePackage(ctx context.Context, actor domain.Actor, in CreateQuestionnairePackageInput) (domain.QuestionnairePackage, error) { +func (s packageReportService) CreateQuestionnairePackage(ctx context.Context, actor domain.Actor, in CreateQuestionnairePackageInput) (domain.QuestionnairePackage, error) { + l := s.ledger if err := ctx.Err(); err != nil { return domain.QuestionnairePackage{}, err } @@ -539,17 +1034,7 @@ func (l *Ledger) CreateQuestionnairePackage(ctx context.Context, actor domain.Ac } responses := []domain.QuestionnaireResponse{} for _, question := range tpl.Questions { - evidenceIDs := []string{} - for _, item := range l.evidence { - if item.TenantID == actor.TenantID && (in.ProductID == "" || item.ProductID == in.ProductID) && (in.ReleaseID == "" || item.ReleaseID == in.ReleaseID) && (question.EvidenceType == "" || item.Type == question.EvidenceType) { - evidenceIDs = append(evidenceIDs, item.ID) - } - } - answer := "No matching evidence was linked for this question." - if len(evidenceIDs) > 0 { - answer = "Evidence is available for review in the linked evidence records." - } - responses = append(responses, domain.QuestionnaireResponse{QuestionID: question.ID, Answer: answer, EvidenceIDs: sortedStrings(evidenceIDs), Limitations: []string{"Questionnaire responses summarize recorded evidence and require human review."}}) + responses = append(responses, l.questionnaireResponseForQuestionLocked(actor.TenantID, question, in.ProductID, in.ReleaseID)) } hash, err := canonicalAnyHash(responses) if err != nil { @@ -564,6 +1049,107 @@ func (l *Ledger) CreateQuestionnairePackage(ctx context.Context, actor domain.Ac return pkg, nil } +func (s packageReportService) CreateQuestionnaireAnswerLibraryEntry(ctx context.Context, actor domain.Actor, in CreateQuestionnaireAnswerLibraryEntryInput) (domain.QuestionnaireAnswerLibraryEntry, error) { + l := s.ledger + if err := ctx.Err(); err != nil { + return domain.QuestionnaireAnswerLibraryEntry{}, err + } + if err := require(actor, ScopePackageWrite); err != nil { + return domain.QuestionnaireAnswerLibraryEntry{}, err + } + in.QuestionID, in.EvidenceType, in.ControlID = strings.TrimSpace(in.QuestionID), strings.TrimSpace(in.EvidenceType), strings.TrimSpace(in.ControlID) + in.ProductID, in.ReleaseID = strings.TrimSpace(in.ProductID), strings.TrimSpace(in.ReleaseID) + in.Answer = strings.TrimSpace(in.Answer) + if in.Answer == "" || (in.QuestionID == "" && in.EvidenceType == "" && in.ControlID == "") { + return domain.QuestionnaireAnswerLibraryEntry{}, ErrValidation + } + l.mu.Lock() + defer l.mu.Unlock() + if in.ProductID != "" || in.ReleaseID != "" { + if err := l.ensureScopeLocked(actor.TenantID, in.ProductID, "", in.ReleaseID); err != nil { + return domain.QuestionnaireAnswerLibraryEntry{}, err + } + if err := l.authorizeResourceLocked(actor, ScopePackageWrite, resourceRefs{ProductID: in.ProductID, ReleaseID: in.ReleaseID}); err != nil { + return domain.QuestionnaireAnswerLibraryEntry{}, err + } + } + if in.ControlID != "" { + control, ok := l.controls[in.ControlID] + if !ok || control.TenantID != actor.TenantID { + return domain.QuestionnaireAnswerLibraryEntry{}, ErrNotFound + } + } + evidenceIDs := sortedStrings(in.EvidenceIDs) + for _, id := range evidenceIDs { + item, ok := l.evidence[id] + if !ok || item.TenantID != actor.TenantID || !evidenceMatchesRefs(item, resourceRefs{ProductID: in.ProductID, ReleaseID: in.ReleaseID}) { + return domain.QuestionnaireAnswerLibraryEntry{}, ErrNotFound + } + } + entry := domain.QuestionnaireAnswerLibraryEntry{ + ID: newID("qal"), + TenantID: actor.TenantID, + QuestionID: in.QuestionID, + EvidenceType: in.EvidenceType, + ControlID: in.ControlID, + ProductID: in.ProductID, + ReleaseID: in.ReleaseID, + Answer: in.Answer, + EvidenceIDs: evidenceIDs, + Limitations: sortedStrings(in.Limitations), + SchemaVersion: domain.QuestionnaireAnswerLibraryVersion, + CreatedAt: l.now(), + } + if len(entry.Limitations) == 0 { + entry.Limitations = []string{"Answer library entries are reusable drafts and require human review before external use."} + } + l.answerLibrary[entry.ID] = entry + _, _ = l.appendChainLocked(actor.TenantID, "questionnaire_answer_library.created", "questionnaire_answer_library", entry.ID, actorType(actor), actorID(actor), "", "") + if err := l.persistLocked(ctx); err != nil { + return domain.QuestionnaireAnswerLibraryEntry{}, err + } + return entry, nil +} + +func (s packageReportService) ListQuestionnaireAnswerLibrary(ctx context.Context, actor domain.Actor, in ListQuestionnaireAnswerLibraryInput) ([]domain.QuestionnaireAnswerLibraryEntry, error) { + l := s.ledger + if err := ctx.Err(); err != nil { + return nil, err + } + if err := require(actor, ScopePackageRead); err != nil { + return nil, err + } + in.QuestionID, in.ProductID, in.ReleaseID = strings.TrimSpace(in.QuestionID), strings.TrimSpace(in.ProductID), strings.TrimSpace(in.ReleaseID) + l.mu.Lock() + defer l.mu.Unlock() + if in.ProductID != "" || in.ReleaseID != "" { + if err := l.ensureScopeLocked(actor.TenantID, in.ProductID, "", in.ReleaseID); err != nil { + return nil, err + } + if err := l.authorizeResourceLocked(actor, ScopePackageRead, resourceRefs{ProductID: in.ProductID, ReleaseID: in.ReleaseID}); err != nil { + return nil, err + } + } + out := []domain.QuestionnaireAnswerLibraryEntry{} + for _, entry := range l.answerLibrary { + if entry.TenantID != actor.TenantID { + continue + } + if in.QuestionID != "" && entry.QuestionID != in.QuestionID { + continue + } + if in.ProductID != "" && entry.ProductID != "" && entry.ProductID != in.ProductID { + continue + } + if in.ReleaseID != "" && entry.ReleaseID != "" && entry.ReleaseID != in.ReleaseID { + continue + } + out = append(out, entry) + } + sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) + return out, nil +} + func (l *Ledger) CreateCommercialCollectorDefinition(ctx context.Context, actor domain.Actor, in CreateCommercialCollectorInput) (domain.CommercialCollectorDefinition, error) { if err := ctx.Err(); err != nil { return domain.CommercialCollectorDefinition{}, err @@ -646,6 +1232,51 @@ func (l *Ledger) ensureRetentionScopeLocked(tenantID, scopeType, scopeID string) return nil } +func (l *Ledger) ensureRoleResourceLocked(tenantID, resourceType, resourceID string) error { + switch resourceType { + case "": + if resourceID != "" { + return ErrValidation + } + case "tenant": + if resourceID == "" { + return nil + } + tenant, ok := l.tenants[resourceID] + if !ok || tenant.ID != tenantID { + return ErrNotFound + } + case "product": + product, ok := l.products[resourceID] + if resourceID == "" || !ok || product.TenantID != tenantID { + return ErrNotFound + } + case "project": + project, ok := l.projects[resourceID] + if resourceID == "" || !ok || project.TenantID != tenantID { + return ErrNotFound + } + case "release": + release, ok := l.releases[resourceID] + if resourceID == "" || !ok || release.TenantID != tenantID { + return ErrNotFound + } + case "customer_security_package": + pkg, ok := l.customerPackages[resourceID] + if resourceID == "" || !ok || pkg.TenantID != tenantID { + return ErrNotFound + } + case "evidence_bundle": + bundle, ok := l.evidenceBundles[resourceID] + if resourceID == "" || !ok || bundle.TenantID != tenantID { + return ErrNotFound + } + default: + return ErrValidation + } + return nil +} + func validRoleSubject(value string) bool { return value == "user" || value == "collector" } @@ -659,13 +1290,57 @@ func validRole(value string) bool { } } -func (l *Ledger) scopesForUserLocked(userID string) []string { - scopes := map[string]struct{}{} +func (l *Ledger) resourceGrantsForUserLocked(userID string) []domain.ResourceGrant { + grants := []domain.ResourceGrant{} for _, binding := range l.roleBindings { if binding.SubjectType != "user" || binding.SubjectID != userID { continue } - for _, scope := range scopesForRole(binding.Role) { + scopes := scopesForRole(binding.Role) + if len(scopes) == 0 { + continue + } + grants = append(grants, domain.ResourceGrant{ + Role: binding.Role, + ResourceType: binding.ResourceType, + ResourceID: binding.ResourceID, + Scopes: scopes, + }) + } + return grants +} + +func (l *Ledger) resourceGrantsForSSOSessionLocked(session domain.SSOSession) []domain.ResourceGrant { + provider, ok := l.ssoProviders[session.ProviderID] + if !ok || provider.TenantID != session.TenantID { + return nil + } + return resourceGrantsForProviderGroups(provider, session.Groups) +} + +func resourceGrantsForProviderGroups(provider domain.SSOProvider, groups []string) []domain.ResourceGrant { + if provider.GroupsClaim == "" || len(provider.RoleMapping) == 0 || len(groups) == 0 { + return nil + } + grants := []domain.ResourceGrant{} + for _, group := range groups { + role := strings.TrimSpace(provider.RoleMapping[group]) + if !validRole(role) { + continue + } + scopes := scopesForRole(role) + if len(scopes) == 0 { + continue + } + grants = append(grants, domain.ResourceGrant{Role: role, Scopes: scopes}) + } + return grants +} + +func scopesFromResourceGrants(grants []domain.ResourceGrant) []string { + scopes := map[string]struct{}{} + for _, grant := range grants { + for _, scope := range grant.Scopes { scopes[scope] = struct{}{} } } @@ -676,6 +1351,292 @@ func (l *Ledger) scopesForUserLocked(userID string) []string { return sortedStrings(out) } +type resourceRefs struct { + ProductID string + ProjectID string + ReleaseID string + ArtifactID string + BuildID string + DeploymentID string + EnvironmentID string + IncidentID string + SecurityScanID string + SourceRepositoryID string + CustomerPackageID string + EvidenceBundleID string +} + +func refsForEvidence(item domain.EvidenceItem) resourceRefs { + return resourceRefs{ProductID: item.ProductID, ProjectID: item.ProjectID, ReleaseID: item.ReleaseID} +} + +func (l *Ledger) authorizeResourceLocked(actor domain.Actor, scope string, refs resourceRefs) error { + if l.resourceAllowedLocked(actor, scope, refs) { + return nil + } + return ErrForbidden +} + +func (l *Ledger) resourceAllowedLocked(actor domain.Actor, scope string, refs resourceRefs) bool { + if !humanSessionActor(actor) { + return true + } + for _, grant := range actor.ResourceGrants { + if !grantHasScope(grant, scope) { + continue + } + if l.grantCoversResourceLocked(actor.TenantID, grant, refs) { + return true + } + } + return false +} + +func humanSessionActor(actor domain.Actor) bool { + return actor.UserID != "" && actor.KeyID == "" && actor.CollectorID == "" +} + +func grantHasScope(grant domain.ResourceGrant, scope string) bool { + for _, got := range grant.Scopes { + if got == "*" || got == scope || got == ScopeAdmin { + return true + } + } + return false +} + +func (l *Ledger) grantCoversResourceLocked(tenantID string, grant domain.ResourceGrant, refs resourceRefs) bool { + switch grant.ResourceType { + case "", "tenant": + return grant.ResourceID == "" || grant.ResourceID == tenantID + case "product": + return grant.ResourceID != "" && l.productCoversRefsLocked(tenantID, grant.ResourceID, refs) + case "project": + return grant.ResourceID != "" && l.projectCoversRefsLocked(tenantID, grant.ResourceID, refs) + case "release": + return grant.ResourceID != "" && l.releaseCoversRefsLocked(tenantID, grant.ResourceID, refs) + case "customer_security_package": + return refs.CustomerPackageID != "" && refs.CustomerPackageID == grant.ResourceID + case "evidence_bundle": + return refs.EvidenceBundleID != "" && refs.EvidenceBundleID == grant.ResourceID + default: + return false + } +} + +func (l *Ledger) productCoversRefsLocked(tenantID, productID string, refs resourceRefs) bool { + if refs == (resourceRefs{}) { + return false + } + if refs.ProductID != "" && refs.ProductID != productID { + return false + } + if refs.ProjectID != "" { + project, ok := l.projects[refs.ProjectID] + if !ok || project.TenantID != tenantID || project.ProductID != productID { + return false + } + } + if refs.SourceRepositoryID != "" { + repo, ok := l.repositories[refs.SourceRepositoryID] + if !ok || repo.TenantID != tenantID { + return false + } + if repo.ProjectID == "" { + return false + } + project, ok := l.projects[repo.ProjectID] + if !ok || project.TenantID != tenantID || project.ProductID != productID { + return false + } + } + if refs.ReleaseID != "" { + release, ok := l.releases[refs.ReleaseID] + if !ok || release.TenantID != tenantID || release.ProductID != productID { + return false + } + } + if refs.BuildID != "" { + build, ok := l.buildRuns[refs.BuildID] + if !ok || build.TenantID != tenantID { + return false + } + if build.ReleaseID != "" { + release, ok := l.releases[build.ReleaseID] + if !ok || release.TenantID != tenantID || release.ProductID != productID { + return false + } + } + if build.ProjectID != "" { + project, ok := l.projects[build.ProjectID] + if !ok || project.TenantID != tenantID || project.ProductID != productID { + return false + } + } + } + if refs.EnvironmentID != "" { + env, ok := l.environments[refs.EnvironmentID] + if !ok || env.TenantID != tenantID || env.ProductID != productID { + return false + } + } + if refs.DeploymentID != "" { + deployment, ok := l.deployments[refs.DeploymentID] + if !ok || deployment.TenantID != tenantID { + return false + } + release, ok := l.releases[deployment.ReleaseID] + if !ok || release.TenantID != tenantID || release.ProductID != productID { + return false + } + } + if refs.IncidentID != "" { + incident, ok := l.incidents[refs.IncidentID] + if !ok || incident.TenantID != tenantID || incident.ProductID != productID { + return false + } + } + if refs.SecurityScanID != "" { + scan, ok := l.securityScans[refs.SecurityScanID] + if !ok || scan.TenantID != tenantID { + return false + } + if scan.ProductID != "" && scan.ProductID != productID { + return false + } + if scan.ReleaseID != "" { + release, ok := l.releases[scan.ReleaseID] + if !ok || release.TenantID != tenantID || release.ProductID != productID { + return false + } + } + } + if refs.ArtifactID != "" && !l.artifactCoversProductLocked(tenantID, refs.ArtifactID, productID) { + return false + } + if refs.CustomerPackageID != "" { + pkg, ok := l.customerPackages[refs.CustomerPackageID] + if !ok || pkg.TenantID != tenantID || pkg.ProductID != productID { + return false + } + } + if refs.EvidenceBundleID != "" { + bundle, ok := l.evidenceBundles[refs.EvidenceBundleID] + if !ok || bundle.TenantID != tenantID { + return false + } + if bundle.ReleaseID != "" { + release, ok := l.releases[bundle.ReleaseID] + if !ok || release.TenantID != tenantID || release.ProductID != productID { + return false + } + } + } + return true +} + +func (l *Ledger) projectCoversRefsLocked(tenantID, projectID string, refs resourceRefs) bool { + if refs.ProjectID != "" { + project, ok := l.projects[refs.ProjectID] + return ok && project.TenantID == tenantID && project.ID == projectID + } + if refs.SourceRepositoryID != "" { + repo, ok := l.repositories[refs.SourceRepositoryID] + return ok && repo.TenantID == tenantID && repo.ProjectID == projectID + } + if refs.BuildID != "" { + build, ok := l.buildRuns[refs.BuildID] + return ok && build.TenantID == tenantID && build.ProjectID == projectID + } + return false +} + +func (l *Ledger) releaseCoversRefsLocked(tenantID, releaseID string, refs resourceRefs) bool { + if refs.ReleaseID != "" { + release, ok := l.releases[refs.ReleaseID] + return ok && release.TenantID == tenantID && release.ID == releaseID + } + if refs.CustomerPackageID != "" { + pkg, ok := l.customerPackages[refs.CustomerPackageID] + return ok && pkg.TenantID == tenantID && pkg.ReleaseID == releaseID + } + if refs.EvidenceBundleID != "" { + bundle, ok := l.evidenceBundles[refs.EvidenceBundleID] + return ok && bundle.TenantID == tenantID && bundle.ReleaseID == releaseID + } + if refs.BuildID != "" { + build, ok := l.buildRuns[refs.BuildID] + return ok && build.TenantID == tenantID && build.ReleaseID == releaseID + } + if refs.DeploymentID != "" { + deployment, ok := l.deployments[refs.DeploymentID] + return ok && deployment.TenantID == tenantID && deployment.ReleaseID == releaseID + } + if refs.IncidentID != "" { + incident, ok := l.incidents[refs.IncidentID] + return ok && incident.TenantID == tenantID && incident.ReleaseID == releaseID + } + if refs.SecurityScanID != "" { + scan, ok := l.securityScans[refs.SecurityScanID] + return ok && scan.TenantID == tenantID && scan.ReleaseID == releaseID + } + if refs.ArtifactID != "" && l.artifactCoversReleaseLocked(tenantID, refs.ArtifactID, releaseID) { + return true + } + return false +} + +func (l *Ledger) artifactCoversProductLocked(tenantID, artifactID, productID string) bool { + for _, item := range l.evidence { + if item.TenantID == tenantID && item.ProductID == productID && evidenceReferencesArtifact(item, artifactID) { + return true + } + } + for _, build := range l.buildRuns { + if build.TenantID != tenantID { + continue + } + for _, output := range build.Outputs { + if output.ArtifactID != artifactID { + continue + } + release, ok := l.releases[build.ReleaseID] + if ok && release.TenantID == tenantID && release.ProductID == productID { + return true + } + } + } + return false +} + +func (l *Ledger) artifactCoversReleaseLocked(tenantID, artifactID, releaseID string) bool { + for _, item := range l.evidence { + if item.TenantID == tenantID && item.ReleaseID == releaseID && evidenceReferencesArtifact(item, artifactID) { + return true + } + } + for _, build := range l.buildRuns { + if build.TenantID != tenantID || build.ReleaseID != releaseID { + continue + } + for _, output := range build.Outputs { + if output.ArtifactID == artifactID { + return true + } + } + } + return false +} + +func evidenceReferencesArtifact(item domain.EvidenceItem, artifactID string) bool { + for _, ref := range item.SubjectRefs { + if ref.Type == "artifact" && ref.ID == artifactID { + return true + } + } + return false +} + func scopesForRole(role string) []string { switch role { case "tenant_admin": @@ -705,10 +1666,132 @@ func scopesForRole(role string) []string { } } +func oidcGroupsFromVerifiedToken(provider domain.SSOProvider, token string) []string { + claimName := strings.TrimSpace(provider.GroupsClaim) + if claimName == "" || token == "" || provider.Type != "oidc" { + return nil + } + parts := strings.Split(token, ".") + if len(parts) != 3 { + return nil + } + body, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return nil + } + var claims map[string]any + if err := json.Unmarshal(body, &claims); err != nil { + return nil + } + return sortedStrings(stringClaimValues(claims[claimName])) +} + +func stringClaimValues(value any) []string { + seen := map[string]struct{}{} + out := []string{} + add := func(raw string) { + item := strings.TrimSpace(raw) + if item == "" { + return + } + if _, ok := seen[item]; ok { + return + } + seen[item] = struct{}{} + out = append(out, item) + } + switch got := value.(type) { + case string: + add(got) + case []any: + for _, item := range got { + if text, ok := item.(string); ok { + add(text) + } + } + } + return out +} + func validSSOType(value string) bool { return value == "oidc" || value == "saml" } +func normalizeJWKS(jwks map[string]any) (map[string]any, error) { + if len(jwks) == 0 { + return nil, nil + } + body, err := json.Marshal(jwks) + if err != nil || len(body) > 64*1024 { + return nil, ErrValidation + } + var normalized map[string]any + if err := json.Unmarshal(body, &normalized); err != nil { + return nil, err + } + keys, ok := normalized["keys"].([]any) + if !ok || len(keys) == 0 || len(keys) > 10 { + return nil, ErrValidation + } + for _, raw := range keys { + key, ok := raw.(map[string]any) + if !ok { + return nil, ErrValidation + } + kty, _ := key["kty"].(string) + kid, _ := key["kid"].(string) + if strings.TrimSpace(kid) == "" { + return nil, ErrValidation + } + switch kty { + case "OKP": + crv, _ := key["crv"].(string) + x, _ := key["x"].(string) + if crv != "Ed25519" || strings.TrimSpace(x) == "" { + return nil, ErrValidation + } + case "RSA": + n, _ := key["n"].(string) + e, _ := key["e"].(string) + if strings.TrimSpace(n) == "" || strings.TrimSpace(e) == "" { + return nil, ErrValidation + } + default: + return nil, ErrValidation + } + } + return normalized, nil +} + +func normalizeSAMLSigningCertificates(certs []string) ([]string, error) { + if len(certs) == 0 { + return nil, nil + } + if len(certs) > 5 { + return nil, ErrValidation + } + out := make([]string, 0, len(certs)) + for _, raw := range certs { + value := strings.TrimSpace(raw) + if value == "" || len(value) > 16*1024 { + return nil, ErrValidation + } + block, _ := pem.Decode([]byte(value)) + if block == nil || block.Type != "CERTIFICATE" { + return nil, ErrValidation + } + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return nil, ErrValidation + } + if _, ok := cert.PublicKey.(*rsa.PublicKey); !ok { + return nil, ErrValidation + } + out = append(out, string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw}))) + } + return out, nil +} + func validRetentionScope(value string) bool { switch value { case "tenant", "product", "project", "release", "evidence": diff --git a/internal/app/enterprise_test.go b/internal/app/enterprise_test.go index b79173c..b7aee35 100644 --- a/internal/app/enterprise_test.go +++ b/internal/app/enterprise_test.go @@ -1,8 +1,21 @@ package app import ( + "bytes" "context" + "crypto" + "crypto/ed25519" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "crypto/x509/pkix" + "encoding/base64" + "encoding/json" + "encoding/pem" "errors" + "math/big" + "strings" "testing" "time" @@ -12,7 +25,7 @@ import ( func TestEnterpriseIdentityRBACSSOAndAdminSnapshot(t *testing.T) { ledger := NewLedger(Config{APIKeyPepper: "test-pepper", Now: fixedNow}) ctx := context.Background() - _, _, secret, err := ledger.BootstrapTenant(ctx, "Tenant", "admin", []string{"*"}) + _, _, secret, err := ledger.BootstrapTenant(ctx, "Tenant", "admin", []string{"*", ScopeInstanceAdmin}) if err != nil { t.Fatalf("bootstrap: %v", err) } @@ -59,12 +72,28 @@ func TestEnterpriseIdentityRBACSSOAndAdminSnapshot(t *testing.T) { if sessionActor.UserID != user.ID || !sessionActor.HasScope(ScopeEvidenceWrite) || sessionActor.HasScope(ScopeIdentityAdmin) { t.Fatalf("session actor scopes = %#v", sessionActor) } - if _, err := ledger.RevokeSSOSession(ctx, actor, session.ID); err != nil { - t.Fatalf("revoke session: %v", err) + if sessionActor.SessionID != session.ID { + t.Fatalf("session actor session id = %q, want %q", sessionActor.SessionID, session.ID) + } + if _, err := ledger.RevokeCurrentSSOSession(ctx, actor); !errors.Is(err, ErrForbidden) { + t.Fatalf("api key current session revoke err=%v, want forbidden", err) + } + if _, err := ledger.RevokeCurrentSSOSession(ctx, sessionActor); err != nil { + t.Fatalf("revoke current session: %v", err) } if _, err := ledger.Authenticate(ctx, secret); !errors.Is(err, ErrUnauthorized) { t.Fatalf("revoked session auth err=%v, want unauthorized", err) } + session, secret, err = ledger.CreateSSOSession(ctx, actor, CreateSSOSessionInput{UserID: user.ID, ProviderID: provider.ID, ExpiresAt: fixedNow().Add(time.Hour)}) + if err != nil { + t.Fatalf("second sso session: %v", err) + } + if _, err := ledger.RevokeSSOSession(ctx, actor, session.ID); err != nil { + t.Fatalf("admin revoke session: %v", err) + } + if _, err := ledger.Authenticate(ctx, secret); !errors.Is(err, ErrUnauthorized) { + t.Fatalf("admin-revoked session auth err=%v, want unauthorized", err) + } snapshot, err := ledger.InstanceAdminSnapshot(ctx, actor) if err != nil { t.Fatalf("instance snapshot: %v", err) @@ -77,6 +106,416 @@ func TestEnterpriseIdentityRBACSSOAndAdminSnapshot(t *testing.T) { } } +func TestSAMLProviderIdentityVerificationUsesConfiguredCertificate(t *testing.T) { + ledger := NewLedger(Config{APIKeyPepper: "test-pepper", Now: fixedNow}) + ctx := context.Background() + _, _, secret, err := ledger.BootstrapTenant(ctx, "Tenant", "admin", []string{"*"}) + if err != nil { + t.Fatalf("bootstrap: %v", err) + } + actor, err := ledger.Authenticate(ctx, secret) + if err != nil { + t.Fatalf("auth: %v", err) + } + privateKey, certPEM := samlTestCertificate(t) + provider, err := ledger.CreateSSOProvider(ctx, actor, CreateSSOProviderInput{ + Name: "SAML", + Type: "saml", + Issuer: "https://saml-idp.example.test", + ClientID: "saml-client", + SAMLSigningCertificates: []string{certPEM}, + }) + if err != nil { + t.Fatalf("saml provider: %v", err) + } + org, err := ledger.CreateOrganization(ctx, actor, CreateOrganizationInput{Name: "SAML Example", Slug: "saml-example"}) + if err != nil { + t.Fatalf("org: %v", err) + } + user, err := ledger.CreateUser(ctx, actor, CreateUserInput{OrganizationID: org.ID, Email: "saml@example.test", DisplayName: "SAML User"}) + if err != nil { + t.Fatalf("user: %v", err) + } + if _, err := ledger.LinkSSOIdentity(ctx, actor, LinkSSOIdentityInput{UserID: user.ID, ProviderID: provider.ID, Subject: "saml-sub", Email: user.Email, Verified: true}); err != nil { + t.Fatalf("identity link: %v", err) + } + assertion := signedTestSAMLAssertion(t, privateKey, "https://saml-idp.example.test", "saml-client", "saml-sub", fixedNow().Add(-time.Minute), fixedNow().Add(time.Hour)) + verification, err := ledger.VerifyProviderIdentity(ctx, actor, VerifyProviderIdentityInput{ProviderType: "saml", ProviderID: provider.ID, Subject: "saml-sub", SAMLAssertion: assertion}) + if err != nil { + t.Fatalf("provider verification: %v", err) + } + if verification.Result != "passed" || !hasVerifyCheck(verification.Checks, "saml_assertion_signature", "passed") { + t.Fatalf("verification = %#v", verification) + } + badAssertion := strings.Replace(assertion, "saml-sub", "other-sub", 1) + failed, err := ledger.VerifyProviderIdentity(ctx, actor, VerifyProviderIdentityInput{ProviderType: "saml", ProviderID: provider.ID, Subject: "saml-sub", SAMLAssertion: badAssertion}) + if !errors.Is(err, ErrVerificationFailed) || failed.Result != "failed" { + t.Fatalf("bad assertion verification = %#v err=%v", failed, err) + } + for _, check := range failed.Checks { + if strings.Contains(check.Detail, badAssertion) { + t.Fatalf("verification leaked assertion in check detail: %#v", failed) + } + } +} + +func TestOIDCProviderIdentityVerificationSupportsRS256JWKS(t *testing.T) { + ledger := NewLedger(Config{APIKeyPepper: "test-pepper", Now: fixedNow}) + ctx := context.Background() + _, _, secret, err := ledger.BootstrapTenant(ctx, "Tenant", "admin", []string{"*"}) + if err != nil { + t.Fatalf("bootstrap: %v", err) + } + actor, err := ledger.Authenticate(ctx, secret) + if err != nil { + t.Fatalf("auth: %v", err) + } + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("rsa keygen: %v", err) + } + jwks := map[string]any{"keys": []any{map[string]any{ + "kty": "RSA", + "kid": "rsa-1", + "alg": "RS256", + "n": base64.RawURLEncoding.EncodeToString(privateKey.N.Bytes()), + "e": base64.RawURLEncoding.EncodeToString(bigEndianExponent(privateKey.E)), + }}} + provider, err := ledger.CreateSSOProvider(ctx, actor, CreateSSOProviderInput{Name: "OIDC RSA", Type: "oidc", Issuer: "https://rsa-idp.example.test", ClientID: "rsa-client"}) + if err != nil { + t.Fatalf("sso provider: %v", err) + } + provider, err = ledger.UpdateSSOProviderTrustMaterial(ctx, actor, provider.ID, UpdateSSOProviderTrustMaterialInput{JWKS: jwks}) + if err != nil { + t.Fatalf("update sso trust material: %v", err) + } + if provider.TrustMaterialUpdatedAt == nil || len(provider.JWKS) == 0 { + t.Fatalf("provider trust material = %#v", provider) + } + org, err := ledger.CreateOrganization(ctx, actor, CreateOrganizationInput{Name: "RSA Example", Slug: "rsa-example"}) + if err != nil { + t.Fatalf("org: %v", err) + } + user, err := ledger.CreateUser(ctx, actor, CreateUserInput{OrganizationID: org.ID, Email: "rsa@example.test", DisplayName: "RSA User"}) + if err != nil { + t.Fatalf("user: %v", err) + } + if _, err := ledger.LinkSSOIdentity(ctx, actor, LinkSSOIdentityInput{UserID: user.ID, ProviderID: provider.ID, Subject: "rsa-sub", Email: user.Email, Verified: true}); err != nil { + t.Fatalf("identity link: %v", err) + } + idToken := signedTestRSAIDToken(t, privateKey, "rsa-1", map[string]any{ + "iss": "https://rsa-idp.example.test", + "aud": []string{"other", "rsa-client"}, + "sub": "rsa-sub", + "email": user.Email, + "email_verified": true, + "exp": fixedNow().Add(time.Hour).Unix(), + }) + verification, err := ledger.VerifyProviderIdentity(ctx, actor, VerifyProviderIdentityInput{ProviderType: "oidc", ProviderID: provider.ID, Subject: "rsa-sub", IDToken: idToken}) + if err != nil { + t.Fatalf("provider verification: %v", err) + } + if verification.Result != "passed" { + t.Fatalf("verification = %#v", verification) + } +} + +func TestOIDCProviderIdentityVerificationUsesStaticJWKS(t *testing.T) { + ledger := NewLedger(Config{APIKeyPepper: "test-pepper", Now: fixedNow}) + ctx := context.Background() + _, _, secret, err := ledger.BootstrapTenant(ctx, "Tenant", "admin", []string{"*"}) + if err != nil { + t.Fatalf("bootstrap: %v", err) + } + actor, err := ledger.Authenticate(ctx, secret) + if err != nil { + t.Fatalf("auth: %v", err) + } + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("keygen: %v", err) + } + jwks := map[string]any{"keys": []any{map[string]any{"kty": "OKP", "crv": "Ed25519", "kid": "kid-1", "alg": "EdDSA", "x": base64.RawURLEncoding.EncodeToString(pub)}}} + provider, err := ledger.CreateSSOProvider(ctx, actor, CreateSSOProviderInput{Name: "OIDC", Type: "oidc", Issuer: "https://idp.example.test", ClientID: "client", JWKS: jwks}) + if err != nil { + t.Fatalf("sso provider: %v", err) + } + org, err := ledger.CreateOrganization(ctx, actor, CreateOrganizationInput{Name: "Example", Slug: "example-oidc"}) + if err != nil { + t.Fatalf("org: %v", err) + } + user, err := ledger.CreateUser(ctx, actor, CreateUserInput{OrganizationID: org.ID, Email: "oidc@example.test", DisplayName: "OIDC User"}) + if err != nil { + t.Fatalf("user: %v", err) + } + if _, err := ledger.LinkSSOIdentity(ctx, actor, LinkSSOIdentityInput{UserID: user.ID, ProviderID: provider.ID, Subject: "sub-1", Email: user.Email, Verified: true}); err != nil { + t.Fatalf("identity link: %v", err) + } + idToken := signedTestIDToken(t, priv, "kid-1", map[string]any{ + "iss": "https://idp.example.test", + "aud": "client", + "sub": "sub-1", + "email": user.Email, + "email_verified": true, + "exp": fixedNow().Add(time.Hour).Unix(), + "nbf": fixedNow().Add(-time.Minute).Unix(), + }) + verification, err := ledger.VerifyProviderIdentity(ctx, actor, VerifyProviderIdentityInput{ProviderType: "oidc", ProviderID: provider.ID, Subject: "sub-1", IDToken: idToken}) + if err != nil { + t.Fatalf("provider verification: %v", err) + } + if verification.Result != "passed" || len(verification.Checks) < 5 { + t.Fatalf("verification = %#v", verification) + } + badAudience := signedTestIDToken(t, priv, "kid-1", map[string]any{ + "iss": "https://idp.example.test", "aud": "other-client", "sub": "sub-1", "email": user.Email, "email_verified": true, "exp": fixedNow().Add(time.Hour).Unix(), + }) + failed, err := ledger.VerifyProviderIdentity(ctx, actor, VerifyProviderIdentityInput{ProviderType: "oidc", ProviderID: provider.ID, Subject: "sub-1", IDToken: badAudience}) + if !errors.Is(err, ErrVerificationFailed) || failed.Result != "failed" { + t.Fatalf("bad audience verification = %#v err=%v", failed, err) + } + if strings.Contains(failed.Checks[0].Detail, badAudience) { + t.Fatalf("verification leaked token in check detail: %#v", failed) + } +} + +func TestExchangeSSOCredentialIssuesSessionFromVerifiedOIDCToken(t *testing.T) { + ledger := NewLedger(Config{APIKeyPepper: "test-pepper", Now: fixedNow}) + ctx := context.Background() + _, _, _, actor := bootstrapEnterpriseTestTenant(t, ledger) + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("keygen: %v", err) + } + jwks := map[string]any{"keys": []any{map[string]any{"kty": "OKP", "crv": "Ed25519", "kid": "kid-login", "alg": "EdDSA", "x": base64.RawURLEncoding.EncodeToString(pub)}}} + provider, err := ledger.CreateSSOProvider(ctx, actor, CreateSSOProviderInput{Name: "OIDC Login", Type: "oidc", Issuer: "https://login-idp.example.test", ClientID: "login-client", GroupsClaim: "groups", RoleMapping: map[string]string{"security": "security_engineer"}, JWKS: jwks}) + if err != nil { + t.Fatalf("sso provider: %v", err) + } + org, err := ledger.CreateOrganization(ctx, actor, CreateOrganizationInput{Name: "Login Example", Slug: "login-example"}) + if err != nil { + t.Fatalf("org: %v", err) + } + user, err := ledger.CreateUser(ctx, actor, CreateUserInput{OrganizationID: org.ID, Email: "login@example.test", DisplayName: "Login User"}) + if err != nil { + t.Fatalf("user: %v", err) + } + if _, err := ledger.LinkSSOIdentity(ctx, actor, LinkSSOIdentityInput{UserID: user.ID, ProviderID: provider.ID, Subject: "login-sub", Email: user.Email, Verified: true}); err != nil { + t.Fatalf("identity link: %v", err) + } + idToken := signedTestIDToken(t, priv, "kid-login", map[string]any{ + "iss": provider.Issuer, + "aud": provider.ClientID, + "sub": "login-sub", + "email": user.Email, + "email_verified": true, + "groups": []string{"security"}, + "exp": fixedNow().Add(time.Hour).Unix(), + }) + verification, session, secret, err := ledger.ExchangeSSOCredential(ctx, ExchangeSSOCredentialInput{ProviderID: provider.ID, Subject: "login-sub", IDToken: idToken}) + if err != nil { + t.Fatalf("exchange credential: %v", err) + } + if verification.Result != "passed" || session.UserID != user.ID || session.ProviderID != provider.ID || secret == "" || session.Hash != "" || len(session.Groups) != 1 || session.Groups[0] != "security" { + t.Fatalf("exchange result verification=%#v session=%#v secret=%q", verification, session, secret) + } + sessionActor, err := ledger.Authenticate(ctx, secret) + if err != nil { + t.Fatalf("authenticate exchanged session: %v", err) + } + if sessionActor.UserID != user.ID || sessionActor.SessionID != session.ID || !sessionActor.HasScope(ScopeEvidenceWrite) { + t.Fatalf("session actor = %#v", sessionActor) + } + bad, badSession, badSecret, err := ledger.ExchangeSSOCredential(ctx, ExchangeSSOCredentialInput{ProviderID: provider.ID, Subject: "other-sub", IDToken: idToken}) + if !errors.Is(err, ErrVerificationFailed) || bad.Result != "failed" || badSession.ID != "" || badSecret != "" { + t.Fatalf("bad exchange verification=%#v session=%#v secret=%q err=%v", bad, badSession, badSecret, err) + } + for _, check := range bad.Checks { + if strings.Contains(check.Detail, idToken) { + t.Fatalf("exchange leaked token in check detail: %#v", bad) + } + } +} + +func TestExchangeSSOCredentialRejectsSessionWithoutAuthorizationGrant(t *testing.T) { + ledger := NewLedger(Config{APIKeyPepper: "test-pepper", Now: fixedNow}) + ctx := context.Background() + _, _, _, actor := bootstrapEnterpriseTestTenant(t, ledger) + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("keygen: %v", err) + } + jwks := map[string]any{"keys": []any{map[string]any{"kty": "OKP", "crv": "Ed25519", "kid": "kid-no-grant", "alg": "EdDSA", "x": base64.RawURLEncoding.EncodeToString(pub)}}} + provider, err := ledger.CreateSSOProvider(ctx, actor, CreateSSOProviderInput{Name: "OIDC Login", Type: "oidc", Issuer: "https://login-idp.example.test", ClientID: "login-client", GroupsClaim: "groups", RoleMapping: map[string]string{"security": "security_engineer"}, JWKS: jwks}) + if err != nil { + t.Fatalf("sso provider: %v", err) + } + org, err := ledger.CreateOrganization(ctx, actor, CreateOrganizationInput{Name: "Login Example", Slug: "login-example-no-grant"}) + if err != nil { + t.Fatalf("org: %v", err) + } + user, err := ledger.CreateUser(ctx, actor, CreateUserInput{OrganizationID: org.ID, Email: "nogrant@example.test", DisplayName: "No Grant User"}) + if err != nil { + t.Fatalf("user: %v", err) + } + if _, err := ledger.LinkSSOIdentity(ctx, actor, LinkSSOIdentityInput{UserID: user.ID, ProviderID: provider.ID, Subject: "login-sub", Email: user.Email, Verified: true}); err != nil { + t.Fatalf("identity link: %v", err) + } + idToken := signedTestIDToken(t, priv, "kid-no-grant", map[string]any{ + "iss": provider.Issuer, + "aud": provider.ClientID, + "sub": "login-sub", + "email": user.Email, + "email_verified": true, + "groups": []string{"unmapped"}, + "exp": fixedNow().Add(time.Hour).Unix(), + }) + verification, session, secret, err := ledger.ExchangeSSOCredential(ctx, ExchangeSSOCredentialInput{ProviderID: provider.ID, Subject: "login-sub", IDToken: idToken}) + if !errors.Is(err, ErrForbidden) || verification.Result != "failed" || session.ID != "" || secret != "" { + t.Fatalf("exchange without grant verification=%#v session=%#v secret=%q err=%v", verification, session, secret, err) + } + if !hasVerifyCheck(verification.Checks, "authorization_grant", "failed") { + t.Fatalf("verification missing authorization_grant failure: %#v", verification) + } +} + +func TestOIDCGroupClaimHelpersNormalizeSupportedClaimShapes(t *testing.T) { + token := signedTestIDToken(t, ed25519.NewKeyFromSeed(bytes.Repeat([]byte{1}, ed25519.SeedSize)), "kid", map[string]any{ + "groups": []string{"security", "release", "security", " "}, + }) + provider := domain.SSOProvider{Type: "oidc", GroupsClaim: "groups"} + groups := oidcGroupsFromVerifiedToken(provider, token) + if strings.Join(groups, ",") != "release,security" { + t.Fatalf("groups = %#v", groups) + } + if got := stringClaimValues("security"); len(got) != 1 || got[0] != "security" { + t.Fatalf("string claim values = %#v", got) + } + if grants := resourceGrantsForProviderGroups(domain.SSOProvider{GroupsClaim: "groups", RoleMapping: map[string]string{"security": "security_engineer", "bad": "not-a-role"}}, []string{"bad", "security"}); len(grants) != 1 || grants[0].Role != "security_engineer" { + t.Fatalf("mapped grants = %#v", grants) + } +} + +func TestOIDCProviderDiscoveryRefreshesTenantTrustMaterial(t *testing.T) { + pub, _, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("keygen: %v", err) + } + jwks := map[string]any{"keys": []any{map[string]any{"kty": "OKP", "crv": "Ed25519", "kid": "kid-refresh", "x": base64.RawURLEncoding.EncodeToString(pub)}}} + discovery := &fakeOIDCDiscovery{result: OIDCDiscoveryResult{Issuer: "https://idp.example.test", JWKS: jwks}} + ledger := NewLedger(Config{APIKeyPepper: "test-pepper", Now: fixedNow, OIDC: discovery}) + ctx := context.Background() + _, _, _, actor := bootstrapEnterpriseTestTenant(t, ledger) + provider, err := ledger.CreateSSOProvider(ctx, actor, CreateSSOProviderInput{Name: "OIDC", Type: "oidc", Issuer: "https://idp.example.test", ClientID: "client"}) + if err != nil { + t.Fatalf("provider: %v", err) + } + refreshed, err := ledger.RefreshSSOProviderOIDCTrustMaterial(ctx, actor, provider.ID) + if err != nil { + t.Fatalf("refresh: %v", err) + } + if refreshed.TrustMaterialUpdatedAt == nil || len(refreshed.JWKS) == 0 || len(refreshed.SAMLSigningCertificates) != 0 { + t.Fatalf("refreshed provider = %#v", refreshed) + } + if discovery.request.TenantID != actor.TenantID || discovery.request.ProviderID != provider.ID || discovery.request.Issuer != provider.Issuer { + t.Fatalf("discovery request = %#v", discovery.request) + } + _, _, _, other := bootstrapEnterpriseTestTenant(t, ledger) + if _, err := ledger.RefreshSSOProviderOIDCTrustMaterial(ctx, other, provider.ID); !errors.Is(err, ErrNotFound) { + t.Fatalf("cross tenant refresh err=%v, want not found", err) + } +} + +func TestOIDCProviderDiscoveryRefreshRejectsMismatchedIssuer(t *testing.T) { + pub, _, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("keygen: %v", err) + } + jwks := map[string]any{"keys": []any{map[string]any{"kty": "OKP", "crv": "Ed25519", "kid": "kid", "x": base64.RawURLEncoding.EncodeToString(pub)}}} + ledger := NewLedger(Config{APIKeyPepper: "test-pepper", Now: fixedNow, OIDC: &fakeOIDCDiscovery{result: OIDCDiscoveryResult{Issuer: "https://other-idp.example.test", JWKS: jwks}}}) + ctx := context.Background() + _, _, _, actor := bootstrapEnterpriseTestTenant(t, ledger) + provider, err := ledger.CreateSSOProvider(ctx, actor, CreateSSOProviderInput{Name: "OIDC", Type: "oidc", Issuer: "https://idp.example.test", ClientID: "client"}) + if err != nil { + t.Fatalf("provider: %v", err) + } + if _, err := ledger.RefreshSSOProviderOIDCTrustMaterial(ctx, actor, provider.ID); !errors.Is(err, ErrVerificationFailed) { + t.Fatalf("refresh err=%v, want verification failed", err) + } +} + +func TestInstanceAdminRequiresExplicitScope(t *testing.T) { + ledger := NewLedger(Config{APIKeyPepper: "test-pepper", Now: fixedNow}) + ctx := context.Background() + _, _, tenantAdminSecret, err := ledger.BootstrapTenant(ctx, "Tenant", "tenant-admin", []string{"*"}) + if err != nil { + t.Fatalf("bootstrap tenant admin: %v", err) + } + tenantAdmin, err := ledger.Authenticate(ctx, tenantAdminSecret) + if err != nil { + t.Fatalf("tenant admin auth: %v", err) + } + if _, err := ledger.InstanceAdminSnapshot(ctx, tenantAdmin); !errors.Is(err, ErrForbidden) { + t.Fatalf("tenant wildcard snapshot err=%v, want forbidden", err) + } + if _, err := ledger.CreateProduct(ctx, tenantAdmin, "Tenant Product", "tenant-product"); err != nil { + t.Fatalf("tenant admin should still create tenant resources: %v", err) + } + if _, _, err := ledger.CreateAPIKey(ctx, tenantAdmin, "instance-escalation", []string{ScopeInstanceAdmin}, nil); !errors.Is(err, ErrForbidden) { + t.Fatalf("tenant admin instance key creation err=%v, want forbidden", err) + } + + _, _, instanceSecret, err := ledger.BootstrapTenant(ctx, "Instance Tenant", "instance-admin", []string{"*", ScopeInstanceAdmin}) + if err != nil { + t.Fatalf("bootstrap instance admin: %v", err) + } + instanceAdmin, err := ledger.Authenticate(ctx, instanceSecret) + if err != nil { + t.Fatalf("instance admin auth: %v", err) + } + if _, err := ledger.InstanceAdminSnapshot(ctx, instanceAdmin); err != nil { + t.Fatalf("explicit instance admin snapshot: %v", err) + } + _, instanceKeySecret, err := ledger.CreateAPIKey(ctx, instanceAdmin, "instance-read", []string{ScopeInstanceAdmin}, nil) + if err != nil { + t.Fatalf("explicit instance key creation: %v", err) + } + instanceKeyActor, err := ledger.Authenticate(ctx, instanceKeySecret) + if err != nil { + t.Fatalf("instance key auth: %v", err) + } + if _, err := ledger.InstanceAdminSnapshot(ctx, instanceKeyActor); err != nil { + t.Fatalf("instance key snapshot: %v", err) + } + + org, err := ledger.CreateOrganization(ctx, tenantAdmin, CreateOrganizationInput{Name: "Example", Slug: "example"}) + if err != nil { + t.Fatalf("org: %v", err) + } + user, err := ledger.CreateUser(ctx, tenantAdmin, CreateUserInput{OrganizationID: org.ID, Email: "admin@example.test", DisplayName: "Admin"}) + if err != nil { + t.Fatalf("user: %v", err) + } + if _, err := ledger.CreateRoleBinding(ctx, tenantAdmin, CreateRoleBindingInput{SubjectType: "user", SubjectID: user.ID, Role: "tenant_admin", ResourceType: "tenant", ResourceID: tenantAdmin.TenantID}); err != nil { + t.Fatalf("role binding: %v", err) + } + provider, err := ledger.CreateSSOProvider(ctx, tenantAdmin, CreateSSOProviderInput{Name: "OIDC", Type: "oidc", Issuer: "https://idp.example.test", ClientID: "client"}) + if err != nil { + t.Fatalf("provider: %v", err) + } + _, sessionSecret, err := ledger.CreateSSOSession(ctx, tenantAdmin, CreateSSOSessionInput{UserID: user.ID, ProviderID: provider.ID, ExpiresAt: fixedNow().Add(time.Hour)}) + if err != nil { + t.Fatalf("session: %v", err) + } + sessionActor, err := ledger.Authenticate(ctx, sessionSecret) + if err != nil { + t.Fatalf("session auth: %v", err) + } + if _, err := ledger.InstanceAdminSnapshot(ctx, sessionActor); !errors.Is(err, ErrForbidden) { + t.Fatalf("tenant-admin SSO snapshot err=%v, want forbidden", err) + } +} + func TestCustomerPortalRetentionQuestionnairesAndCommercialCollectors(t *testing.T) { ledger := NewLedger(Config{APIKeyPepper: "test-pepper", Now: fixedNow}) ctx := context.Background() @@ -93,19 +532,94 @@ func TestCustomerPortalRetentionQuestionnairesAndCommercialCollectors(t *testing if err != nil { t.Fatalf("customer package: %v", err) } - access, token, err := ledger.CreateCustomerPortalAccess(ctx, actor, CreateCustomerPortalAccessInput{PackageID: pkg.ID, CustomerName: "ACME", ExpiresAt: fixedNow().Add(time.Hour)}) + access, token, err := ledger.CreateCustomerPortalAccess(ctx, actor, CreateCustomerPortalAccessInput{PackageID: pkg.ID, CustomerName: "ACME", ReviewerName: "Alice Reviewer", ReviewerEmail: "ALICE.REVIEWER@EXAMPLE.TEST", RequireNDA: true, Watermark: "ACME confidential review copy", ExpiresAt: fixedNow().Add(time.Hour)}) if err != nil { t.Fatalf("portal access: %v", err) } - if token == "" || access.Hash != "" { + if token == "" || access.Hash != "" || !access.RequireNDA || access.Watermark == "" || access.ReviewerName != "Alice Reviewer" || access.ReviewerEmail != "alice.reviewer@example.test" { t.Fatalf("portal token/hash leakage access=%#v token=%q", access, token) } - portalPkg, err := ledger.AccessCustomerPortalPackage(ctx, token) + listedAccess, err := ledger.ListCustomerPortalAccess(ctx, actor, pkg.ID) + if err != nil || len(listedAccess) != 1 || listedAccess[0].ID != access.ID || listedAccess[0].Hash != "" || listedAccess[0].ReviewerEmail != "alice.reviewer@example.test" { + t.Fatalf("portal access list=%#v err=%v", listedAccess, err) + } + if _, _, err := ledger.CreateCustomerPortalAccess(ctx, actor, CreateCustomerPortalAccessInput{PackageID: pkg.ID, CustomerName: "ACME", ReviewerEmail: "not an email", ExpiresAt: fixedNow().Add(time.Hour)}); !errors.Is(err, ErrValidation) { + t.Fatalf("invalid reviewer email err=%v, want validation", err) + } + if _, err := ledger.AccessCustomerPortalPackage(ctx, token); !errors.Is(err, ErrForbidden) { + t.Fatalf("NDA-gated portal package without acceptance err=%v, want forbidden", err) + } + portalPkg, err := ledger.AccessCustomerPortalPackageWithAcceptance(ctx, token, CustomerPortalAcceptanceInput{NDAAccepted: true, NDAAcceptedBy: "reviewer@example.test"}) if err != nil { t.Fatalf("portal package: %v", err) } - if portalPkg.ID != pkg.ID { - t.Fatalf("portal package id = %s want %s", portalPkg.ID, pkg.ID) + if portalPkg.ID != pkg.ID || portalPkg.DistributionWatermark != "ACME confidential review copy" { + t.Fatalf("portal package = %#v, want id %s with watermark", portalPkg, pkg.ID) + } + portalArchive, err := ledger.ExportCustomerPortalPackageArchive(ctx, token) + if err != nil { + t.Fatalf("portal package download: %v", err) + } + if portalArchive.PackageID != pkg.ID || portalArchive.MediaType != "application/zip" { + t.Fatalf("portal archive metadata = %#v", portalArchive) + } + badToken := mutateTokenSuffix(token) + if _, err := ledger.AccessCustomerPortalPackage(ctx, badToken); !errors.Is(err, ErrUnauthorized) { + t.Fatalf("bad portal token err=%v, want unauthorized", err) + } + entries, err := ledger.ListAuditLog(ctx, actor, AuditLogFilter{SubjectType: "customer_portal_access", SubjectID: access.ID, Limit: 10}) + if err != nil { + t.Fatalf("portal audit log: %v", err) + } + portalFiles := packageArchiveFiles(t, portalArchive.Bytes) + if !strings.Contains(portalFiles["WATERMARK.txt"], "ACME confidential review copy") || !strings.Contains(portalFiles["report.html"], "ACME confidential review copy") || strings.Contains(portalFiles["package.json"], token) { + t.Fatalf("portal archive watermark/token handling invalid: files=%#v", portalFiles) + } + foundAccess, foundDownload, foundFailedAccess, foundNDAAccepted := false, false, false, false + for _, entry := range entries { + switch entry.EntryType { + case "customer_portal_package.accessed": + if entry.ActorID == access.ID && entry.PayloadHash == pkg.ManifestHash { + foundAccess = true + } + case "customer_portal_package.nda_accepted": + foundNDAAccepted = true + case "customer_portal_package.downloaded": + if entry.ActorID == access.ID && entry.PayloadHash == pkg.ManifestHash { + foundDownload = true + } + case "customer_portal_package.access_failed": + foundFailedAccess = true + if entry.ActorID != "unverified" { + t.Fatalf("failed portal access actor = %q, want unverified", entry.ActorID) + } + } + if entry.ActorID == badToken || entry.PayloadHash == badToken { + t.Fatalf("portal audit leaked token: %#v", entry) + } + } + if !foundAccess || !foundDownload || !foundNDAAccepted { + t.Fatalf("missing portal access/download/NDA audit entries access=%v download=%v nda=%v entries=%#v", foundAccess, foundDownload, foundNDAAccepted, entries) + } + if !foundFailedAccess { + t.Fatalf("missing failed portal access audit entry: %#v", entries) + } + metrics, err := ledger.Metrics(ctx, actor) + if err != nil { + t.Fatalf("metrics: %v", err) + } + if got := metrics["customer_portal_failed_access_count"]; got != 1 { + t.Fatalf("portal failed access metric = %#v, want 1", got) + } + revokedAccess, err := ledger.RevokeCustomerPortalAccess(ctx, actor, access.ID) + if err != nil { + t.Fatalf("revoke portal access: %v", err) + } + if revokedAccess.RevokedAt == nil || revokedAccess.Hash != "" { + t.Fatalf("revoked portal access leaked hash or missed timestamp: %#v", revokedAccess) + } + if _, err := ledger.AccessCustomerPortalPackage(ctx, token); !errors.Is(err, ErrUnauthorized) { + t.Fatalf("revoked reviewer token err=%v, want unauthorized", err) } if _, err := ledger.CreateLegalHold(ctx, actor, CreateLegalHoldInput{ScopeType: "release", ScopeID: release.ID, Reason: "customer dispute", Owner: "legal"}); err != nil { t.Fatalf("legal hold: %v", err) @@ -124,11 +638,19 @@ func TestCustomerPortalRetentionQuestionnairesAndCommercialCollectors(t *testing if err != nil { t.Fatalf("questionnaire template: %v", err) } + answer, err := ledger.CreateQuestionnaireAnswerLibraryEntry(ctx, actor, CreateQuestionnaireAnswerLibraryEntryInput{QuestionID: "q1", EvidenceType: "sbom", ProductID: release.ProductID, ReleaseID: release.ID, Answer: "A scoped SBOM evidence record is available in the linked package evidence.", EvidenceIDs: []string{item.ID}}) + if err != nil { + t.Fatalf("answer library: %v", err) + } + answers, err := ledger.ListQuestionnaireAnswerLibrary(ctx, actor, ListQuestionnaireAnswerLibraryInput{ProductID: release.ProductID, ReleaseID: release.ID}) + if err != nil || len(answers) != 1 || answers[0].ID != answer.ID { + t.Fatalf("answer library list=%#v err=%v", answers, err) + } qpkg, err := ledger.CreateQuestionnairePackage(ctx, actor, CreateQuestionnairePackageInput{TemplateID: template.ID, PackageID: pkg.ID, ProductID: release.ProductID, ReleaseID: release.ID}) if err != nil { t.Fatalf("questionnaire package: %v", err) } - if len(qpkg.Responses) != 1 || len(qpkg.Responses[0].EvidenceIDs) != 1 { + if len(qpkg.Responses) != 1 || len(qpkg.Responses[0].EvidenceIDs) != 1 || qpkg.Responses[0].Answer != answer.Answer { t.Fatalf("questionnaire package = %#v", qpkg) } def, err := ledger.CreateCommercialCollectorDefinition(ctx, actor, CreateCommercialCollectorInput{Name: "jira", Provider: "jira", Version: "1.0.0", ManifestHash: sampleDigest("manifest"), AllowedScopes: []string{ScopeEvidenceWrite}}) @@ -149,4 +671,626 @@ func TestCustomerPortalRetentionQuestionnairesAndCommercialCollectors(t *testing if _, _, err := ledger.CreateCustomerPortalAccess(ctx, other, CreateCustomerPortalAccessInput{PackageID: pkg.ID, CustomerName: "bad", ExpiresAt: fixedNow().Add(time.Hour)}); !errors.Is(err, ErrNotFound) { t.Fatalf("cross tenant portal err=%v, want not found", err) } + if _, err := ledger.ListCustomerPortalAccess(ctx, other, pkg.ID); !errors.Is(err, ErrNotFound) { + t.Fatalf("cross tenant portal list err=%v, want not found", err) + } + if _, err := ledger.RevokeCustomerPortalAccess(ctx, other, access.ID); !errors.Is(err, ErrNotFound) { + t.Fatalf("cross tenant portal revoke err=%v, want not found", err) + } + if _, err := ledger.CreateQuestionnaireAnswerLibraryEntry(ctx, other, CreateQuestionnaireAnswerLibraryEntryInput{QuestionID: "q1", ProductID: release.ProductID, ReleaseID: release.ID, Answer: "bad"}); !errors.Is(err, ErrNotFound) { + t.Fatalf("cross tenant answer library err=%v, want not found", err) + } +} + +func TestCustomerPortalAccessRevokesAfterRepeatedFailedAttempts(t *testing.T) { + ledger := NewLedger(Config{APIKeyPepper: "test-pepper", Now: fixedNow}) + ctx := context.Background() + actor, release, _ := setupReleaseRiskFixture(t, ledger) + profile, err := ledger.CreateRedactionProfile(ctx, actor, CreateRedactionProfileInput{Name: "customer", AllowedTypes: []string{"sbom"}}) + if err != nil { + t.Fatalf("profile: %v", err) + } + pkg, err := ledger.CreateCustomerSecurityPackage(ctx, actor, CreateCustomerPackageInput{ProductID: release.ProductID, ReleaseID: release.ID, RedactionProfileID: profile.ID, Title: "Customer", ExpiresAt: fixedNow().Add(time.Hour)}) + if err != nil { + t.Fatalf("customer package: %v", err) + } + access, token, err := ledger.CreateCustomerPortalAccess(ctx, actor, CreateCustomerPortalAccessInput{PackageID: pkg.ID, CustomerName: "ACME", ExpiresAt: fixedNow().Add(time.Hour)}) + if err != nil { + t.Fatalf("portal access: %v", err) + } + badToken := mutateTokenSuffix(token) + for i := 0; i < customerPortalFailedAccessLimit; i++ { + if _, err := ledger.AccessCustomerPortalPackage(ctx, badToken); !errors.Is(err, ErrUnauthorized) { + t.Fatalf("bad portal token attempt %d err=%v, want unauthorized", i+1, err) + } + } + if _, err := ledger.AccessCustomerPortalPackage(ctx, token); !errors.Is(err, ErrUnauthorized) { + t.Fatalf("revoked portal token err=%v, want unauthorized", err) + } + entries, err := ledger.ListAuditLog(ctx, actor, AuditLogFilter{SubjectType: "customer_portal_access", SubjectID: access.ID, Limit: 20}) + if err != nil { + t.Fatalf("audit log: %v", err) + } + var failures, revocations int + for _, entry := range entries { + switch entry.EntryType { + case "customer_portal_package.access_failed": + failures++ + case "customer_portal_access.revoked_after_failed_access": + revocations++ + } + if entry.ActorID == badToken || entry.PayloadHash == badToken { + t.Fatalf("portal audit leaked token: %#v", entry) + } + } + if failures != customerPortalFailedAccessLimit || revocations != 1 { + t.Fatalf("audit entries failures=%d revocations=%d", failures, revocations) + } + metrics, err := ledger.Metrics(ctx, actor) + if err != nil { + t.Fatalf("metrics: %v", err) + } + if metrics["customer_portal_failed_access_count"] != customerPortalFailedAccessLimit || metrics["customer_portal_revoked_access_count"] != 1 { + t.Fatalf("portal metrics = %#v", metrics) + } +} + +func TestCustomerPortalAccessAbuseBoundaries(t *testing.T) { + now := fixedNow() + ledger := NewLedger(Config{APIKeyPepper: "test-pepper", Now: func() time.Time { return now }}) + ctx := context.Background() + actor, release, _ := setupReleaseRiskFixture(t, ledger) + profile, err := ledger.CreateRedactionProfile(ctx, actor, CreateRedactionProfileInput{Name: "customer", AllowedTypes: []string{"sbom"}}) + if err != nil { + t.Fatalf("profile: %v", err) + } + pkg, err := ledger.CreateCustomerSecurityPackage(ctx, actor, CreateCustomerPackageInput{ProductID: release.ProductID, ReleaseID: release.ID, RedactionProfileID: profile.ID, Title: "Customer", ExpiresAt: now.Add(4 * time.Hour)}) + if err != nil { + t.Fatalf("customer package: %v", err) + } + access, token, err := ledger.CreateCustomerPortalAccess(ctx, actor, CreateCustomerPortalAccessInput{PackageID: pkg.ID, CustomerName: "ACME", ExpiresAt: now.Add(time.Hour)}) + if err != nil { + t.Fatalf("portal access: %v", err) + } + if _, err := ledger.AccessCustomerPortalPackage(ctx, "evycp_wrongprefix"); !errors.Is(err, ErrUnauthorized) { + t.Fatalf("wrong-prefix token err=%v, want unauthorized", err) + } + metrics, err := ledger.Metrics(ctx, actor) + if err != nil { + t.Fatalf("metrics: %v", err) + } + if metrics["customer_portal_failed_access_count"] != 0 || metrics["customer_portal_revoked_access_count"] != 0 { + t.Fatalf("wrong-prefix attempt should not increment known-access metrics: %#v", metrics) + } + now = now.Add(2 * time.Hour) + if _, err := ledger.AccessCustomerPortalPackage(ctx, token); !errors.Is(err, ErrUnauthorized) { + t.Fatalf("expired token err=%v, want unauthorized", err) + } + entries, err := ledger.ListAuditLog(ctx, actor, AuditLogFilter{SubjectType: "customer_portal_access", SubjectID: access.ID, Limit: 20}) + if err != nil { + t.Fatalf("audit log: %v", err) + } + for _, entry := range entries { + if entry.EntryType == "customer_portal_package.access_failed" { + t.Fatalf("expired token should not produce failed-access audit entry: %#v", entry) + } + if strings.Contains(entry.ActorID, token) || strings.Contains(entry.PayloadHash, token) { + t.Fatalf("portal audit leaked token: %#v", entry) + } + } +} + +func TestSecretHashComparisonHelper(t *testing.T) { + ledger := NewLedger(Config{APIKeyPepper: "test-pepper", Now: fixedNow}) + hash := ledger.hashSecret("evy_test_secret") + if !secretHashEqual(hash, hash) { + t.Fatalf("same HMAC hash did not compare equal") + } + if secretHashEqual(hash, ledger.hashSecret("evy_test_other")) { + t.Fatalf("different HMAC hashes compared equal") + } + if secretHashEqual(hash, "short") { + t.Fatalf("malformed hash compared equal") + } +} + +func TestResourceGrantHelperBranchCoverage(t *testing.T) { + ledger := NewLedger(Config{APIKeyPepper: "test-pepper", Now: fixedNow}) + ctx := context.Background() + admin, release, artifact := setupReleaseRiskFixture(t, ledger) + project, err := ledger.CreateProject(ctx, admin, release.ProductID, "api") + if err != nil { + t.Fatalf("project: %v", err) + } + repo, err := ledger.CreateSourceRepository(ctx, admin, CreateRepositoryInput{ProjectID: project.ID, Provider: "github", FullName: "example/api", CloneURL: "https://github.com/example/api.git", DefaultBranch: "main"}) + if err != nil { + t.Fatalf("repo: %v", err) + } + build, err := ledger.CreateBuildRun(ctx, admin, CreateBuildRunInput{ + ProjectID: project.ID, + ReleaseID: release.ID, + Provider: "generic", + CommitSHA: "0123456789abcdef0123456789abcdef01234567", + Status: "passed", + StartedAt: fixedNow(), + Outputs: []domain.BuildOutput{{ArtifactID: artifact.ID, Digest: artifact.Digest}}, + }) + if err != nil { + t.Fatalf("build: %v", err) + } + evidence, err := ledger.CreateEvidence(ctx, admin, CreateEvidenceInput{ + ProductID: release.ProductID, + ProjectID: project.ID, + ReleaseID: release.ID, + Type: "build", + Title: "Build", + PayloadHash: sampleDigest("resource-helper"), + SubjectRefs: []domain.SubjectRef{{Type: "artifact", ID: artifact.ID}}, + }) + if err != nil { + t.Fatalf("evidence: %v", err) + } + _ = evidence + otherProduct, err := ledger.CreateProduct(ctx, admin, "Other", "other-resource-helper") + if err != nil { + t.Fatalf("other product: %v", err) + } + otherRelease, err := ledger.CreateRelease(ctx, admin, otherProduct.ID, "1") + if err != nil { + t.Fatalf("other release: %v", err) + } + + ledger.mu.Lock() + defer ledger.mu.Unlock() + if !ledger.productCoversRefsLocked(admin.TenantID, release.ProductID, resourceRefs{ProjectID: project.ID, SourceRepositoryID: repo.ID, ReleaseID: release.ID, BuildID: build.ID, ArtifactID: artifact.ID}) { + t.Fatalf("product grant should cover project/source/release/build/artifact refs") + } + if ledger.productCoversRefsLocked(admin.TenantID, otherProduct.ID, resourceRefs{ArtifactID: artifact.ID}) { + t.Fatalf("foreign product grant unexpectedly covered artifact") + } + if !ledger.projectCoversRefsLocked(admin.TenantID, project.ID, resourceRefs{ProjectID: project.ID}) { + t.Fatalf("project grant should cover direct project ref") + } + if !ledger.projectCoversRefsLocked(admin.TenantID, project.ID, resourceRefs{SourceRepositoryID: repo.ID}) { + t.Fatalf("project grant should cover source repository ref") + } + if !ledger.projectCoversRefsLocked(admin.TenantID, project.ID, resourceRefs{BuildID: build.ID}) { + t.Fatalf("project grant should cover build ref") + } + if !ledger.releaseCoversRefsLocked(admin.TenantID, release.ID, resourceRefs{ReleaseID: release.ID}) { + t.Fatalf("release grant should cover direct release ref") + } + if !ledger.releaseCoversRefsLocked(admin.TenantID, release.ID, resourceRefs{BuildID: build.ID, ArtifactID: artifact.ID}) { + t.Fatalf("release grant should cover build and artifact refs") + } + if ledger.releaseCoversRefsLocked(admin.TenantID, otherRelease.ID, resourceRefs{ArtifactID: artifact.ID}) { + t.Fatalf("foreign release grant unexpectedly covered artifact") + } + if !ledger.artifactCoversProductLocked(admin.TenantID, artifact.ID, release.ProductID) { + t.Fatalf("artifact should be linked to product through evidence/build output") + } + if !ledger.artifactCoversReleaseLocked(admin.TenantID, artifact.ID, release.ID) { + t.Fatalf("artifact should be linked to release through evidence/build output") + } + if ledger.artifactCoversReleaseLocked(admin.TenantID, artifact.ID, otherRelease.ID) { + t.Fatalf("artifact unexpectedly covered foreign release") + } +} + +func mutateTokenSuffix(token string) string { + if strings.HasSuffix(token, "x") { + return token[:len(token)-1] + "y" + } + return token[:len(token)-1] + "x" +} + +func TestHumanSSOSessionRoleBindingsAreResourceScoped(t *testing.T) { + ledger := NewLedger(Config{APIKeyPepper: "test-pepper", Now: fixedNow}) + ctx := context.Background() + _, _, secret, err := ledger.BootstrapTenant(ctx, "Tenant", "admin", []string{"*"}) + if err != nil { + t.Fatalf("bootstrap: %v", err) + } + admin, err := ledger.Authenticate(ctx, secret) + if err != nil { + t.Fatalf("auth: %v", err) + } + productA, err := ledger.CreateProduct(ctx, admin, "A", "a") + if err != nil { + t.Fatalf("product A: %v", err) + } + releaseA, err := ledger.CreateRelease(ctx, admin, productA.ID, "1") + if err != nil { + t.Fatalf("release A: %v", err) + } + productB, err := ledger.CreateProduct(ctx, admin, "B", "b") + if err != nil { + t.Fatalf("product B: %v", err) + } + releaseB, err := ledger.CreateRelease(ctx, admin, productB.ID, "1") + if err != nil { + t.Fatalf("release B: %v", err) + } + org, err := ledger.CreateOrganization(ctx, admin, CreateOrganizationInput{Name: "Example", Slug: "example"}) + if err != nil { + t.Fatalf("org: %v", err) + } + user, err := ledger.CreateUser(ctx, admin, CreateUserInput{OrganizationID: org.ID, Email: "release@example.test", DisplayName: "Release"}) + if err != nil { + t.Fatalf("user: %v", err) + } + if _, err := ledger.CreateRoleBinding(ctx, admin, CreateRoleBindingInput{SubjectType: "user", SubjectID: user.ID, Role: "release_manager", ResourceType: "product", ResourceID: productA.ID}); err != nil { + t.Fatalf("role binding: %v", err) + } + provider, err := ledger.CreateSSOProvider(ctx, admin, CreateSSOProviderInput{Name: "OIDC", Type: "oidc", Issuer: "https://idp.example.test", ClientID: "client"}) + if err != nil { + t.Fatalf("provider: %v", err) + } + session, sessionSecret, err := ledger.CreateSSOSession(ctx, admin, CreateSSOSessionInput{UserID: user.ID, ProviderID: provider.ID, ExpiresAt: fixedNow().Add(time.Hour)}) + if err != nil { + t.Fatalf("session: %v", err) + } + scoped, err := ledger.Authenticate(ctx, sessionSecret) + if err != nil { + t.Fatalf("session auth: %v", err) + } + if scoped.UserID != user.ID || len(scoped.ResourceGrants) != 1 || scoped.ResourceGrants[0].ResourceID != productA.ID { + t.Fatalf("scoped actor = %#v session=%#v", scoped, session) + } + products, err := ledger.ListProducts(ctx, scoped) + if err != nil { + t.Fatalf("list products: %v", err) + } + if len(products) != 1 || products[0].ID != productA.ID { + t.Fatalf("scoped products = %#v", products) + } + if _, err := ledger.GetRelease(ctx, scoped, releaseA.ID); err != nil { + t.Fatalf("get allowed release: %v", err) + } + if _, err := ledger.GetRelease(ctx, scoped, releaseB.ID); !errors.Is(err, ErrForbidden) { + t.Fatalf("get foreign product release err=%v, want forbidden", err) + } + if _, err := ledger.CreateRelease(ctx, scoped, productA.ID, "2"); err != nil { + t.Fatalf("create scoped release: %v", err) + } + if _, err := ledger.CreateRelease(ctx, scoped, productB.ID, "2"); !errors.Is(err, ErrForbidden) { + t.Fatalf("create foreign product release err=%v, want forbidden", err) + } + if _, err := ledger.CreateEvidence(ctx, scoped, CreateEvidenceInput{ProductID: productA.ID, ReleaseID: releaseA.ID, Type: "build", Title: "Build", PayloadHash: sampleDigest("allowed")}); err != nil { + t.Fatalf("create scoped evidence: %v", err) + } + if _, err := ledger.CreateEvidence(ctx, scoped, CreateEvidenceInput{ProductID: productB.ID, ReleaseID: releaseB.ID, Type: "build", Title: "Build", PayloadHash: sampleDigest("denied")}); !errors.Is(err, ErrForbidden) { + t.Fatalf("create foreign evidence err=%v, want forbidden", err) + } + if _, err := ledger.MissingEvidenceReport(ctx, scoped, releaseB.ID); !errors.Is(err, ErrForbidden) { + t.Fatalf("foreign report err=%v, want forbidden", err) + } + + profile, err := ledger.CreateRedactionProfile(ctx, admin, CreateRedactionProfileInput{Name: "customer", AllowedTypes: []string{"build"}}) + if err != nil { + t.Fatalf("profile: %v", err) + } + pkgA, err := ledger.CreateCustomerSecurityPackage(ctx, admin, CreateCustomerPackageInput{ProductID: productA.ID, ReleaseID: releaseA.ID, RedactionProfileID: profile.ID, Title: "A package", ExpiresAt: fixedNow().Add(time.Hour)}) + if err != nil { + t.Fatalf("package A: %v", err) + } + pkgB, err := ledger.CreateCustomerSecurityPackage(ctx, admin, CreateCustomerPackageInput{ProductID: productB.ID, ReleaseID: releaseB.ID, RedactionProfileID: profile.ID, Title: "B package", ExpiresAt: fixedNow().Add(time.Hour)}) + if err != nil { + t.Fatalf("package B: %v", err) + } + verifier, err := ledger.CreateUser(ctx, admin, CreateUserInput{OrganizationID: org.ID, Email: "verifier@example.test", DisplayName: "Verifier"}) + if err != nil { + t.Fatalf("verifier: %v", err) + } + if _, err := ledger.CreateRoleBinding(ctx, admin, CreateRoleBindingInput{SubjectType: "user", SubjectID: verifier.ID, Role: "customer_verifier", ResourceType: "customer_security_package", ResourceID: pkgA.ID}); err != nil { + t.Fatalf("verifier role: %v", err) + } + verifierSession, verifierSecret, err := ledger.CreateSSOSession(ctx, admin, CreateSSOSessionInput{UserID: verifier.ID, ProviderID: provider.ID, ExpiresAt: fixedNow().Add(time.Hour)}) + if err != nil { + t.Fatalf("verifier session: %v", err) + } + _ = verifierSession + verifierActor, err := ledger.Authenticate(ctx, verifierSecret) + if err != nil { + t.Fatalf("verifier auth: %v", err) + } + if _, err := ledger.AccessCustomerSecurityPackage(ctx, verifierActor, pkgA.ID); err != nil { + t.Fatalf("access scoped package: %v", err) + } + if _, err := ledger.AccessCustomerSecurityPackage(ctx, verifierActor, pkgB.ID); !errors.Is(err, ErrForbidden) { + t.Fatalf("access foreign package err=%v, want forbidden", err) + } + if _, err := ledger.SecurityReviewPackageReport(ctx, verifierActor, pkgB.ID); !errors.Is(err, ErrForbidden) { + t.Fatalf("foreign package report err=%v, want forbidden", err) + } +} + +func TestHumanSSOSessionResourceScopeCoversWorkflowFamilies(t *testing.T) { + ledger := NewLedger(Config{APIKeyPepper: "test-pepper", Now: fixedNow}) + ctx := context.Background() + _, _, secret, err := ledger.BootstrapTenant(ctx, "Tenant", "admin", []string{"*"}) + if err != nil { + t.Fatalf("bootstrap: %v", err) + } + admin, err := ledger.Authenticate(ctx, secret) + if err != nil { + t.Fatalf("auth: %v", err) + } + productA, err := ledger.CreateProduct(ctx, admin, "A", "a") + if err != nil { + t.Fatalf("product A: %v", err) + } + releaseA, err := ledger.CreateRelease(ctx, admin, productA.ID, "1") + if err != nil { + t.Fatalf("release A: %v", err) + } + projectA, err := ledger.CreateProject(ctx, admin, productA.ID, "api") + if err != nil { + t.Fatalf("project A: %v", err) + } + artifactA, err := ledger.RegisterArtifact(ctx, admin, "api", "application/octet-stream", sampleDigest("artifact-a"), 10) + if err != nil { + t.Fatalf("artifact A: %v", err) + } + productB, err := ledger.CreateProduct(ctx, admin, "B", "b") + if err != nil { + t.Fatalf("product B: %v", err) + } + releaseB, err := ledger.CreateRelease(ctx, admin, productB.ID, "1") + if err != nil { + t.Fatalf("release B: %v", err) + } + projectB, err := ledger.CreateProject(ctx, admin, productB.ID, "api") + if err != nil { + t.Fatalf("project B: %v", err) + } + artifactB, err := ledger.RegisterArtifact(ctx, admin, "api-b", "application/octet-stream", sampleDigest("artifact-b"), 10) + if err != nil { + t.Fatalf("artifact B: %v", err) + } + + framework, err := ledger.CreateControlFramework(ctx, admin, CreateControlFrameworkInput{Name: "Controls", Version: "1"}) + if err != nil { + t.Fatalf("framework: %v", err) + } + control, err := ledger.CreateSecurityControl(ctx, admin, CreateSecurityControlInput{FrameworkID: framework.ID, Code: "EVD-1", Title: "Evidence", Objective: "Collect evidence", EvidenceRequirements: []domain.ControlEvidenceRequirement{{Type: "build", Required: true}}}) + if err != nil { + t.Fatalf("control: %v", err) + } + buildA, err := ledger.CreateBuildRun(ctx, admin, CreateBuildRunInput{ProjectID: projectA.ID, ReleaseID: releaseA.ID, Provider: "generic_ci", CommitSHA: "0123456789abcdef0123456789abcdef01234567", Status: "passed", StartedAt: fixedNow(), Outputs: []domain.BuildOutput{{ArtifactID: artifactA.ID, Digest: artifactA.Digest}}}) + if err != nil { + t.Fatalf("build A: %v", err) + } + buildB, err := ledger.CreateBuildRun(ctx, admin, CreateBuildRunInput{ProjectID: projectB.ID, ReleaseID: releaseB.ID, Provider: "generic_ci", CommitSHA: "1123456789abcdef0123456789abcdef01234567", Status: "passed", StartedAt: fixedNow(), Outputs: []domain.BuildOutput{{ArtifactID: artifactB.ID, Digest: artifactB.Digest}}}) + if err != nil { + t.Fatalf("build B: %v", err) + } + incidentA, err := ledger.CreateIncident(ctx, admin, CreateIncidentInput{ProductID: productA.ID, ReleaseID: releaseA.ID, Title: "incident A", Severity: "high"}) + if err != nil { + t.Fatalf("incident A: %v", err) + } + incidentB, err := ledger.CreateIncident(ctx, admin, CreateIncidentInput{ProductID: productB.ID, ReleaseID: releaseB.ID, Title: "incident B", Severity: "high"}) + if err != nil { + t.Fatalf("incident B: %v", err) + } + envA, err := ledger.CreateDeploymentEnvironment(ctx, admin, CreateEnvironmentInput{ProductID: productA.ID, Name: "prod-a", Kind: "production"}) + if err != nil { + t.Fatalf("env A: %v", err) + } + envB, err := ledger.CreateDeploymentEnvironment(ctx, admin, CreateEnvironmentInput{ProductID: productB.ID, Name: "prod-b", Kind: "production"}) + if err != nil { + t.Fatalf("env B: %v", err) + } + depA, err := ledger.RecordDeployment(ctx, admin, RecordDeploymentInput{EnvironmentID: envA.ID, ReleaseID: releaseA.ID, ArtifactIDs: []string{artifactA.ID}, Status: deploymentStatusSucceeded, StartedAt: fixedNow()}) + if err != nil { + t.Fatalf("deployment A: %v", err) + } + depB, err := ledger.RecordDeployment(ctx, admin, RecordDeploymentInput{EnvironmentID: envB.ID, ReleaseID: releaseB.ID, ArtifactIDs: []string{artifactB.ID}, Status: deploymentStatusSucceeded, StartedAt: fixedNow()}) + if err != nil { + t.Fatalf("deployment B: %v", err) + } + repoA, err := ledger.CreateSourceRepository(ctx, admin, CreateRepositoryInput{ProjectID: projectA.ID, Provider: "github", FullName: "org/a"}) + if err != nil { + t.Fatalf("repo A: %v", err) + } + repoB, err := ledger.CreateSourceRepository(ctx, admin, CreateRepositoryInput{ProjectID: projectB.ID, Provider: "github", FullName: "org/b"}) + if err != nil { + t.Fatalf("repo B: %v", err) + } + + org, err := ledger.CreateOrganization(ctx, admin, CreateOrganizationInput{Name: "Example", Slug: "example"}) + if err != nil { + t.Fatalf("org: %v", err) + } + user, err := ledger.CreateUser(ctx, admin, CreateUserInput{OrganizationID: org.ID, Email: "scoped@example.test", DisplayName: "Scoped"}) + if err != nil { + t.Fatalf("user: %v", err) + } + if _, err := ledger.CreateRoleBinding(ctx, admin, CreateRoleBindingInput{SubjectType: "user", SubjectID: user.ID, Role: "tenant_admin", ResourceType: "product", ResourceID: productA.ID}); err != nil { + t.Fatalf("role binding: %v", err) + } + provider, err := ledger.CreateSSOProvider(ctx, admin, CreateSSOProviderInput{Name: "OIDC", Type: "oidc", Issuer: "https://idp.example.test", ClientID: "client"}) + if err != nil { + t.Fatalf("provider: %v", err) + } + _, sessionSecret, err := ledger.CreateSSOSession(ctx, admin, CreateSSOSessionInput{UserID: user.ID, ProviderID: provider.ID, ExpiresAt: fixedNow().Add(time.Hour)}) + if err != nil { + t.Fatalf("session: %v", err) + } + scoped, err := ledger.Authenticate(ctx, sessionSecret) + if err != nil { + t.Fatalf("scoped auth: %v", err) + } + + if _, err := ledger.GetBuildRun(ctx, scoped, buildA.ID); err != nil { + t.Fatalf("get allowed build: %v", err) + } + if _, err := ledger.GetBuildRun(ctx, scoped, buildB.ID); !errors.Is(err, ErrForbidden) { + t.Fatalf("get foreign build err=%v, want forbidden", err) + } + if _, err := ledger.LinkControlEvidence(ctx, scoped, control.ID, LinkControlEvidenceInput{EvidenceType: "build", SubjectType: "build", SubjectID: buildA.ID, ProductID: productA.ID, ReleaseID: releaseA.ID, Confidence: confidenceHigh}); err != nil { + t.Fatalf("link allowed control evidence: %v", err) + } + if _, err := ledger.LinkControlEvidence(ctx, scoped, control.ID, LinkControlEvidenceInput{EvidenceType: "build", SubjectType: "build", SubjectID: buildB.ID, ProductID: productB.ID, ReleaseID: releaseB.ID, Confidence: confidenceHigh}); !errors.Is(err, ErrForbidden) { + t.Fatalf("link foreign control evidence err=%v, want forbidden", err) + } + if _, err := ledger.IncidentReport(ctx, scoped, incidentA.ID); err != nil { + t.Fatalf("incident report A: %v", err) + } + if _, err := ledger.IncidentReport(ctx, scoped, incidentB.ID); !errors.Is(err, ErrForbidden) { + t.Fatalf("incident report B err=%v, want forbidden", err) + } + if _, err := ledger.UploadSecurityScan(ctx, scoped, UploadSecurityScanInput{ProductID: productB.ID, ReleaseID: releaseB.ID, Category: "secret_scan", Format: "generic", Scanner: "scan", TargetRef: "target", Raw: []byte(`{"findings":[]}`)}); !errors.Is(err, ErrForbidden) { + t.Fatalf("foreign security scan err=%v, want forbidden", err) + } + if _, err := ledger.GetDeployment(ctx, scoped, depA.ID); err != nil { + t.Fatalf("get deployment A: %v", err) + } + if _, err := ledger.GetDeployment(ctx, scoped, depB.ID); !errors.Is(err, ErrForbidden) { + t.Fatalf("get deployment B err=%v, want forbidden", err) + } + repos, err := ledger.ListSourceRepositories(ctx, scoped, "") + if err != nil { + t.Fatalf("list repos: %v", err) + } + if len(repos) != 1 || repos[0].ID != repoA.ID { + t.Fatalf("scoped repos=%#v want only %s; foreign repo was %s", repos, repoA.ID, repoB.ID) + } + if _, err := ledger.RecordSourceCommit(ctx, scoped, RecordCommitInput{RepositoryID: repoB.ID, SHA: "2123456789abcdef0123456789abcdef01234567", CommittedAt: fixedNow()}); !errors.Is(err, ErrForbidden) { + t.Fatalf("foreign source commit err=%v, want forbidden", err) + } + deployments, err := ledger.ListDeployments(ctx, scoped, "", "") + if err != nil { + t.Fatalf("list deployments: %v", err) + } + if len(deployments) != 1 || deployments[0].ID != depA.ID { + t.Fatalf("scoped deployments=%#v want only %s", deployments, depA.ID) + } +} + +func signedTestIDToken(t *testing.T, private ed25519.PrivateKey, kid string, claims map[string]any) string { + t.Helper() + header := map[string]any{"alg": "EdDSA", "kid": kid, "typ": "JWT"} + headerBody, err := json.Marshal(header) + if err != nil { + t.Fatalf("marshal header: %v", err) + } + claimsBody, err := json.Marshal(claims) + if err != nil { + t.Fatalf("marshal claims: %v", err) + } + unsigned := base64.RawURLEncoding.EncodeToString(headerBody) + "." + base64.RawURLEncoding.EncodeToString(claimsBody) + signature := ed25519.Sign(private, []byte(unsigned)) + return unsigned + "." + base64.RawURLEncoding.EncodeToString(signature) +} + +func signedTestRSAIDToken(t *testing.T, private *rsa.PrivateKey, kid string, claims map[string]any) string { + t.Helper() + headerBody, err := json.Marshal(map[string]any{"alg": "RS256", "kid": kid, "typ": "JWT"}) + if err != nil { + t.Fatalf("marshal header: %v", err) + } + claimsBody, err := json.Marshal(claims) + if err != nil { + t.Fatalf("marshal claims: %v", err) + } + unsigned := base64.RawURLEncoding.EncodeToString(headerBody) + "." + base64.RawURLEncoding.EncodeToString(claimsBody) + signature, err := signRS256TestJWT(private, []byte(unsigned)) + if err != nil { + t.Fatalf("rsa sign: %v", err) + } + return unsigned + "." + base64.RawURLEncoding.EncodeToString(signature) +} + +func samlTestCertificate(t *testing.T) (*rsa.PrivateKey, string) { + t.Helper() + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("rsa keygen: %v", err) + } + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "saml-idp.example.test"}, + NotBefore: fixedNow().Add(-time.Hour), + NotAfter: fixedNow().Add(24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + } + der, err := x509.CreateCertificate(rand.Reader, template, template, &privateKey.PublicKey, privateKey) + if err != nil { + t.Fatalf("certificate: %v", err) + } + return privateKey, string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})) +} + +func signedTestSAMLAssertion(t *testing.T, privateKey *rsa.PrivateKey, issuer, audience, subject string, notBefore, notOnOrAfter time.Time) string { + t.Helper() + notBeforeText := notBefore.UTC().Format(time.RFC3339) + notOnOrAfterText := notOnOrAfter.UTC().Format(time.RFC3339) + payload := samlAssertionSignaturePayload(issuer, audience, subject, notBeforeText, notOnOrAfterText) + sum := sha256.Sum256([]byte(payload)) + signature, err := rsa.SignPKCS1v15(rand.Reader, privateKey, crypto.SHA256, sum[:]) + if err != nil { + t.Fatalf("sign saml assertion: %v", err) + } + return `` + + `` + issuer + `` + + `` + subject + `` + + `` + + `` + audience + `` + + `` + + `` + base64.StdEncoding.EncodeToString(signature) + `` + + `` +} + +func hasVerifyCheck(checks []domain.VerifyCheck, name, result string) bool { + for _, check := range checks { + if check.Name == name && check.Result == result { + return true + } + } + return false +} + +type fakeOIDCDiscovery struct { + request OIDCDiscoveryRequest + result OIDCDiscoveryResult + err error +} + +func (f *fakeOIDCDiscovery) FetchOIDCTrustMaterial(_ context.Context, req OIDCDiscoveryRequest) (OIDCDiscoveryResult, error) { + f.request = req + if f.err != nil { + return OIDCDiscoveryResult{}, f.err + } + return f.result, nil +} + +func bootstrapEnterpriseTestTenant(t *testing.T, ledger *Ledger) (domain.Tenant, domain.APIKey, string, domain.Actor) { + t.Helper() + tenant, key, secret, err := ledger.BootstrapTenant(t.Context(), "Tenant", "admin", []string{"*"}) + if err != nil { + t.Fatalf("bootstrap: %v", err) + } + actor, err := ledger.Authenticate(t.Context(), secret) + if err != nil { + t.Fatalf("auth: %v", err) + } + return tenant, key, secret, actor +} + +func bigEndianExponent(value int) []byte { + if value == 0 { + return []byte{0} + } + out := []byte{} + for value > 0 { + out = append([]byte{byte(value)}, out...) + value >>= 8 + } + return out +} + +func signRS256TestJWT(private *rsa.PrivateKey, body []byte) ([]byte, error) { + sum := sha256.Sum256(body) + return rsa.SignPKCS1v15(rand.Reader, private, crypto.SHA256, sum[:]) } diff --git a/internal/app/errors.go b/internal/app/errors.go index d5a0b40..b850f11 100644 --- a/internal/app/errors.go +++ b/internal/app/errors.go @@ -11,4 +11,5 @@ var ( ErrImmutable = errors.New("immutable resource") ErrIdempotencyConflict = errors.New("idempotency key reused with different request") ErrVerificationFailed = errors.New("verification failed") + ErrRateLimited = errors.New("rate limited") ) diff --git a/internal/app/future_extensions.go b/internal/app/future_extensions.go new file mode 100644 index 0000000..f62b59e --- /dev/null +++ b/internal/app/future_extensions.go @@ -0,0 +1,1507 @@ +package app + +import ( + "bytes" + "context" + "crypto" + "crypto/ed25519" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/hex" + "encoding/json" + "encoding/pem" + "encoding/xml" + "errors" + "fmt" + "math/big" + "sort" + "strings" + "time" + + "github.com/aatuh/evydence/internal/domain" +) + +type CreateEvidenceSummaryInput struct { + SubjectType string + SubjectID string + EvidenceIDs []string +} + +type CreateQuestionnaireDraftInput struct { + TemplateID string + ProductID string + ReleaseID string +} + +type CreateGraphSnapshotInput struct { + ProductID string + ReleaseID string +} + +type CreateSaaSEditionProfileInput struct { + Name string + Region string + AdminTenantID string + IsolationModel string +} + +type CreatePublicTransparencyLogInput struct { + Name string + Endpoint string + PublicKey string +} + +type PublishPublicTransparencyLogEntryInput struct { + LogID string + CheckpointID string + ExternalID string +} + +type VerifyPublicTransparencyLogEntryInput struct { + LeafHash string + RootHash string + LeafIndex int + TreeSize int + InclusionProof []string + Source string +} + +type CreateMarketplaceCollectorInput struct { + Name string + Provider string + Version string + Publisher string + ManifestHash string + SignatureID string + SBOMID string + ScanID string +} + +type CreatePDFReportPackageInput struct { + ReportType string + ProductID string + ReleaseID string + Title string +} + +type AnomalyReportInput struct { + SubjectType string + SubjectID string +} + +type CreateSigningOperationInput struct { + ProviderID string + SubjectType string + SubjectID string + PayloadHash string + ExternalSignature string +} + +type VerifyProviderIdentityInput struct { + ProviderType string + ProviderID string + Subject string + IDToken string + SAMLAssertion string + AccessToken string +} + +func (s packageReportService) CreateEvidenceSummary(ctx context.Context, actor domain.Actor, in CreateEvidenceSummaryInput) (domain.EvidenceSummary, error) { + l := s.ledger + if err := ctx.Err(); err != nil { + return domain.EvidenceSummary{}, err + } + if err := require(actor, ScopeReportRead); err != nil { + return domain.EvidenceSummary{}, err + } + subjectType, subjectID := strings.TrimSpace(in.SubjectType), strings.TrimSpace(in.SubjectID) + if subjectType == "" || subjectID == "" { + return domain.EvidenceSummary{}, ErrValidation + } + l.mu.Lock() + defer l.mu.Unlock() + refs, err := l.ensureFutureSubjectLocked(actor.TenantID, subjectType, subjectID) + if err != nil { + return domain.EvidenceSummary{}, err + } + if err := l.authorizeResourceLocked(actor, ScopeReportRead, refs); err != nil { + return domain.EvidenceSummary{}, err + } + evidenceIDs := sortedStrings(in.EvidenceIDs) + if len(evidenceIDs) == 0 { + evidenceIDs = l.evidenceIDsForRefsLocked(actor.TenantID, refs, "") + } + if len(evidenceIDs) == 0 { + return domain.EvidenceSummary{}, ErrValidation + } + citations := make([]domain.EvidenceCitation, 0, len(evidenceIDs)) + titles := make([]string, 0, len(evidenceIDs)) + for _, id := range evidenceIDs { + item, ok := l.evidence[id] + if !ok || item.TenantID != actor.TenantID { + return domain.EvidenceSummary{}, ErrNotFound + } + if !evidenceMatchesRefs(item, refs) { + return domain.EvidenceSummary{}, ErrValidation + } + citations = append(citations, domain.EvidenceCitation{EvidenceID: item.ID, Type: item.Type, Title: item.Title, CanonicalHash: item.CanonicalHash}) + titles = append(titles, item.Title) + } + summaryText := "Technical evidence recorded for " + subjectType + " " + subjectID + ": " + strings.Join(titles, "; ") + "." + summary := domain.EvidenceSummary{ + ID: newID("sum"), + TenantID: actor.TenantID, + SubjectType: subjectType, + SubjectID: subjectID, + EvidenceIDs: evidenceIDs, + Summary: summaryText, + Citations: citations, + Assumptions: []string{"Summary is generated only from explicitly linked Evydence records."}, + Limitations: []string{"This summary supports evidence review and does not assert legal compliance, certification, or release security."}, + SchemaVersion: domain.EvidenceSummaryVersion, + CreatedAt: l.now(), + } + l.evidenceSummaries[summary.ID] = summary + _, _ = l.appendChainLocked(actor.TenantID, "evidence_summary.created", "evidence_summary", summary.ID, actorType(actor), actorID(actor), "", "") + if err := l.persistLocked(ctx); err != nil { + return domain.EvidenceSummary{}, err + } + return summary, nil +} + +func (s packageReportService) CreateQuestionnaireDraft(ctx context.Context, actor domain.Actor, in CreateQuestionnaireDraftInput) (domain.QuestionnaireDraft, error) { + l := s.ledger + if err := ctx.Err(); err != nil { + return domain.QuestionnaireDraft{}, err + } + if err := require(actor, ScopePackageRead); err != nil { + return domain.QuestionnaireDraft{}, err + } + if strings.TrimSpace(in.TemplateID) == "" { + return domain.QuestionnaireDraft{}, ErrValidation + } + l.mu.Lock() + defer l.mu.Unlock() + template, ok := l.questionTemplates[strings.TrimSpace(in.TemplateID)] + if !ok || template.TenantID != actor.TenantID { + return domain.QuestionnaireDraft{}, ErrNotFound + } + if err := l.ensureScopeLocked(actor.TenantID, strings.TrimSpace(in.ProductID), "", strings.TrimSpace(in.ReleaseID)); err != nil { + return domain.QuestionnaireDraft{}, err + } + if err := l.authorizeResourceLocked(actor, ScopePackageRead, resourceRefs{ProductID: strings.TrimSpace(in.ProductID), ReleaseID: strings.TrimSpace(in.ReleaseID)}); err != nil { + return domain.QuestionnaireDraft{}, err + } + responses := make([]domain.QuestionnaireResponse, 0, len(template.Questions)) + for _, question := range template.Questions { + responses = append(responses, l.questionnaireResponseForQuestionLocked(actor.TenantID, question, in.ProductID, in.ReleaseID)) + } + hash, err := canonicalAnyHash(responses) + if err != nil { + return domain.QuestionnaireDraft{}, err + } + draft := domain.QuestionnaireDraft{ + ID: newID("qdr"), + TenantID: actor.TenantID, + TemplateID: template.ID, + ProductID: strings.TrimSpace(in.ProductID), + ReleaseID: strings.TrimSpace(in.ReleaseID), + Responses: responses, + ManifestHash: hash, + Limitations: []string{"Generated answers are drafts based on stored evidence and do not provide compliance conclusions."}, + SchemaVersion: domain.QuestionnaireDraftVersion, + CreatedAt: l.now(), + } + l.questionDrafts[draft.ID] = draft + _, _ = l.appendChainLocked(actor.TenantID, "questionnaire_draft.created", "questionnaire_draft", draft.ID, actorType(actor), actorID(actor), hash, "") + if err := l.persistLocked(ctx); err != nil { + return domain.QuestionnaireDraft{}, err + } + return draft, nil +} + +func (l *Ledger) CreateGraphSnapshot(ctx context.Context, actor domain.Actor, in CreateGraphSnapshotInput) (domain.EvidenceGraphSnapshot, error) { + if err := ctx.Err(); err != nil { + return domain.EvidenceGraphSnapshot{}, err + } + if err := require(actor, ScopeEvidenceRead); err != nil { + return domain.EvidenceGraphSnapshot{}, err + } + productID, releaseID := strings.TrimSpace(in.ProductID), strings.TrimSpace(in.ReleaseID) + if productID == "" && releaseID == "" { + return domain.EvidenceGraphSnapshot{}, ErrValidation + } + l.mu.Lock() + defer l.mu.Unlock() + if err := l.ensureScopeLocked(actor.TenantID, productID, "", releaseID); err != nil { + return domain.EvidenceGraphSnapshot{}, err + } + refs := resourceRefs{ProductID: productID, ReleaseID: releaseID} + if err := l.authorizeResourceLocked(actor, ScopeEvidenceRead, refs); err != nil { + return domain.EvidenceGraphSnapshot{}, err + } + nodes := []domain.GraphNode{} + edges := []domain.GraphEdge{} + if productID != "" { + product := l.products[productID] + nodes = append(nodes, domain.GraphNode{ID: product.ID, Type: "product", Label: product.Name}) + } + if releaseID != "" { + release := l.releases[releaseID] + nodes = append(nodes, domain.GraphNode{ID: release.ID, Type: "release", Label: release.Version}) + if productID != "" { + edges = append(edges, domain.GraphEdge{From: productID, To: release.ID, Relationship: "has_release"}) + } + } + for _, id := range l.evidenceIDsForRefsLocked(actor.TenantID, refs, "") { + item := l.evidence[id] + nodes = append(nodes, domain.GraphNode{ID: item.ID, Type: "evidence", Label: item.Title}) + if item.ReleaseID != "" { + edges = append(edges, domain.GraphEdge{From: item.ReleaseID, To: item.ID, Relationship: "has_evidence"}) + } else if item.ProductID != "" { + edges = append(edges, domain.GraphEdge{From: item.ProductID, To: item.ID, Relationship: "has_evidence"}) + } + for _, ref := range item.SubjectRefs { + if ref.ID != "" { + edges = append(edges, domain.GraphEdge{From: item.ID, To: ref.ID, Relationship: "references_" + ref.Type}) + } + } + } + hash, err := canonicalAnyHash(struct { + Nodes []domain.GraphNode `json:"nodes"` + Edges []domain.GraphEdge `json:"edges"` + }{Nodes: nodes, Edges: edges}) + if err != nil { + return domain.EvidenceGraphSnapshot{}, err + } + graph := domain.EvidenceGraphSnapshot{ + ID: newID("grf"), + TenantID: actor.TenantID, + ProductID: productID, + ReleaseID: releaseID, + Nodes: nodes, + Edges: edges, + GraphHash: hash, + Limitations: []string{"Snapshot includes stored Evydence adjacency only; absence of a node is not proof that evidence does not exist elsewhere."}, + SchemaVersion: domain.EvidenceGraphSnapshotVersion, + CreatedAt: l.now(), + } + l.graphSnapshots[graph.ID] = graph + _, _ = l.appendChainLocked(actor.TenantID, "evidence_graph_snapshot.created", "evidence_graph_snapshot", graph.ID, actorType(actor), actorID(actor), hash, "") + if err := l.persistLocked(ctx); err != nil { + return domain.EvidenceGraphSnapshot{}, err + } + return graph, nil +} + +func (l *Ledger) CreateSaaSEditionProfile(ctx context.Context, actor domain.Actor, in CreateSaaSEditionProfileInput) (domain.SaaSEditionProfile, error) { + if err := ctx.Err(); err != nil { + return domain.SaaSEditionProfile{}, err + } + if !actorHasExactScope(actor, ScopeInstanceAdmin) { + return domain.SaaSEditionProfile{}, ErrForbidden + } + name, region, adminTenantID, isolation := strings.TrimSpace(in.Name), strings.TrimSpace(in.Region), strings.TrimSpace(in.AdminTenantID), strings.TrimSpace(in.IsolationModel) + if name == "" || region == "" || adminTenantID == "" || isolation == "" { + return domain.SaaSEditionProfile{}, ErrValidation + } + l.mu.Lock() + defer l.mu.Unlock() + if _, ok := l.tenants[adminTenantID]; !ok { + return domain.SaaSEditionProfile{}, ErrNotFound + } + cfgHash, err := canonicalAnyHash(in) + if err != nil { + return domain.SaaSEditionProfile{}, err + } + profile := domain.SaaSEditionProfile{ + ID: newID("saas"), + TenantID: actor.TenantID, + Name: name, + Region: region, + AdminTenantID: adminTenantID, + IsolationModel: isolation, + Status: "proposed", + ConfigHash: cfgHash, + Limitations: []string{"This profile records SaaS edition configuration intent; it is not a deployment readiness certification."}, + SchemaVersion: domain.SaaSEditionProfileVersion, + CreatedAt: l.now(), + } + l.saasProfiles[profile.ID] = profile + _, _ = l.appendChainLocked(actor.TenantID, "saas_profile.created", "saas_profile", profile.ID, actorType(actor), actorID(actor), cfgHash, "") + if err := l.persistLocked(ctx); err != nil { + return domain.SaaSEditionProfile{}, err + } + return profile, nil +} + +func (l *Ledger) CreatePublicTransparencyLog(ctx context.Context, actor domain.Actor, in CreatePublicTransparencyLogInput) (domain.PublicTransparencyLog, error) { + if err := ctx.Err(); err != nil { + return domain.PublicTransparencyLog{}, err + } + if err := require(actor, ScopeKeysAdmin); err != nil { + return domain.PublicTransparencyLog{}, err + } + name, endpoint, publicKey := strings.TrimSpace(in.Name), strings.TrimSpace(in.Endpoint), strings.TrimSpace(in.PublicKey) + if name == "" || publicKey == "" || !strings.HasPrefix(endpoint, "https://") { + return domain.PublicTransparencyLog{}, ErrValidation + } + l.mu.Lock() + defer l.mu.Unlock() + record := domain.PublicTransparencyLog{ID: newID("ptl"), TenantID: actor.TenantID, Name: name, Endpoint: endpoint, PublicKey: publicKey, State: "configured", SchemaVersion: domain.PublicTransparencyLogVersion, CreatedAt: l.now()} + l.publicLogs[record.ID] = record + _, _ = l.appendChainLocked(actor.TenantID, "public_transparency_log.created", "public_transparency_log", record.ID, actorType(actor), actorID(actor), "", "") + if err := l.persistLocked(ctx); err != nil { + return domain.PublicTransparencyLog{}, err + } + return record, nil +} + +func (l *Ledger) PublishPublicTransparencyLogEntry(ctx context.Context, actor domain.Actor, in PublishPublicTransparencyLogEntryInput) (domain.PublicTransparencyLogEntry, error) { + if err := ctx.Err(); err != nil { + return domain.PublicTransparencyLogEntry{}, err + } + if err := require(actor, ScopeKeysAdmin); err != nil { + return domain.PublicTransparencyLogEntry{}, err + } + logID, checkpointID, externalID := strings.TrimSpace(in.LogID), strings.TrimSpace(in.CheckpointID), strings.TrimSpace(in.ExternalID) + if logID == "" || checkpointID == "" || externalID == "" { + return domain.PublicTransparencyLogEntry{}, ErrValidation + } + l.mu.Lock() + defer l.mu.Unlock() + logRecord, ok := l.publicLogs[logID] + if !ok || logRecord.TenantID != actor.TenantID { + return domain.PublicTransparencyLogEntry{}, ErrNotFound + } + checkpoint, ok := l.transparency[checkpointID] + if !ok || checkpoint.TenantID != actor.TenantID { + return domain.PublicTransparencyLogEntry{}, ErrNotFound + } + batch, ok := l.merkleBatches[checkpoint.BatchID] + if !ok || batch.TenantID != actor.TenantID { + return domain.PublicTransparencyLogEntry{}, ErrNotFound + } + entryHash, err := canonicalAnyHash(struct { + LogID string `json:"log_id"` + CheckpointID string `json:"checkpoint_id"` + MerkleRoot string `json:"merkle_root"` + ExternalID string `json:"external_id"` + }{LogID: logRecord.ID, CheckpointID: checkpoint.ID, MerkleRoot: batch.RootHash, ExternalID: externalID}) + if err != nil { + return domain.PublicTransparencyLogEntry{}, err + } + entry := domain.PublicTransparencyLogEntry{ID: newID("pte"), TenantID: actor.TenantID, LogID: logRecord.ID, CheckpointID: checkpoint.ID, MerkleBatchID: batch.ID, ExternalID: externalID, EntryHash: entryHash, State: "published", SchemaVersion: domain.PublicTransparencyEntryVersion, CreatedAt: l.now()} + l.publicLogEntries[entry.ID] = entry + _, _ = l.appendChainLocked(actor.TenantID, "public_transparency_log_entry.published", "public_transparency_log_entry", entry.ID, actorType(actor), actorID(actor), entryHash, "") + if err := l.persistLocked(ctx); err != nil { + return domain.PublicTransparencyLogEntry{}, err + } + return entry, nil +} + +func (l *Ledger) VerifyPublicTransparencyLogEntry(ctx context.Context, actor domain.Actor, id string, in VerifyPublicTransparencyLogEntryInput) (domain.PublicTransparencyLogEntry, error) { + if err := ctx.Err(); err != nil { + return domain.PublicTransparencyLogEntry{}, err + } + if err := require(actor, ScopeKeysAdmin); err != nil { + return domain.PublicTransparencyLogEntry{}, err + } + id = strings.TrimSpace(id) + leafHash := strings.TrimSpace(in.LeafHash) + rootHash := strings.TrimSpace(in.RootHash) + if id == "" || rootHash == "" || in.TreeSize <= 0 || in.LeafIndex < 0 || in.LeafIndex >= in.TreeSize || len(in.InclusionProof) > 64 { + return domain.PublicTransparencyLogEntry{}, ErrValidation + } + for i := range in.InclusionProof { + in.InclusionProof[i] = strings.TrimSpace(in.InclusionProof[i]) + if !validSHA256Digest(in.InclusionProof[i]) { + return domain.PublicTransparencyLogEntry{}, ErrValidation + } + } + if !validSHA256Digest(rootHash) { + return domain.PublicTransparencyLogEntry{}, ErrValidation + } + + l.mu.Lock() + entry, ok := l.publicLogEntries[id] + if !ok || entry.TenantID != actor.TenantID { + l.mu.Unlock() + return domain.PublicTransparencyLogEntry{}, ErrNotFound + } + if leafHash == "" { + leafHash = entry.EntryHash + } + if !validSHA256Digest(leafHash) { + l.mu.Unlock() + return domain.PublicTransparencyLogEntry{}, ErrValidation + } + if _, ok := l.publicLogs[entry.LogID]; !ok { + l.mu.Unlock() + return domain.PublicTransparencyLogEntry{}, ErrNotFound + } + if _, ok := l.transparency[entry.CheckpointID]; !ok { + l.mu.Unlock() + return domain.PublicTransparencyLogEntry{}, ErrNotFound + } + l.mu.Unlock() + + leafBound := leafHash == entry.EntryHash + proofOK := leafBound && verifyRFC6962StyleProof(leafHash, rootHash, in.LeafIndex, in.TreeSize, in.InclusionProof) + checks := []domain.VerifyCheck{ + {Name: "public_log_leaf_binding", Result: checkResultString(leafBound), Detail: "The supplied leaf hash must match the published Evydence entry hash."}, + {Name: "public_log_inclusion_proof", Result: checkResultString(proofOK), Detail: "The supplied proof must recompute the supplied public-log root hash."}, + } + source := strings.TrimSpace(in.Source) + if source == "fetched" { + checks = append(checks, domain.VerifyCheck{Name: "public_log_proof_source", Result: "passed", Detail: "Inclusion proof material was fetched through the configured transparency proof fetcher."}) + } + proofHash, err := canonicalAnyHash(struct { + EntryID string `json:"entry_id"` + LeafHash string `json:"leaf_hash"` + RootHash string `json:"root_hash"` + LeafIndex int `json:"leaf_index"` + TreeSize int `json:"tree_size"` + InclusionProof []string `json:"inclusion_proof"` + Source string `json:"source,omitempty"` + }{ + EntryID: entry.ID, + LeafHash: leafHash, + RootHash: rootHash, + LeafIndex: in.LeafIndex, + TreeSize: in.TreeSize, + InclusionProof: append([]string(nil), in.InclusionProof...), + Source: source, + }) + if err != nil { + return domain.PublicTransparencyLogEntry{}, err + } + now := l.now() + entry.InclusionRootHash = rootHash + entry.InclusionProofHash = proofHash + entry.InclusionVerifiedAt = &now + entry.VerificationChecks = checks + entry.VerificationLimitations = []string{ + "Evydence verifies RFC6962-style proof material locally; the material may be supplied by an operator or fetched through a configured transparency proof fetcher.", + "Operators remain responsible for public-log trust, endpoint availability, and any provider-specific inclusion semantics.", + } + if source == "fetched" { + entry.VerificationLimitations = append(entry.VerificationLimitations, "Fetched proof material is trusted only as input to local verification; provider identity and availability remain deployment responsibilities.") + } + entry.State = "inclusion_verified" + if !proofOK { + entry.State = "inclusion_not_verified" + } + + l.mu.Lock() + defer l.mu.Unlock() + l.publicLogEntries[entry.ID] = entry + eventType := "public_transparency_log_entry.inclusion_verified" + if !proofOK { + eventType = "public_transparency_log_entry.inclusion_not_verified" + } + _, _ = l.appendChainLocked(actor.TenantID, eventType, "public_transparency_log_entry", entry.ID, actorType(actor), actorID(actor), proofHash, "") + if err := l.persistLocked(ctx); err != nil { + return domain.PublicTransparencyLogEntry{}, err + } + return entry, nil +} + +func (l *Ledger) FetchAndVerifyPublicTransparencyLogEntry(ctx context.Context, actor domain.Actor, id string) (domain.PublicTransparencyLogEntry, error) { + if err := ctx.Err(); err != nil { + return domain.PublicTransparencyLogEntry{}, err + } + if err := require(actor, ScopeKeysAdmin); err != nil { + return domain.PublicTransparencyLogEntry{}, err + } + id = strings.TrimSpace(id) + if id == "" { + return domain.PublicTransparencyLogEntry{}, ErrValidation + } + fetcher := l.transparencyProofs + if fetcher == nil { + return domain.PublicTransparencyLogEntry{}, ErrValidation + } + l.mu.Lock() + entry, ok := l.publicLogEntries[id] + if !ok || entry.TenantID != actor.TenantID { + l.mu.Unlock() + return domain.PublicTransparencyLogEntry{}, ErrNotFound + } + logRecord, ok := l.publicLogs[entry.LogID] + if !ok || logRecord.TenantID != actor.TenantID { + l.mu.Unlock() + return domain.PublicTransparencyLogEntry{}, ErrNotFound + } + l.mu.Unlock() + + proof, err := fetcher.FetchTransparencyProof(ctx, TransparencyProofRequest{ + TenantID: actor.TenantID, + LogID: logRecord.ID, + EntryID: entry.ID, + Endpoint: logRecord.Endpoint, + ExternalID: entry.ExternalID, + EntryHash: entry.EntryHash, + }) + if err != nil { + return domain.PublicTransparencyLogEntry{}, ErrVerificationFailed + } + if proof.ExternalID != "" && proof.ExternalID != entry.ExternalID { + return domain.PublicTransparencyLogEntry{}, ErrVerificationFailed + } + if strings.TrimSpace(proof.LeafHash) == "" { + proof.LeafHash = entry.EntryHash + } + return l.VerifyPublicTransparencyLogEntry(ctx, actor, entry.ID, VerifyPublicTransparencyLogEntryInput{ + LeafHash: proof.LeafHash, + RootHash: proof.RootHash, + LeafIndex: proof.LeafIndex, + TreeSize: proof.TreeSize, + InclusionProof: proof.InclusionProof, + Source: "fetched", + }) +} + +func verifyRFC6962StyleProof(leafHash, rootHash string, leafIndex, treeSize int, proof []string) bool { + if treeSize == 1 { + return leafIndex == 0 && len(proof) == 0 && leafHash == rootHash + } + node, err := decodeSHA256Digest(leafHash) + if err != nil { + return false + } + index := leafIndex + for _, proofHash := range proof { + sibling, err := decodeSHA256Digest(proofHash) + if err != nil { + return false + } + if index%2 == 0 { + node = transparencyParentHash(node, sibling) + } else { + node = transparencyParentHash(sibling, node) + } + index /= 2 + } + root, err := decodeSHA256Digest(rootHash) + if err != nil { + return false + } + return bytes.Equal(node, root) +} + +func transparencyParentHash(left, right []byte) []byte { + h := sha256.New() + h.Write([]byte{0x01}) + h.Write(left) + h.Write(right) + return h.Sum(nil) +} + +func decodeSHA256Digest(value string) ([]byte, error) { + if !validSHA256Digest(value) { + return nil, ErrValidation + } + return hex.DecodeString(strings.TrimPrefix(value, "sha256:")) +} + +func validSHA256Digest(value string) bool { + if !strings.HasPrefix(value, "sha256:") || len(value) != len("sha256:")+64 { + return false + } + _, err := hex.DecodeString(strings.TrimPrefix(value, "sha256:")) + return err == nil +} + +func checkResultString(ok bool) string { + if ok { + return "passed" + } + return "failed" +} + +func (l *Ledger) CreateMarketplaceCollector(ctx context.Context, actor domain.Actor, in CreateMarketplaceCollectorInput) (domain.MarketplaceCollector, error) { + if err := ctx.Err(); err != nil { + return domain.MarketplaceCollector{}, err + } + if err := require(actor, ScopeCollectorAdmin); err != nil { + return domain.MarketplaceCollector{}, err + } + name, provider, version, publisher := strings.TrimSpace(in.Name), strings.TrimSpace(in.Provider), strings.TrimSpace(in.Version), strings.TrimSpace(in.Publisher) + if name == "" || provider == "" || version == "" || publisher == "" || !validDigest(strings.TrimSpace(in.ManifestHash)) { + return domain.MarketplaceCollector{}, ErrValidation + } + l.mu.Lock() + defer l.mu.Unlock() + if in.SignatureID != "" { + sig, ok := l.signatures[strings.TrimSpace(in.SignatureID)] + if !ok || sig.TenantID != actor.TenantID { + return domain.MarketplaceCollector{}, ErrNotFound + } + } + if in.SBOMID != "" { + sbom, ok := l.sboms[strings.TrimSpace(in.SBOMID)] + if !ok || sbom.TenantID != actor.TenantID { + return domain.MarketplaceCollector{}, ErrNotFound + } + } + if in.ScanID != "" { + scan, ok := l.scans[strings.TrimSpace(in.ScanID)] + if !ok || scan.TenantID != actor.TenantID { + return domain.MarketplaceCollector{}, ErrNotFound + } + } + collector := domain.MarketplaceCollector{ + ID: newID("mpc"), + TenantID: actor.TenantID, + Name: name, + Provider: provider, + Version: version, + Publisher: publisher, + ManifestHash: strings.TrimSpace(in.ManifestHash), + SignatureID: strings.TrimSpace(in.SignatureID), + SBOMID: strings.TrimSpace(in.SBOMID), + ScanID: strings.TrimSpace(in.ScanID), + State: "registered", + Limitations: []string{"Registration records package metadata and does not imply marketplace trust or endorsement."}, + SchemaVersion: domain.MarketplaceCollectorVersion, + CreatedAt: l.now(), + } + l.marketplaceCollectors[collector.ID] = collector + _, _ = l.appendChainLocked(actor.TenantID, "marketplace_collector.created", "marketplace_collector", collector.ID, actorType(actor), actorID(actor), collector.ManifestHash, "") + if err := l.persistLocked(ctx); err != nil { + return domain.MarketplaceCollector{}, err + } + return collector, nil +} + +func (l *Ledger) ListMarketplaceCollectors(ctx context.Context, actor domain.Actor) ([]domain.MarketplaceCollector, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + if err := require(actor, ScopeCollectorRead); err != nil { + return nil, err + } + l.mu.Lock() + defer l.mu.Unlock() + out := []domain.MarketplaceCollector{} + for _, collector := range l.marketplaceCollectors { + if collector.TenantID == actor.TenantID { + out = append(out, collector) + } + } + sortMarketplaceCollectors(out) + return out, nil +} + +func (l *Ledger) MarketplaceCollectorHealth(ctx context.Context, actor domain.Actor, id string) (domain.MarketplaceCollectorHealthReport, error) { + if err := ctx.Err(); err != nil { + return domain.MarketplaceCollectorHealthReport{}, err + } + if err := require(actor, ScopeCollectorRead); err != nil { + return domain.MarketplaceCollectorHealthReport{}, err + } + l.mu.Lock() + defer l.mu.Unlock() + collector, ok := l.marketplaceCollectors[strings.TrimSpace(id)] + if !ok || collector.TenantID != actor.TenantID { + return domain.MarketplaceCollectorHealthReport{}, ErrNotFound + } + checks := []domain.VerifyCheck{{Name: "manifest_digest", Result: "passed", Detail: "collector package manifest digest is recorded"}} + result := "verified" + if collector.SignatureID == "" { + result = "incomplete" + checks = append(checks, domain.VerifyCheck{Name: "signature_evidence", Result: "failed", Detail: "collector package signature evidence is missing"}) + } else if sig, ok := l.signatures[collector.SignatureID]; !ok || sig.TenantID != actor.TenantID { + result = "failed" + checks = append(checks, domain.VerifyCheck{Name: "signature_evidence", Result: "failed", Detail: "collector package signature evidence reference is invalid"}) + } else { + checks = append(checks, domain.VerifyCheck{Name: "signature_evidence", Result: "passed"}) + } + if collector.SBOMID == "" { + result = worseHealth(result, "incomplete") + checks = append(checks, domain.VerifyCheck{Name: "sbom_evidence", Result: "failed", Detail: "collector package SBOM evidence is missing"}) + } else if sbom, ok := l.sboms[collector.SBOMID]; !ok || sbom.TenantID != actor.TenantID { + result = "failed" + checks = append(checks, domain.VerifyCheck{Name: "sbom_evidence", Result: "failed", Detail: "collector package SBOM evidence reference is invalid"}) + } else { + checks = append(checks, domain.VerifyCheck{Name: "sbom_evidence", Result: "passed"}) + } + if collector.ScanID == "" { + result = worseHealth(result, "incomplete") + checks = append(checks, domain.VerifyCheck{Name: "vulnerability_scan_evidence", Result: "failed", Detail: "collector package vulnerability scan evidence is missing"}) + } else if scan, ok := l.scans[collector.ScanID]; !ok || scan.TenantID != actor.TenantID { + result = "failed" + checks = append(checks, domain.VerifyCheck{Name: "vulnerability_scan_evidence", Result: "failed", Detail: "collector package vulnerability scan evidence reference is invalid"}) + } else { + checks = append(checks, domain.VerifyCheck{Name: "vulnerability_scan_evidence", Result: "passed"}) + } + return domain.MarketplaceCollectorHealthReport{ + ReportType: "marketplace_collector_health", + CollectorID: collector.ID, + Name: collector.Name, + Provider: collector.Provider, + Version: collector.Version, + SupplyChainStatus: result, + Checks: checks, + Collector: collector, + Assumptions: []string{"Health is based on evidence recorded in Evydence for this tenant."}, + Limitations: []string{"This report does not prove marketplace trust, package safety, or provider endorsement."}, + GeneratedAt: l.now(), + }, nil +} + +func worseHealth(current, candidate string) string { + if current == "failed" || candidate == "failed" { + return "failed" + } + if current == "incomplete" || candidate == "incomplete" { + return "incomplete" + } + return "verified" +} + +type oidcJWTHeader struct { + Alg string `json:"alg"` + KID string `json:"kid"` + Typ string `json:"typ"` +} + +type oidcJWTClaims struct { + Issuer string `json:"iss"` + Audience any `json:"aud"` + Subject string `json:"sub"` + Email string `json:"email"` + EmailVerified bool `json:"email_verified"` + ExpiresAt int64 `json:"exp"` + NotBefore int64 `json:"nbf,omitempty"` + IssuedAt int64 `json:"iat,omitempty"` +} + +func verifyOIDCIDToken(provider domain.SSOProvider, expectedSubject, token string, now time.Time) ([]domain.VerifyCheck, error) { + checks := []domain.VerifyCheck{} + if len(token) > 16*1024 { + return []domain.VerifyCheck{{Name: "id_token_size", Result: "failed"}}, ErrVerificationFailed + } + parts := strings.Split(token, ".") + if len(parts) != 3 { + return []domain.VerifyCheck{{Name: "id_token_shape", Result: "failed"}}, ErrVerificationFailed + } + headerBody, err := base64.RawURLEncoding.DecodeString(parts[0]) + if err != nil { + return []domain.VerifyCheck{{Name: "id_token_header", Result: "failed"}}, ErrVerificationFailed + } + var header oidcJWTHeader + if err := json.Unmarshal(headerBody, &header); err != nil { + return []domain.VerifyCheck{{Name: "id_token_header", Result: "failed"}}, ErrVerificationFailed + } + if (header.Alg != "EdDSA" && header.Alg != "RS256") || strings.TrimSpace(header.KID) == "" { + return []domain.VerifyCheck{{Name: "id_token_algorithm", Result: "failed"}}, ErrVerificationFailed + } + signature, err := base64.RawURLEncoding.DecodeString(parts[2]) + if err != nil { + return []domain.VerifyCheck{{Name: "id_token_signature", Result: "failed"}}, ErrVerificationFailed + } + unsigned := parts[0] + "." + parts[1] + if err := verifyOIDCJWTSignature(provider.JWKS, header, []byte(unsigned), signature); err != nil { + return []domain.VerifyCheck{{Name: "id_token_signature", Result: "failed"}}, ErrVerificationFailed + } + checks = append(checks, domain.VerifyCheck{Name: "id_token_signature", Result: "passed"}) + claimsBody, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return checksWithFailure(checks, "id_token_claims"), ErrVerificationFailed + } + var claims oidcJWTClaims + if err := json.Unmarshal(claimsBody, &claims); err != nil { + return checksWithFailure(checks, "id_token_claims"), ErrVerificationFailed + } + if claims.Issuer != provider.Issuer { + return checksWithFailure(checks, "issuer"), ErrVerificationFailed + } + checks = append(checks, domain.VerifyCheck{Name: "issuer", Result: "passed"}) + if !audienceContains(claims.Audience, provider.ClientID) { + return checksWithFailure(checks, "audience"), ErrVerificationFailed + } + checks = append(checks, domain.VerifyCheck{Name: "audience", Result: "passed"}) + if claims.Subject == "" || claims.Subject != expectedSubject { + return checksWithFailure(checks, "subject"), ErrVerificationFailed + } + checks = append(checks, domain.VerifyCheck{Name: "subject", Result: "passed"}) + if claims.ExpiresAt == 0 || !time.Unix(claims.ExpiresAt, 0).After(now) { + return checksWithFailure(checks, "expiry"), ErrVerificationFailed + } + if claims.NotBefore != 0 && time.Unix(claims.NotBefore, 0).After(now.Add(time.Minute)) { + return checksWithFailure(checks, "not_before"), ErrVerificationFailed + } + checks = append(checks, domain.VerifyCheck{Name: "token_time", Result: "passed"}) + if claims.Email != "" && !claims.EmailVerified { + return checksWithFailure(checks, "email_verified"), ErrVerificationFailed + } + if claims.Email != "" { + checks = append(checks, domain.VerifyCheck{Name: "email_verified", Result: "passed"}) + } + return checks, nil +} + +func oidcJWKEd25519Key(jwks map[string]any, kid string) (ed25519.PublicKey, error) { + keys, ok := jwks["keys"].([]any) + if !ok { + return nil, errors.New("jwks missing keys") + } + for _, raw := range keys { + key, ok := raw.(map[string]any) + if !ok || key["kid"] != kid || key["kty"] != "OKP" || key["crv"] != "Ed25519" { + continue + } + x, _ := key["x"].(string) + pub, err := base64.RawURLEncoding.DecodeString(x) + if err == nil && len(pub) == ed25519.PublicKeySize { + return ed25519.PublicKey(pub), nil + } + } + return nil, errors.New("matching jwk not found") +} + +func verifyOIDCJWTSignature(jwks map[string]any, header oidcJWTHeader, unsigned, signature []byte) error { + switch header.Alg { + case "EdDSA": + if len(signature) != ed25519.SignatureSize { + return errors.New("invalid ed25519 signature size") + } + key, err := oidcJWKEd25519Key(jwks, header.KID) + if err != nil { + return err + } + if !ed25519.Verify(key, unsigned, signature) { + return errors.New("invalid ed25519 signature") + } + return nil + case "RS256": + key, err := oidcJWKRSAKey(jwks, header.KID) + if err != nil { + return err + } + sum := sha256.Sum256(unsigned) + return rsa.VerifyPKCS1v15(key, crypto.SHA256, sum[:], signature) + default: + return errors.New("unsupported jwt algorithm") + } +} + +func oidcJWKRSAKey(jwks map[string]any, kid string) (*rsa.PublicKey, error) { + keys, ok := jwks["keys"].([]any) + if !ok { + return nil, errors.New("jwks missing keys") + } + for _, raw := range keys { + key, ok := raw.(map[string]any) + if !ok || key["kid"] != kid || key["kty"] != "RSA" { + continue + } + nValue, _ := key["n"].(string) + eValue, _ := key["e"].(string) + modulusBytes, err := base64.RawURLEncoding.DecodeString(nValue) + if err != nil || len(modulusBytes) == 0 { + continue + } + exponentBytes, err := base64.RawURLEncoding.DecodeString(eValue) + if err != nil || len(exponentBytes) == 0 || len(exponentBytes) > 8 { + continue + } + exponent := 0 + for _, b := range exponentBytes { + exponent = exponent<<8 + int(b) + } + if exponent < 3 { + continue + } + return &rsa.PublicKey{N: new(big.Int).SetBytes(modulusBytes), E: exponent}, nil + } + return nil, errors.New("matching rsa jwk not found") +} + +func checksWithFailure(checks []domain.VerifyCheck, name string) []domain.VerifyCheck { + return append(checks, domain.VerifyCheck{Name: name, Result: "failed"}) +} + +func audienceContains(audience any, expected string) bool { + switch got := audience.(type) { + case string: + return got == expected + case []any: + for _, item := range got { + if value, ok := item.(string); ok && value == expected { + return true + } + } + } + return false +} + +type samlAssertionDocument struct { + XMLName xml.Name `xml:"Assertion"` + Issuer string `xml:"Issuer"` + Subject samlAssertionSubject `xml:"Subject"` + Conditions samlAssertionConditions `xml:"Conditions"` + Signature samlAssertionSignature `xml:"Signature"` +} + +type samlAssertionSubject struct { + NameID string `xml:"NameID"` +} + +type samlAssertionConditions struct { + NotBefore string `xml:"NotBefore,attr"` + NotOnOrAfter string `xml:"NotOnOrAfter,attr"` + Audience samlAudienceRestriction `xml:"AudienceRestriction"` +} + +type samlAudienceRestriction struct { + Audience string `xml:"Audience"` +} + +type samlAssertionSignature struct { + Algorithm string `xml:"Algorithm,attr"` + SignatureValue string `xml:"SignatureValue"` +} + +func verifySAMLAssertion(provider domain.SSOProvider, expectedSubject, assertion string, now time.Time) ([]domain.VerifyCheck, error) { + if len(assertion) > 128*1024 { + return []domain.VerifyCheck{{Name: "saml_assertion_size", Result: "failed"}}, ErrVerificationFailed + } + if len(provider.SAMLSigningCertificates) == 0 { + return []domain.VerifyCheck{{Name: "saml_signing_certificate", Result: "failed"}}, ErrVerificationFailed + } + var doc samlAssertionDocument + decoder := xml.NewDecoder(strings.NewReader(assertion)) + decoder.Strict = true + if err := decoder.Decode(&doc); err != nil { + return []domain.VerifyCheck{{Name: "saml_assertion_shape", Result: "failed"}}, ErrVerificationFailed + } + checks := []domain.VerifyCheck{{Name: "saml_assertion_shape", Result: "passed"}} + notBefore, err := time.Parse(time.RFC3339, strings.TrimSpace(doc.Conditions.NotBefore)) + if err != nil { + return checksWithFailure(checks, "saml_assertion_time"), ErrVerificationFailed + } + notOnOrAfter, err := time.Parse(time.RFC3339, strings.TrimSpace(doc.Conditions.NotOnOrAfter)) + if err != nil { + return checksWithFailure(checks, "saml_assertion_time"), ErrVerificationFailed + } + signatureValue, err := base64.StdEncoding.DecodeString(strings.TrimSpace(doc.Signature.SignatureValue)) + if err != nil || strings.TrimSpace(doc.Signature.Algorithm) != "rsa-sha256" { + return checksWithFailure(checks, "saml_assertion_signature"), ErrVerificationFailed + } + payload := samlAssertionSignaturePayload(strings.TrimSpace(doc.Issuer), strings.TrimSpace(doc.Conditions.Audience.Audience), strings.TrimSpace(doc.Subject.NameID), notBefore.UTC().Format(time.RFC3339), notOnOrAfter.UTC().Format(time.RFC3339)) + if err := verifySAMLAssertionSignature(provider.SAMLSigningCertificates, []byte(payload), signatureValue); err != nil { + return checksWithFailure(checks, "saml_assertion_signature"), ErrVerificationFailed + } + checks = append(checks, domain.VerifyCheck{Name: "saml_assertion_signature", Result: "passed"}) + if strings.TrimSpace(doc.Issuer) != provider.Issuer { + return checksWithFailure(checks, "issuer"), ErrVerificationFailed + } + checks = append(checks, domain.VerifyCheck{Name: "issuer", Result: "passed"}) + if strings.TrimSpace(doc.Conditions.Audience.Audience) != provider.ClientID { + return checksWithFailure(checks, "audience"), ErrVerificationFailed + } + checks = append(checks, domain.VerifyCheck{Name: "audience", Result: "passed"}) + if strings.TrimSpace(doc.Subject.NameID) == "" || strings.TrimSpace(doc.Subject.NameID) != expectedSubject { + return checksWithFailure(checks, "subject"), ErrVerificationFailed + } + checks = append(checks, domain.VerifyCheck{Name: "subject", Result: "passed"}) + if notBefore.After(now.Add(time.Minute)) || !notOnOrAfter.After(now) { + return checksWithFailure(checks, "saml_assertion_time"), ErrVerificationFailed + } + checks = append(checks, domain.VerifyCheck{Name: "saml_assertion_time", Result: "passed"}) + return checks, nil +} + +func samlAssertionSignaturePayload(issuer, audience, subject, notBefore, notOnOrAfter string) string { + return strings.Join([]string{issuer, audience, subject, notBefore, notOnOrAfter}, "\n") +} + +func verifySAMLAssertionSignature(certs []string, payload, signature []byte) error { + sum := sha256.Sum256(payload) + for _, raw := range certs { + block, _ := pem.Decode([]byte(raw)) + if block == nil { + continue + } + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + continue + } + key, ok := cert.PublicKey.(*rsa.PublicKey) + if !ok { + continue + } + if rsa.VerifyPKCS1v15(key, crypto.SHA256, sum[:], signature) == nil { + return nil + } + } + return errors.New("no configured saml signing certificate verified assertion") +} + +func (s packageReportService) CreatePDFReportPackage(ctx context.Context, actor domain.Actor, in CreatePDFReportPackageInput) (domain.PDFReportPackage, error) { + l := s.ledger + if err := ctx.Err(); err != nil { + return domain.PDFReportPackage{}, err + } + if err := require(actor, ScopeReportRead); err != nil { + return domain.PDFReportPackage{}, err + } + reportType, title := strings.TrimSpace(in.ReportType), strings.TrimSpace(in.Title) + productID, releaseID := strings.TrimSpace(in.ProductID), strings.TrimSpace(in.ReleaseID) + if reportType == "" || title == "" || (productID == "" && releaseID == "") { + return domain.PDFReportPackage{}, ErrValidation + } + l.mu.Lock() + defer l.mu.Unlock() + if err := l.ensureScopeLocked(actor.TenantID, productID, "", releaseID); err != nil { + return domain.PDFReportPackage{}, err + } + if err := l.authorizeResourceLocked(actor, ScopeReportRead, resourceRefs{ProductID: productID, ReleaseID: releaseID}); err != nil { + return domain.PDFReportPackage{}, err + } + body := []byte("%PDF-1.4\n% Evydence reproducible report\n1 0 obj << /Type /Catalog >> endobj\n% " + title + "\n% compliance readiness evidence only\n%%EOF\n") + digest := hashBytes(body) + ref, err := l.storePayload(ctx, actor.TenantID, "pdf_report", "application/pdf", digest, body) + if err != nil { + return domain.PDFReportPackage{}, err + } + record := domain.PDFReportPackage{ID: newID("pdf"), TenantID: actor.TenantID, ReportType: reportType, ProductID: productID, ReleaseID: releaseID, Title: title, PayloadRef: ref, PayloadHash: digest, PayloadSize: int64(len(body)), Limitations: []string{"PDF output is reproducible report packaging and does not provide legal compliance or security certification."}, SchemaVersion: domain.PDFReportPackageVersion, CreatedAt: l.now()} + l.pdfReports[record.ID] = record + _, _ = l.appendChainLocked(actor.TenantID, "pdf_report.created", "pdf_report", record.ID, actorType(actor), actorID(actor), digest, "") + if err := l.persistLocked(ctx); err != nil { + return domain.PDFReportPackage{}, err + } + return record, nil +} + +func (s packageReportService) GenerateAnomalyReport(ctx context.Context, actor domain.Actor, in AnomalyReportInput) (domain.AnomalyReport, error) { + l := s.ledger + if err := ctx.Err(); err != nil { + return domain.AnomalyReport{}, err + } + if err := require(actor, ScopeReportRead); err != nil { + return domain.AnomalyReport{}, err + } + subjectType, subjectID := strings.TrimSpace(in.SubjectType), strings.TrimSpace(in.SubjectID) + if subjectType == "" || subjectID == "" { + return domain.AnomalyReport{}, ErrValidation + } + l.mu.Lock() + defer l.mu.Unlock() + refs, err := l.ensureFutureSubjectLocked(actor.TenantID, subjectType, subjectID) + if err != nil { + return domain.AnomalyReport{}, err + } + if err := l.authorizeResourceLocked(actor, ScopeReportRead, refs); err != nil { + return domain.AnomalyReport{}, err + } + signals := []domain.AnomalySignal{} + if subjectType == "release" { + if l.checkReleaseHasPassedBuildLocked(actor.TenantID, subjectID).Result != "passed" { + signals = append(signals, domain.AnomalySignal{Name: "missing_passed_build", Severity: "medium", Detail: "No passed build run is linked to this release."}) + } + if l.checkReleaseHasBuildAttestationLocked(actor.TenantID, subjectID).Result != "passed" { + signals = append(signals, domain.AnomalySignal{Name: "missing_matching_attestation", Severity: "medium", Detail: "No build attestation subject digest matches a release artifact digest."}) + } + if len(l.unhandledCriticalFindingsLocked(actor.TenantID, subjectID)) > 0 { + signals = append(signals, domain.AnomalySignal{Name: "unhandled_critical_finding", Severity: "high", Detail: "An open critical finding lacks a valid decision or approved exception."}) + } + } + result := "clear" + if len(signals) > 0 { + result = "attention_required" + } + report := domain.AnomalyReport{ID: newID("ano"), TenantID: actor.TenantID, SubjectType: subjectType, SubjectID: subjectID, Result: result, Signals: signals, Assumptions: []string{"Signals are deterministic checks over stored Evydence records."}, Limitations: []string{"This report identifies evidence anomalies only and does not infer malicious behavior or release security."}, SchemaVersion: domain.AnomalyReportVersion, CreatedAt: l.now()} + l.anomalyReports[report.ID] = report + _, _ = l.appendChainLocked(actor.TenantID, "anomaly_report.created", "anomaly_report", report.ID, actorType(actor), actorID(actor), "", "") + if err := l.persistLocked(ctx); err != nil { + return domain.AnomalyReport{}, err + } + return report, nil +} + +func (l *Ledger) CreateSigningOperation(ctx context.Context, actor domain.Actor, in CreateSigningOperationInput) (domain.SigningOperation, error) { + if err := ctx.Err(); err != nil { + return domain.SigningOperation{}, err + } + if err := require(actor, ScopeKeysAdmin); err != nil { + return domain.SigningOperation{}, err + } + providerID, subjectType, subjectID := strings.TrimSpace(in.ProviderID), strings.TrimSpace(in.SubjectType), strings.TrimSpace(in.SubjectID) + signatureValue := strings.TrimSpace(in.ExternalSignature) + payloadHash := strings.TrimSpace(in.PayloadHash) + if providerID == "" || subjectType == "" || subjectID == "" || !validDigest(payloadHash) || len(signatureValue) > 32768 { + return domain.SigningOperation{}, ErrValidation + } + l.mu.Lock() + provider, ok := l.signingProviders[providerID] + if !ok || provider.TenantID != actor.TenantID { + l.mu.Unlock() + return domain.SigningOperation{}, ErrNotFound + } + if _, err := l.ensureFutureSubjectLocked(actor.TenantID, subjectType, subjectID); err != nil { + l.mu.Unlock() + return domain.SigningOperation{}, err + } + providerActive := provider.Status == "active" + l.mu.Unlock() + + checks := []domain.VerifyCheck{ + {Name: "provider_active", Result: "passed"}, + {Name: "payload_hash_valid", Result: "passed"}, + } + if !providerActive { + checks[0].Result = "failed" + } + signatureAlgorithm := "external-" + provider.Type + if signatureValue == "" { + if l.signer == nil { + return domain.SigningOperation{}, ErrValidation + } + if !providerActive { + return domain.SigningOperation{}, ErrVerificationFailed + } + signed, err := l.signer.Sign(ctx, SigningRequest{ + TenantID: actor.TenantID, + ProviderID: provider.ID, + ProviderType: provider.Type, + KeyRef: provider.KeyRef, + SubjectType: subjectType, + SubjectID: subjectID, + PayloadHash: payloadHash, + }) + if err != nil { + return domain.SigningOperation{}, err + } + signatureValue = strings.TrimSpace(signed.Signature) + if signatureValue == "" || len(signatureValue) > 32768 { + return domain.SigningOperation{}, ErrValidation + } + if strings.TrimSpace(signed.Algorithm) != "" { + signatureAlgorithm = strings.TrimSpace(signed.Algorithm) + } + checks = append(checks, signed.Checks...) + checks = append(checks, domain.VerifyCheck{Name: "signing_executor_invoked", Result: "passed", Detail: strings.TrimSpace(signed.KeyID)}) + } else { + checks = append(checks, domain.VerifyCheck{Name: "external_signature_present", Result: "passed", Detail: "Signature value was supplied by the configured provider path; local cryptographic trust verification is not implied."}) + } + result := "passed" + for _, check := range checks { + if check.Result == "failed" { + result = "failed" + break + } + } + l.mu.Lock() + defer l.mu.Unlock() + provider, ok = l.signingProviders[providerID] + if !ok || provider.TenantID != actor.TenantID { + return domain.SigningOperation{}, ErrNotFound + } + if _, err := l.ensureFutureSubjectLocked(actor.TenantID, subjectType, subjectID); err != nil { + return domain.SigningOperation{}, err + } + signature := domain.Signature{ID: newID("sig"), TenantID: actor.TenantID, SubjectType: subjectType, SubjectID: subjectID, KeyID: provider.ID, Algorithm: signatureAlgorithm, Value: signatureValue, CreatedAt: l.now()} + l.signatures[signature.ID] = signature + op := domain.SigningOperation{ID: newID("sop"), TenantID: actor.TenantID, ProviderID: provider.ID, SubjectType: subjectType, SubjectID: subjectID, PayloadHash: payloadHash, SignatureRef: signature.ID, Result: result, Checks: checks, SchemaVersion: domain.SigningOperationVersion, CreatedAt: l.now()} + l.signingOperations[op.ID] = op + _, _ = l.appendChainLocked(actor.TenantID, "signing_operation.created", "signing_operation", op.ID, actorType(actor), actorID(actor), op.PayloadHash, signature.ID) + if err := l.persistLocked(ctx); err != nil { + return domain.SigningOperation{}, err + } + if result != "passed" { + return op, ErrVerificationFailed + } + return op, nil +} + +func (l *Ledger) VerifyProviderIdentity(ctx context.Context, actor domain.Actor, in VerifyProviderIdentityInput) (domain.ProviderVerification, error) { + if err := ctx.Err(); err != nil { + return domain.ProviderVerification{}, err + } + if err := require(actor, ScopeIdentityAdmin); err != nil { + return domain.ProviderVerification{}, err + } + providerType, providerID, subject := strings.TrimSpace(in.ProviderType), strings.TrimSpace(in.ProviderID), strings.TrimSpace(in.Subject) + idToken := strings.TrimSpace(in.IDToken) + samlAssertion := strings.TrimSpace(in.SAMLAssertion) + accessToken := strings.TrimSpace(in.AccessToken) + if providerType == "" || providerID == "" || subject == "" { + return domain.ProviderVerification{}, ErrValidation + } + if len(accessToken) > 16*1024 { + return domain.ProviderVerification{}, ErrValidation + } + if (providerType == "oidc" && samlAssertion != "") || (providerType == "saml" && (idToken != "" || accessToken != "")) { + return domain.ProviderVerification{}, ErrValidation + } + l.mu.Lock() + var provider domain.SSOProvider + var providerFound bool + var verifiedLinkFound bool + switch providerType { + case "oidc", "saml": + provider, providerFound = l.ssoProviders[providerID] + if !providerFound || provider.TenantID != actor.TenantID || provider.Type != providerType { + l.mu.Unlock() + return domain.ProviderVerification{}, ErrNotFound + } + for _, link := range l.identityLinks { + if link.TenantID == actor.TenantID && link.ProviderID == provider.ID && link.Subject == subject && link.Verified { + verifiedLinkFound = true + break + } + } + default: + l.mu.Unlock() + return domain.ProviderVerification{}, ErrValidation + } + l.mu.Unlock() + + checks := []domain.VerifyCheck{} + result := "passed" + now := l.now() + if idToken != "" { + tokenChecks, err := verifyOIDCIDToken(provider, subject, idToken, now) + checks = append(checks, tokenChecks...) + if err != nil { + result = "failed" + } + } + if samlAssertion != "" { + assertionChecks, err := verifySAMLAssertion(provider, subject, samlAssertion, now) + checks = append(checks, assertionChecks...) + if err != nil { + result = "failed" + } + } + limitations := []string{"Verification uses stored provider metadata and configured local token/assertion trust roots; no live provider API or discovery call is made."} + if idToken == "" && samlAssertion == "" { + limitations = []string{"Verification is limited to stored provider/link metadata because no provider token was supplied."} + } + if accessToken != "" { + if l.providerAPI == nil { + result = "failed" + checks = append(checks, domain.VerifyCheck{Name: "live_provider_api_configured", Result: "failed"}) + limitations = []string{"A provider access token was supplied, but no live provider API validator is configured."} + } else { + validation, err := l.providerAPI.ValidateProviderIdentity(ctx, ProviderIdentityValidationRequest{ + TenantID: actor.TenantID, + ProviderID: provider.ID, + ProviderType: provider.Type, + Issuer: provider.Issuer, + Subject: subject, + GroupsClaim: provider.GroupsClaim, + AccessToken: accessToken, + }) + checks = append(checks, validation.Checks...) + if len(validation.Groups) > 0 && len(resourceGrantsForProviderGroups(provider, validation.Groups)) > 0 { + checks = append(checks, domain.VerifyCheck{Name: "mapped_provider_api_groups", Result: "passed", Detail: fmt.Sprintf("%d provider API group role mapping(s) can be applied to sessions", len(resourceGrantsForProviderGroups(provider, validation.Groups)))}) + } + if len(validation.Limitations) > 0 { + limitations = validation.Limitations + } else { + limitations = []string{"Live provider API validation used a supplied OIDC access token; no access token is stored in Evydence records."} + } + if err != nil { + result = "failed" + } + } + } + if verifiedLinkFound { + checks = append(checks, domain.VerifyCheck{Name: "verified_identity_link", Result: "passed"}) + } else { + result = "failed" + checks = append(checks, domain.VerifyCheck{Name: "verified_identity_link", Result: "failed"}) + } + + record := domain.ProviderVerification{ID: newID("pvr"), TenantID: actor.TenantID, ProviderType: providerType, ProviderID: providerID, Subject: subject, Result: result, Checks: checks, Limitations: limitations, SchemaVersion: domain.ProviderVerificationVersion, CreatedAt: l.now()} + l.mu.Lock() + defer l.mu.Unlock() + l.providerVerifications[record.ID] = record + _, _ = l.appendChainLocked(actor.TenantID, "provider_identity.verified", "provider_identity", record.ID, actorType(actor), actorID(actor), "", "") + if err := l.persistLocked(ctx); err != nil { + return domain.ProviderVerification{}, err + } + if result != "passed" { + return record, ErrVerificationFailed + } + return record, nil +} + +func (l *Ledger) ensureFutureSubjectLocked(tenantID, subjectType, subjectID string) (resourceRefs, error) { + switch subjectType { + case "tenant": + if subjectID != tenantID { + return resourceRefs{}, ErrNotFound + } + return resourceRefs{}, nil + case "product": + product, ok := l.products[subjectID] + if !ok || product.TenantID != tenantID { + return resourceRefs{}, ErrNotFound + } + return resourceRefs{ProductID: product.ID}, nil + case "release": + release, ok := l.releases[subjectID] + if !ok || release.TenantID != tenantID { + return resourceRefs{}, ErrNotFound + } + return resourceRefs{ProductID: release.ProductID, ReleaseID: release.ID}, nil + case "evidence": + item, ok := l.evidence[subjectID] + if !ok || item.TenantID != tenantID { + return resourceRefs{}, ErrNotFound + } + return refsForEvidence(item), nil + case "build": + build, ok := l.buildRuns[subjectID] + if !ok || build.TenantID != tenantID { + return resourceRefs{}, ErrNotFound + } + return resourceRefs{ProjectID: build.ProjectID, ReleaseID: build.ReleaseID}, nil + case "customer_package": + pkg, ok := l.customerPackages[subjectID] + if !ok || pkg.TenantID != tenantID { + return resourceRefs{}, ErrNotFound + } + return resourceRefs{ProductID: pkg.ProductID, ReleaseID: pkg.ReleaseID, CustomerPackageID: pkg.ID}, nil + default: + return resourceRefs{}, ErrValidation + } +} + +func (l *Ledger) evidenceIDsForQuestionLocked(tenantID string, question domain.QuestionnaireQuestion, productID, releaseID string) []string { + if question.ControlID != "" { + ids := []string{} + for _, link := range l.controlLinks { + if link.TenantID == tenantID && link.ControlID == question.ControlID && scopeMatches(link.ProductID, productID) && scopeMatches(link.ReleaseID, releaseID) { + if link.SubjectType == "evidence" { + ids = append(ids, link.SubjectID) + } + } + } + return sortedStrings(ids) + } + return l.evidenceIDsForRefsLocked(tenantID, resourceRefs{ProductID: strings.TrimSpace(productID), ReleaseID: strings.TrimSpace(releaseID)}, strings.TrimSpace(question.EvidenceType)) +} + +func (l *Ledger) questionnaireResponseForQuestionLocked(tenantID string, question domain.QuestionnaireQuestion, productID, releaseID string) domain.QuestionnaireResponse { + if entry, ok := l.questionnaireAnswerLibraryMatchLocked(tenantID, question, productID, releaseID); ok { + return domain.QuestionnaireResponse{ + QuestionID: question.ID, + Answer: entry.Answer, + EvidenceIDs: append([]string(nil), entry.EvidenceIDs...), + Limitations: append([]string(nil), entry.Limitations...), + } + } + ids := l.evidenceIDsForQuestionLocked(tenantID, question, productID, releaseID) + answer := "No matching evidence is recorded for this question." + if len(ids) > 0 { + answer = "Evidence is available for review in the linked evidence records." + } + return domain.QuestionnaireResponse{QuestionID: question.ID, Answer: answer, EvidenceIDs: ids, Limitations: []string{"Questionnaire responses summarize recorded evidence and require human review."}} +} + +func (l *Ledger) questionnaireAnswerLibraryMatchLocked(tenantID string, question domain.QuestionnaireQuestion, productID, releaseID string) (domain.QuestionnaireAnswerLibraryEntry, bool) { + productID, releaseID = strings.TrimSpace(productID), strings.TrimSpace(releaseID) + candidates := []domain.QuestionnaireAnswerLibraryEntry{} + for _, entry := range l.answerLibrary { + if entry.TenantID != tenantID || !questionnaireAnswerMatchesQuestion(entry, question) { + continue + } + if entry.ProductID != "" && entry.ProductID != productID { + continue + } + if entry.ReleaseID != "" && entry.ReleaseID != releaseID { + continue + } + candidates = append(candidates, entry) + } + if len(candidates) == 0 { + return domain.QuestionnaireAnswerLibraryEntry{}, false + } + sort.Slice(candidates, func(i, j int) bool { + left, right := questionnaireAnswerSpecificity(candidates[i], question), questionnaireAnswerSpecificity(candidates[j], question) + if left != right { + return left > right + } + if !candidates[i].CreatedAt.Equal(candidates[j].CreatedAt) { + return candidates[i].CreatedAt.After(candidates[j].CreatedAt) + } + return candidates[i].ID < candidates[j].ID + }) + return candidates[0], true +} + +func questionnaireAnswerMatchesQuestion(entry domain.QuestionnaireAnswerLibraryEntry, question domain.QuestionnaireQuestion) bool { + if entry.QuestionID != "" && entry.QuestionID == question.ID { + return true + } + if entry.ControlID != "" && entry.ControlID == question.ControlID { + return true + } + return entry.EvidenceType != "" && entry.EvidenceType == question.EvidenceType +} + +func questionnaireAnswerSpecificity(entry domain.QuestionnaireAnswerLibraryEntry, question domain.QuestionnaireQuestion) int { + score := 0 + if entry.QuestionID != "" && entry.QuestionID == question.ID { + score += 8 + } + if entry.ControlID != "" && entry.ControlID == question.ControlID { + score += 4 + } + if entry.EvidenceType != "" && entry.EvidenceType == question.EvidenceType { + score += 2 + } + if entry.ReleaseID != "" { + score++ + } + if entry.ProductID != "" { + score++ + } + return score +} + +func (l *Ledger) evidenceIDsForRefsLocked(tenantID string, refs resourceRefs, evidenceType string) []string { + ids := []string{} + for _, item := range l.evidence { + if item.TenantID != tenantID { + continue + } + if evidenceType != "" && item.Type != evidenceType { + continue + } + if evidenceMatchesRefs(item, refs) { + ids = append(ids, item.ID) + } + } + return sortedStrings(ids) +} + +func evidenceMatchesRefs(item domain.EvidenceItem, refs resourceRefs) bool { + if refs.ProductID != "" && item.ProductID != refs.ProductID { + return false + } + if refs.ProjectID != "" && item.ProjectID != refs.ProjectID { + return false + } + if refs.ReleaseID != "" && item.ReleaseID != refs.ReleaseID { + return false + } + return true +} + +func sortMarketplaceCollectors(items []domain.MarketplaceCollector) { + for i := 1; i < len(items); i++ { + for j := i; j > 0 && items[j-1].ID > items[j].ID; j-- { + items[j-1], items[j] = items[j], items[j-1] + } + } +} diff --git a/internal/app/future_extensions_test.go b/internal/app/future_extensions_test.go new file mode 100644 index 0000000..9025f3d --- /dev/null +++ b/internal/app/future_extensions_test.go @@ -0,0 +1,409 @@ +package app + +import ( + "context" + "encoding/hex" + "encoding/json" + "errors" + "strings" + "testing" + + "github.com/aatuh/evydence/internal/domain" +) + +func TestFutureExtensionsAreEvidenceBackedAndTenantScoped(t *testing.T) { + ledger := NewLedger(Config{APIKeyPepper: "test-pepper", Now: fixedNow}) + ctx := context.Background() + actor, release, artifact := setupReleaseRiskFixture(t, ledger) + evidence, err := ledger.CreateEvidence(ctx, actor, CreateEvidenceInput{ + ProductID: release.ProductID, + ReleaseID: release.ID, + Type: "security_review", + Title: "Release review", + PayloadHash: sampleDigest("review"), + }) + if err != nil { + t.Fatalf("evidence: %v", err) + } + template, err := ledger.CreateQuestionnaireTemplate(ctx, actor, CreateQuestionnaireTemplateInput{Name: "customer", Version: "1", Questions: []domain.QuestionnaireQuestion{{ID: "q1", Prompt: "Is review evidence available?", EvidenceType: "security_review"}}}) + if err != nil { + t.Fatalf("template: %v", err) + } + + summary, err := ledger.CreateEvidenceSummary(ctx, actor, CreateEvidenceSummaryInput{SubjectType: "release", SubjectID: release.ID, EvidenceIDs: []string{evidence.ID}}) + if err != nil { + t.Fatalf("summary: %v", err) + } + if !strings.Contains(summary.Summary, "Release review") || len(summary.Citations) != 1 || summary.Citations[0].EvidenceID != evidence.ID { + t.Fatalf("summary is not evidence-cited: %#v", summary) + } + if strings.Contains(strings.ToLower(summary.Summary), "compliant") { + t.Fatalf("summary used compliance conclusion language: %s", summary.Summary) + } + + draft, err := ledger.CreateQuestionnaireDraft(ctx, actor, CreateQuestionnaireDraftInput{TemplateID: template.ID, ProductID: release.ProductID, ReleaseID: release.ID}) + if err != nil { + t.Fatalf("questionnaire draft: %v", err) + } + if len(draft.Responses) != 1 || len(draft.Responses[0].EvidenceIDs) != 1 || draft.Responses[0].Answer == "" { + t.Fatalf("draft responses = %#v", draft.Responses) + } + + graph, err := ledger.CreateGraphSnapshot(ctx, actor, CreateGraphSnapshotInput{ProductID: release.ProductID, ReleaseID: release.ID}) + if err != nil { + t.Fatalf("graph: %v", err) + } + if len(graph.Nodes) == 0 || len(graph.Edges) == 0 { + t.Fatalf("graph should expose release adjacency: %#v", graph) + } + + pdf, err := ledger.CreatePDFReportPackage(ctx, actor, CreatePDFReportPackageInput{ReportType: "release_readiness", ProductID: release.ProductID, ReleaseID: release.ID, Title: "Readiness"}) + if err != nil { + t.Fatalf("pdf: %v", err) + } + if !strings.HasPrefix(pdf.PayloadHash, "sha256:") || pdf.PayloadSize == 0 || len(pdf.Limitations) == 0 { + t.Fatalf("pdf package = %#v", pdf) + } + + anomaly, err := ledger.GenerateAnomalyReport(ctx, actor, AnomalyReportInput{SubjectType: "release", SubjectID: release.ID}) + if err != nil { + t.Fatalf("anomaly report: %v", err) + } + if anomaly.Result == "" || len(anomaly.Limitations) == 0 { + t.Fatalf("anomaly report = %#v", anomaly) + } + + _, _, otherSecret, err := ledger.BootstrapTenant(ctx, "Other", "admin", []string{"*"}) + if err != nil { + t.Fatalf("bootstrap other: %v", err) + } + other, err := ledger.Authenticate(ctx, otherSecret) + if err != nil { + t.Fatalf("auth other: %v", err) + } + if _, err := ledger.CreateEvidenceSummary(ctx, other, CreateEvidenceSummaryInput{SubjectType: "release", SubjectID: release.ID, EvidenceIDs: []string{evidence.ID}}); !errors.Is(err, ErrNotFound) { + t.Fatalf("cross-tenant summary err=%v, want not found", err) + } + if _, err := ledger.CreateQuestionnaireDraft(ctx, other, CreateQuestionnaireDraftInput{TemplateID: template.ID, ProductID: release.ProductID, ReleaseID: release.ID}); !errors.Is(err, ErrNotFound) { + t.Fatalf("cross-tenant draft err=%v, want not found", err) + } + if _, err := ledger.CreatePDFReportPackage(ctx, other, CreatePDFReportPackageInput{ReportType: "release_readiness", ProductID: release.ProductID, ReleaseID: release.ID, Title: "Bad"}); !errors.Is(err, ErrNotFound) { + t.Fatalf("cross-tenant pdf err=%v, want not found", err) + } + _ = artifact +} + +func TestFetchAndVerifyPublicTransparencyLogEntryUsesFetcher(t *testing.T) { + fetcher := &fakeTransparencyProofFetcher{} + ledger := NewLedger(Config{APIKeyPepper: "test-pepper", Now: fixedNow, Transparency: fetcher}) + ctx := context.Background() + actor, _, _ := setupReleaseRiskFixture(t, ledger) + batch, err := ledger.CreateMerkleBatch(ctx, actor, CreateMerkleBatchInput{}) + if err != nil { + t.Fatalf("merkle batch: %v", err) + } + log, err := ledger.CreatePublicTransparencyLog(ctx, actor, CreatePublicTransparencyLogInput{Name: "public-test", Endpoint: "https://transparency.example.test", PublicKey: "pub"}) + if err != nil { + t.Fatalf("public log: %v", err) + } + checkpoint, err := ledger.CreateTransparencyCheckpoint(ctx, actor, CreateTransparencyCheckpointInput{BatchID: batch.ID, Provider: "internal", ExternalID: "ts-1"}) + if err != nil { + t.Fatalf("checkpoint: %v", err) + } + entry, err := ledger.PublishPublicTransparencyLogEntry(ctx, actor, PublishPublicTransparencyLogEntryInput{LogID: log.ID, CheckpointID: checkpoint.ID, ExternalID: "entry-1"}) + if err != nil { + t.Fatalf("entry: %v", err) + } + fetcher.result = TransparencyProofResult{ExternalID: "entry-1", RootHash: entry.EntryHash, LeafIndex: 0, TreeSize: 1, InclusionProof: []string{}} + verified, err := ledger.FetchAndVerifyPublicTransparencyLogEntry(ctx, actor, entry.ID) + if err != nil { + t.Fatalf("fetch verify: %v", err) + } + if verified.State != "inclusion_verified" || !hasVerifyCheck(verified.VerificationChecks, "public_log_proof_source", "passed") { + t.Fatalf("verified = %#v", verified) + } + if fetcher.request.Endpoint != log.Endpoint || fetcher.request.ExternalID != entry.ExternalID || fetcher.request.EntryHash != entry.EntryHash { + t.Fatalf("fetch request = %#v", fetcher.request) + } + _, _, _, other := bootstrapEnterpriseTestTenant(t, ledger) + if _, err := ledger.FetchAndVerifyPublicTransparencyLogEntry(ctx, other, entry.ID); !errors.Is(err, ErrNotFound) { + t.Fatalf("cross tenant fetch err=%v, want not found", err) + } +} + +func TestFutureOperationalExtensionsAndPartialTrustClosures(t *testing.T) { + ledger := NewLedger(Config{APIKeyPepper: "test-pepper", Now: fixedNow}) + ctx := context.Background() + _, _, secret, err := ledger.BootstrapTenant(ctx, "Tenant", "admin", []string{"*", ScopeInstanceAdmin}) + if err != nil { + t.Fatalf("bootstrap: %v", err) + } + actor, err := ledger.Authenticate(ctx, secret) + if err != nil { + t.Fatalf("auth: %v", err) + } + product, err := ledger.CreateProduct(ctx, actor, "API", "api") + if err != nil { + t.Fatalf("product: %v", err) + } + release, err := ledger.CreateRelease(ctx, actor, product.ID, "1") + if err != nil { + t.Fatalf("release: %v", err) + } + if _, err := ledger.CreateSaaSEditionProfile(ctx, actor, CreateSaaSEditionProfileInput{Name: "hosted-eu", Region: "eu", AdminTenantID: actor.TenantID, IsolationModel: "shared-control-plane"}); err != nil { + t.Fatalf("saas profile: %v", err) + } + + batch, err := ledger.CreateMerkleBatch(ctx, actor, CreateMerkleBatchInput{}) + if err != nil { + t.Fatalf("merkle batch: %v", err) + } + log, err := ledger.CreatePublicTransparencyLog(ctx, actor, CreatePublicTransparencyLogInput{Name: "public-test", Endpoint: "https://transparency.example.test", PublicKey: "pub"}) + if err != nil { + t.Fatalf("public log: %v", err) + } + checkpoint, err := ledger.CreateTransparencyCheckpoint(ctx, actor, CreateTransparencyCheckpointInput{BatchID: batch.ID, Provider: "internal", ExternalID: "ts-1"}) + if err != nil { + t.Fatalf("checkpoint: %v", err) + } + entry, err := ledger.PublishPublicTransparencyLogEntry(ctx, actor, PublishPublicTransparencyLogEntryInput{LogID: log.ID, CheckpointID: checkpoint.ID, ExternalID: "entry-1"}) + if err != nil { + t.Fatalf("public log entry: %v", err) + } + if entry.MerkleBatchID != batch.ID || entry.EntryHash == "" { + t.Fatalf("public log entry = %#v", entry) + } + leaf, err := decodeSHA256Digest(entry.EntryHash) + if err != nil { + t.Fatalf("decode entry hash: %v", err) + } + siblingHash := sampleDigest("public-log-sibling") + sibling, err := decodeSHA256Digest(siblingHash) + if err != nil { + t.Fatalf("decode sibling hash: %v", err) + } + rootHash := "sha256:" + hex.EncodeToString(transparencyParentHash(leaf, sibling)) + verifiedEntry, err := ledger.VerifyPublicTransparencyLogEntry(ctx, actor, entry.ID, VerifyPublicTransparencyLogEntryInput{ + RootHash: rootHash, + LeafIndex: 0, + TreeSize: 2, + InclusionProof: []string{siblingHash}, + }) + if err != nil { + t.Fatalf("verify public log entry: %v", err) + } + if verifiedEntry.State != "inclusion_verified" || verifiedEntry.InclusionProofHash == "" || len(verifiedEntry.VerificationChecks) != 2 { + t.Fatalf("verified public log entry = %#v", verifiedEntry) + } + + provider, err := ledger.CreateSigningProvider(ctx, actor, CreateSigningProviderInput{Name: "kms", Type: "aws_kms", KeyRef: "arn:aws:kms:example", Encrypted: true}) + if err != nil { + t.Fatalf("signing provider: %v", err) + } + op, err := ledger.CreateSigningOperation(ctx, actor, CreateSigningOperationInput{ProviderID: provider.ID, SubjectType: "release", SubjectID: release.ID, PayloadHash: sampleDigest("payload"), ExternalSignature: "sig"}) + if err != nil { + t.Fatalf("signing operation: %v", err) + } + if op.Result != "passed" || op.SignatureRef == "" { + t.Fatalf("signing operation = %#v", op) + } + + oidcProvider, err := ledger.CreateSSOProvider(ctx, actor, CreateSSOProviderInput{Name: "OIDC", Type: "oidc", Issuer: "https://idp.example.test", ClientID: "client"}) + if err != nil { + t.Fatalf("sso provider: %v", err) + } + org, err := ledger.CreateOrganization(ctx, actor, CreateOrganizationInput{Name: "Example", Slug: "example"}) + if err != nil { + t.Fatalf("org: %v", err) + } + user, err := ledger.CreateUser(ctx, actor, CreateUserInput{OrganizationID: org.ID, Email: "user@example.test", DisplayName: "User"}) + if err != nil { + t.Fatalf("user: %v", err) + } + if _, err := ledger.LinkSSOIdentity(ctx, actor, LinkSSOIdentityInput{UserID: user.ID, ProviderID: oidcProvider.ID, Subject: "sub-1", Email: user.Email, Verified: true}); err != nil { + t.Fatalf("identity link: %v", err) + } + verification, err := ledger.VerifyProviderIdentity(ctx, actor, VerifyProviderIdentityInput{ProviderType: "oidc", ProviderID: oidcProvider.ID, Subject: "sub-1"}) + if err != nil { + t.Fatalf("provider verification: %v", err) + } + if verification.Result != "passed" { + t.Fatalf("provider verification = %#v", verification) + } + + marketplace, err := ledger.CreateMarketplaceCollector(ctx, actor, CreateMarketplaceCollectorInput{Name: "scanner", Provider: "scannerco", Version: "1.0.0", Publisher: "scannerco", ManifestHash: sampleDigest("collector")}) + if err != nil { + t.Fatalf("marketplace collector: %v", err) + } + listed, err := ledger.ListMarketplaceCollectors(ctx, actor) + if err != nil { + t.Fatalf("list marketplace: %v", err) + } + if len(listed) != 1 || listed[0].ID != marketplace.ID { + t.Fatalf("marketplace list = %#v", listed) + } + health, err := ledger.MarketplaceCollectorHealth(ctx, actor, marketplace.ID) + if err != nil { + t.Fatalf("marketplace health: %v", err) + } + if health.SupplyChainStatus != "incomplete" || len(health.Checks) == 0 { + t.Fatalf("marketplace health = %#v", health) + } + + policy, err := ledger.CreateObjectRetentionPolicy(ctx, actor, CreateObjectRetentionPolicyInput{Name: "objects", ObjectPrefix: "tenants/" + actor.TenantID + "/", Mode: "compliance", RetentionDays: 90}) + if err != nil { + t.Fatalf("retention policy: %v", err) + } + verified, err := ledger.VerifyObjectRetentionPolicy(ctx, actor, policy.ID) + if err != nil { + t.Fatalf("verify retention: %v", err) + } + if verified.Status != "verified" || verified.VerificationHash == "" { + t.Fatalf("verified retention = %#v", verified) + } + + _, _, otherSecret, err := ledger.BootstrapTenant(ctx, "Other", "admin", []string{"*"}) + if err != nil { + t.Fatalf("bootstrap other: %v", err) + } + other, err := ledger.Authenticate(ctx, otherSecret) + if err != nil { + t.Fatalf("auth other: %v", err) + } + if _, err := ledger.PublishPublicTransparencyLogEntry(ctx, other, PublishPublicTransparencyLogEntryInput{LogID: log.ID, CheckpointID: checkpoint.ID, ExternalID: "bad"}); !errors.Is(err, ErrNotFound) { + t.Fatalf("cross tenant transparency err=%v, want not found", err) + } + if _, err := ledger.VerifyPublicTransparencyLogEntry(ctx, other, entry.ID, VerifyPublicTransparencyLogEntryInput{RootHash: rootHash, LeafIndex: 0, TreeSize: 2, InclusionProof: []string{siblingHash}}); !errors.Is(err, ErrNotFound) { + t.Fatalf("cross tenant transparency verification err=%v, want not found", err) + } + if _, err := ledger.CreateSigningOperation(ctx, other, CreateSigningOperationInput{ProviderID: provider.ID, SubjectType: "release", SubjectID: release.ID, PayloadHash: sampleDigest("payload"), ExternalSignature: "sig"}); !errors.Is(err, ErrNotFound) { + t.Fatalf("cross tenant signing operation err=%v, want not found", err) + } + if _, err := ledger.CreateSaaSEditionProfile(ctx, other, CreateSaaSEditionProfileInput{Name: "bad", Region: "eu", AdminTenantID: other.TenantID, IsolationModel: "shared-control-plane"}); !errors.Is(err, ErrForbidden) { + t.Fatalf("non-instance saas profile err=%v, want forbidden", err) + } +} + +type fakeSigningExecutor struct { + request SigningRequest +} + +func (f *fakeSigningExecutor) Sign(_ context.Context, request SigningRequest) (SigningResult, error) { + f.request = request + return SigningResult{ + Signature: "sig_from_executor", + KeyID: "kms-key-1", + Algorithm: "external-aws_kms", + Checks: []domain.VerifyCheck{{Name: "fake_executor", Result: "passed"}}, + }, nil +} + +type fakeProviderIdentityValidator struct { + request ProviderIdentityValidationRequest + result ProviderIdentityValidationResult + err error +} + +func (f *fakeProviderIdentityValidator) ValidateProviderIdentity(_ context.Context, request ProviderIdentityValidationRequest) (ProviderIdentityValidationResult, error) { + f.request = request + if f.err != nil { + return f.result, f.err + } + return f.result, nil +} + +type fakeTransparencyProofFetcher struct { + request TransparencyProofRequest + result TransparencyProofResult + err error +} + +func (f *fakeTransparencyProofFetcher) FetchTransparencyProof(_ context.Context, request TransparencyProofRequest) (TransparencyProofResult, error) { + f.request = request + if f.err != nil { + return TransparencyProofResult{}, f.err + } + return f.result, nil +} + +func TestSigningOperationCanExecuteConfiguredSignerWithoutPrivateKey(t *testing.T) { + signer := &fakeSigningExecutor{} + ledger := NewLedger(Config{APIKeyPepper: "test-pepper", Now: fixedNow, Signer: signer}) + ctx := context.Background() + actor, release, _ := setupReleaseRiskFixture(t, ledger) + provider, err := ledger.CreateSigningProvider(ctx, actor, CreateSigningProviderInput{Name: "kms", Type: "aws_kms", KeyRef: "arn:aws:kms:example", Encrypted: true}) + if err != nil { + t.Fatalf("provider: %v", err) + } + op, err := ledger.CreateSigningOperation(ctx, actor, CreateSigningOperationInput{ProviderID: provider.ID, SubjectType: "release", SubjectID: release.ID, PayloadHash: sampleDigest("payload")}) + if err != nil { + t.Fatalf("signing operation: %v", err) + } + if op.Result != "passed" || op.SignatureRef == "" { + t.Fatalf("operation = %#v", op) + } + if signer.request.ProviderID != provider.ID || signer.request.KeyRef != provider.KeyRef || signer.request.PayloadHash != sampleDigest("payload") { + t.Fatalf("executor request = %#v", signer.request) + } + if !hasVerifyCheck(op.Checks, "signing_executor_invoked", "passed") || !hasVerifyCheck(op.Checks, "fake_executor", "passed") { + t.Fatalf("operation checks = %#v", op.Checks) + } +} + +func TestProviderVerificationCanUseLiveOIDCUserInfoWithoutPersistingToken(t *testing.T) { + validator := &fakeProviderIdentityValidator{result: ProviderIdentityValidationResult{ + Checks: []domain.VerifyCheck{{Name: "oidc_userinfo_subject", Result: "passed"}}, + Groups: []string{"security"}, + Limitations: []string{"live provider API validation was used"}, + }} + ledger := NewLedger(Config{APIKeyPepper: "test-pepper", Now: fixedNow, ProviderAPI: validator}) + ctx := context.Background() + actor, _, _ := setupReleaseRiskFixture(t, ledger) + provider, err := ledger.CreateSSOProvider(ctx, actor, CreateSSOProviderInput{Name: "OIDC", Type: "oidc", Issuer: "https://idp.example.test", ClientID: "client", GroupsClaim: "groups", RoleMapping: map[string]string{"security": "security_engineer"}}) + if err != nil { + t.Fatalf("provider: %v", err) + } + user, err := ledger.CreateUser(ctx, actor, CreateUserInput{Email: "user@example.test", DisplayName: "User"}) + if err != nil { + t.Fatalf("user: %v", err) + } + if _, err := ledger.LinkSSOIdentity(ctx, actor, LinkSSOIdentityInput{UserID: user.ID, ProviderID: provider.ID, Subject: "sub-1", Email: user.Email, Verified: true}); err != nil { + t.Fatalf("identity link: %v", err) + } + verification, err := ledger.VerifyProviderIdentity(ctx, actor, VerifyProviderIdentityInput{ProviderType: "oidc", ProviderID: provider.ID, Subject: "sub-1", AccessToken: "access-token-secret"}) + if err != nil { + t.Fatalf("provider verification: %v", err) + } + if validator.request.AccessToken != "access-token-secret" || validator.request.Subject != "sub-1" { + t.Fatalf("validator request = %#v", validator.request) + } + if verification.Result != "passed" || len(verification.Checks) < 3 { + t.Fatalf("verification = %#v", verification) + } + for _, check := range verification.Checks { + if strings.Contains(check.Detail, "access-token-secret") { + t.Fatalf("check leaked access token: %#v", check) + } + } + ledger.mu.Lock() + state := ledger.snapshotLocked() + ledger.mu.Unlock() + stored := state.ProviderVerifications[verification.ID] + encoded, _ := json.Marshal(stored) + if strings.Contains(string(encoded), "access-token-secret") { + t.Fatalf("stored provider verification leaked access token: %s", encoded) + } +} + +func TestSigningOperationRequiresSignatureWhenNoExecutorConfigured(t *testing.T) { + ledger := NewLedger(Config{APIKeyPepper: "test-pepper", Now: fixedNow}) + ctx := context.Background() + actor, release, _ := setupReleaseRiskFixture(t, ledger) + provider, err := ledger.CreateSigningProvider(ctx, actor, CreateSigningProviderInput{Name: "kms", Type: "aws_kms", KeyRef: "arn:aws:kms:example", Encrypted: true}) + if err != nil { + t.Fatalf("provider: %v", err) + } + if _, err := ledger.CreateSigningOperation(ctx, actor, CreateSigningOperationInput{ProviderID: provider.ID, SubjectType: "release", SubjectID: release.ID, PayloadHash: sampleDigest("payload")}); !errors.Is(err, ErrValidation) { + t.Fatalf("err=%v, want validation", err) + } +} diff --git a/internal/app/fuzz_test.go b/internal/app/fuzz_test.go new file mode 100644 index 0000000..2e19d7b --- /dev/null +++ b/internal/app/fuzz_test.go @@ -0,0 +1,48 @@ +package app + +import ( + "encoding/hex" + "strings" + "testing" +) + +func FuzzValidDigest(f *testing.F) { + f.Add("") + f.Add("sha256:") + f.Add("sha256:" + strings.Repeat("0", 64)) + f.Add("sha256:" + strings.Repeat("g", 64)) + f.Add("md5:" + strings.Repeat("0", 32)) + + f.Fuzz(func(t *testing.T, value string) { + got := validDigest(value) + if !got { + return + } + if !strings.HasPrefix(value, "sha256:") { + t.Fatalf("valid digest without sha256 prefix: %q", value) + } + digest := strings.TrimPrefix(value, "sha256:") + if len(digest) != 64 { + t.Fatalf("valid digest with length %d", len(digest)) + } + decoded, err := hex.DecodeString(digest) + if err != nil { + t.Fatalf("valid digest did not decode: %v", err) + } + if len(decoded) != 32 { + t.Fatalf("valid digest decoded to %d bytes", len(decoded)) + } + }) +} + +func FuzzHashBytesProducesValidDigest(f *testing.F) { + f.Add([]byte("")) + f.Add([]byte("release-evidence")) + f.Add([]byte(`{"password":"not-a-secret-hash-input","payload":"content"}`)) + + f.Fuzz(func(t *testing.T, body []byte) { + if digest := hashBytes(body); !validDigest(digest) { + t.Fatalf("hashBytes produced invalid digest: %q", digest) + } + }) +} diff --git a/internal/app/governance_packages.go b/internal/app/governance_packages.go index 692bc6a..413b86d 100644 --- a/internal/app/governance_packages.go +++ b/internal/app/governance_packages.go @@ -1,12 +1,15 @@ package app import ( + "archive/zip" + "bytes" "context" "crypto/ed25519" "encoding/base64" "encoding/json" "html" "sort" + "strconv" "strings" "time" @@ -36,6 +39,7 @@ type CreateApprovalInput struct { type CreateRedactionProfileInput struct { Name string Description string + Preset string AllowedTypes []string ExcludedFields []string } @@ -48,6 +52,20 @@ type CreateCustomerPackageInput struct { ExpiresAt time.Time } +type CustomerPackageArchive struct { + PackageID string + Filename string + MediaType string + Bytes []byte + Hash string + Size int64 +} + +const ( + customerDecisionExportFile = "vulnerability-decisions.json" + customerDecisionExportVersion = "customer-vulnerability-decisions.v1.0.0" +) + type CreateReportTemplateInput struct { Name string Version string @@ -69,6 +87,109 @@ type CreateDSSETrustRootInput struct { PublicKey string } +type redactionProfilePreset struct { + Name string + Description string + AllowedTypes []string + ExcludedFields []string +} + +var redactionProfilePresets = map[string]redactionProfilePreset{ + "customer_safe": { + Name: "customer_safe", + Description: "Customer-safe package profile for release evidence summaries without raw payloads, secrets, internal notes, or internal-only provenance fields.", + AllowedTypes: []string{ + "artifact", + "answer_library", + "sbom", + "vulnerability_scan", + "vex", + "vulnerability_decision", + "release_bundle", + "approval", + "exception", + "object_lock_proof", + "waiver", + }, + ExcludedFields: []string{ + "action_internal_url", + "environment_hash", + "internal_notes", + "internal_url", + "object_key", + "oidc_subject", + "parameters_hash", + "payload", + "payload_bytes", + "payload_ref", + "private_key", + "repository", + "secret", + "source_identity", + "token", + "workflow_ref", + }, + }, + "security_review": { + Name: "security_review", + Description: "Security-review package profile for broader technical evidence review while still excluding raw payloads, secrets, token material, and private keys.", + AllowedTypes: []string{ + "api_security", + "approval", + "answer_library", + "artifact", + "build", + "build_attestation", + "dast", + "exception", + "license_scan", + "manual_security_document", + "object_lock_proof", + "openapi_contract", + "pen_test_report", + "release_bundle", + "sast", + "sbom", + "secret_scan", + "security_review", + "threat_model", + "vex", + "vulnerability_decision", + "vulnerability_scan", + "waiver", + }, + ExcludedFields: []string{ + "internal_notes", + "object_key", + "payload", + "payload_bytes", + "payload_ref", + "private_key", + "secret", + "token", + }, + }, +} + +func applyRedactionProfilePreset(in CreateRedactionProfileInput) (CreateRedactionProfileInput, error) { + presetName := strings.TrimSpace(in.Preset) + if presetName == "" { + return in, nil + } + preset, ok := redactionProfilePresets[presetName] + if !ok { + return CreateRedactionProfileInput{}, ErrValidation + } + if len(in.AllowedTypes) > 0 || len(in.ExcludedFields) > 0 { + return CreateRedactionProfileInput{}, ErrValidation + } + in.Name = preset.Name + in.Description = preset.Description + in.AllowedTypes = append([]string(nil), preset.AllowedTypes...) + in.ExcludedFields = append([]string(nil), preset.ExcludedFields...) + return in, nil +} + func (l *Ledger) CreateWaiver(ctx context.Context, actor domain.Actor, in CreateWaiverInput) (domain.Waiver, error) { if err := ctx.Err(); err != nil { return domain.Waiver{}, err @@ -192,17 +313,26 @@ func (l *Ledger) CreateApprovalRecord(ctx context.Context, actor domain.Actor, i return approval, nil } -func (l *Ledger) CreateRedactionProfile(ctx context.Context, actor domain.Actor, in CreateRedactionProfileInput) (domain.RedactionProfile, error) { +func (s packageReportService) CreateRedactionProfile(ctx context.Context, actor domain.Actor, in CreateRedactionProfileInput) (domain.RedactionProfile, error) { + l := s.ledger if err := ctx.Err(); err != nil { return domain.RedactionProfile{}, err } if err := require(actor, ScopePackageWrite); err != nil { return domain.RedactionProfile{}, err } + var err error + in, err = applyRedactionProfilePreset(in) + if err != nil { + return domain.RedactionProfile{}, err + } in.Name = strings.TrimSpace(in.Name) if in.Name == "" { return domain.RedactionProfile{}, ErrValidation } + if len(in.AllowedTypes) == 0 { + return domain.RedactionProfile{}, ErrValidation + } for _, typ := range in.AllowedTypes { if strings.TrimSpace(typ) == "" { return domain.RedactionProfile{}, ErrValidation @@ -219,7 +349,8 @@ func (l *Ledger) CreateRedactionProfile(ctx context.Context, actor domain.Actor, return profile, nil } -func (l *Ledger) CreateCustomerSecurityPackage(ctx context.Context, actor domain.Actor, in CreateCustomerPackageInput) (domain.CustomerSecurityPackage, error) { +func (s packageReportService) CreateCustomerSecurityPackage(ctx context.Context, actor domain.Actor, in CreateCustomerPackageInput) (domain.CustomerSecurityPackage, error) { + l := s.ledger if err := ctx.Err(); err != nil { return domain.CustomerSecurityPackage{}, err } @@ -236,25 +367,22 @@ func (l *Ledger) CreateCustomerSecurityPackage(ctx context.Context, actor domain if err := l.ensureScopeLocked(actor.TenantID, in.ProductID, "", in.ReleaseID); err != nil { return domain.CustomerSecurityPackage{}, err } + if err := l.authorizeResourceLocked(actor, ScopePackageWrite, resourceRefs{ProductID: in.ProductID, ReleaseID: in.ReleaseID}); err != nil { + return domain.CustomerSecurityPackage{}, err + } profile, ok := l.redactions[in.RedactionProfileID] if !ok || profile.TenantID != actor.TenantID { return domain.CustomerSecurityPackage{}, ErrNotFound } evidenceIDs := l.packageEvidenceIDsLocked(actor.TenantID, in.ProductID, in.ReleaseID, profile) - manifest := map[string]any{ - "package_version": domain.CustomerPackageSchemaVersion, - "title": in.Title, - "product_id": in.ProductID, - "release_id": in.ReleaseID, - "redaction_profile_id": profile.ID, - "evidence_ids": evidenceIDs, - "limitations": []string{"Package contents are scoped and redacted; raw evidence payload bytes are not included in this manifest."}, - } + packageID := newID("csp") + generatedAt := l.now() + manifest := l.customerPackageManifestLocked(packageID, generatedAt, actor.TenantID, in.Title, in.ProductID, in.ReleaseID, profile, evidenceIDs) hash, err := canonicalAnyHash(manifest) if err != nil { return domain.CustomerSecurityPackage{}, err } - pkg := domain.CustomerSecurityPackage{ID: newID("csp"), TenantID: actor.TenantID, ProductID: in.ProductID, ReleaseID: in.ReleaseID, RedactionProfileID: profile.ID, Title: in.Title, State: "generated", Manifest: manifest, ManifestHash: hash, ExpiresAt: in.ExpiresAt.UTC(), SchemaVersion: domain.CustomerPackageSchemaVersion, CreatedAt: l.now()} + pkg := domain.CustomerSecurityPackage{ID: packageID, TenantID: actor.TenantID, ProductID: in.ProductID, ReleaseID: in.ReleaseID, RedactionProfileID: profile.ID, Title: in.Title, State: "generated", Manifest: manifest, ManifestHash: hash, ExpiresAt: in.ExpiresAt.UTC(), SchemaVersion: domain.CustomerPackageSchemaVersion, CreatedAt: generatedAt} l.customerPackages[pkg.ID] = pkg _, _ = l.appendChainLocked(actor.TenantID, "customer_package.generated", "customer_security_package", pkg.ID, "api_key", actor.KeyID, hash, "") if err := l.persistLocked(ctx); err != nil { @@ -263,7 +391,786 @@ func (l *Ledger) CreateCustomerSecurityPackage(ctx context.Context, actor domain return pkg, nil } -func (l *Ledger) AccessCustomerSecurityPackage(ctx context.Context, actor domain.Actor, id string) (domain.CustomerSecurityPackage, error) { +func (l *Ledger) customerPackageManifestLocked(packageID string, generatedAt time.Time, tenantID, title, productID, releaseID string, profile domain.RedactionProfile, evidenceIDs []string) map[string]any { + product := l.products[productID] + var release domain.Release + if releaseID != "" { + release = l.releases[releaseID] + } + checks := l.packageReadinessChecksLocked(tenantID, releaseID) + gaps := []string{} + readinessResult := "not_applicable" + if releaseID != "" { + readinessResult = "passed" + for _, check := range checks { + gaps = append(gaps, check.Missing...) + if check.Result == "failed" { + readinessResult = "failed" + } + } + sort.Strings(gaps) + } + manifest := map[string]any{ + "schema_version": domain.CustomerPackageSchemaVersion, + "package_version": domain.CustomerPackageSchemaVersion, + "package_id": packageID, + "id": packageID, + "title": title, + "generated_at": generatedAt.UTC().Format(time.RFC3339Nano), + "tenant": l.packageTenantMetadataLocked(tenantID), + "organization": l.packageOrganizationMetadataLocked(tenantID), + "product": packageProductMetadata(product), + "product_id": productID, + "release": packageReleaseMetadata(release), + "release_id": releaseID, + "redaction_profile_id": profile.ID, + "redaction_profile": packageRedactionProfileMetadata(profile), + "evidence_ids": append([]string(nil), evidenceIDs...), + "artifact_digests": l.packageArtifactMetadataLocked(tenantID, releaseID), + "readiness_summary": packageReadinessSummary(readinessResult, checks, gaps), + "verification_material": l.packageVerificationMaterialLocked(tenantID, releaseID), + "limitations": []string{ + "Package contents are scoped by product, release, redaction profile, and package expiry.", + "Raw tenant evidence payload bytes, object-store payload references, bearer tokens, private keys, API key hashes, SSO/session token hashes, and internal decision notes are not included.", + "Package data reflects records present in this Evydence instance at generation time.", + }, + "non_claims": []string{ + "This package supports technical evidence review and compliance readiness only.", + "It is not legal compliance proof, certification, complete SBOM proof, an authoritative vulnerability result, regulator acceptance, or a secure-release guarantee.", + }, + } + if customerSafeGaps := packageCustomerSafeGaps(checks, profile); len(customerSafeGaps) > 0 { + manifest["customer_safe_gaps"] = customerSafeGaps + } + if profileAllowsPackageType(profile, "sbom") { + manifest["sboms"] = l.packageSBOMMetadataLocked(tenantID, releaseID) + } + if profileAllowsPackageType(profile, "vulnerability_scan") { + manifest["vulnerability_scans"] = l.packageVulnerabilityScanMetadataLocked(tenantID, releaseID) + } + if profileAllowsPackageType(profile, "vex") { + manifest["vex_documents"] = l.packageVEXMetadataLocked(tenantID, releaseID) + } + if profileAllowsPackageType(profile, "openapi_contract") { + manifest["api_contracts"] = l.packageAPIContractMetadataLocked(tenantID, releaseID) + } + if decisions := l.packageDecisionSummariesLocked(tenantID, releaseID, profile); len(decisions) > 0 { + manifest["vulnerability_decisions"] = decisions + manifest["customer_decision_export"] = map[string]any{ + "file": customerDecisionExportFile, + "schema_version": customerDecisionExportVersion, + "decision_count": len(decisions), + "scope": "package", + } + } + if profileAllowsPackageType(profile, "approval") { + manifest["approvals"] = l.packageApprovalSummariesLocked(tenantID, productID, releaseID) + } + if profileAllowsPackageType(profile, "exception") { + manifest["exceptions"] = l.packageExceptionSummariesLocked(tenantID, releaseID) + } + if profileAllowsPackageType(profile, "waiver") { + manifest["waivers"] = l.packageWaiverSummariesLocked(tenantID, productID, releaseID) + } + if profileAllowsPackageType(profile, "answer_library") { + manifest["answer_library"] = l.packageAnswerLibraryMetadataLocked(tenantID, productID, releaseID) + } + if profileAllowsPackageType(profile, "object_lock_proof") { + manifest["object_lock_proofs"] = l.packageObjectLockProofsLocked(tenantID) + } + if profileAllowsPackageType(profile, "build") || profileAllowsPackageType(profile, "build_attestation") { + manifest["provenance"] = l.packageProvenanceMetadataLocked(tenantID, releaseID, profile) + } + return manifest +} + +func (l *Ledger) packageTenantMetadataLocked(tenantID string) map[string]any { + tenant := l.tenants[tenantID] + return map[string]any{"id": tenant.ID, "name": tenant.Name} +} + +func (l *Ledger) packageOrganizationMetadataLocked(tenantID string) map[string]any { + organizations := []map[string]any{} + for _, organization := range l.organizations { + if organization.TenantID != tenantID { + continue + } + organizations = append(organizations, map[string]any{ + "id": organization.ID, + "name": organization.Name, + "slug": organization.Slug, + "status": organization.Status, + "created": organization.CreatedAt.UTC().Format(time.RFC3339), + }) + } + sort.Slice(organizations, func(i, j int) bool { return organizations[i]["id"].(string) < organizations[j]["id"].(string) }) + return map[string]any{ + "records": organizations, + "limitations": []string{ + "Organization records are included only as tenant metadata; product-to-organization ownership is not inferred by this package schema.", + }, + } +} + +func packageProductMetadata(product domain.Product) map[string]any { + if product.ID == "" { + return map[string]any{} + } + return map[string]any{ + "id": product.ID, + "name": product.Name, + "slug": product.Slug, + "created_at": product.CreatedAt.UTC().Format(time.RFC3339), + } +} + +func packageReleaseMetadata(release domain.Release) map[string]any { + if release.ID == "" { + return map[string]any{} + } + out := map[string]any{ + "id": release.ID, + "product_id": release.ProductID, + "version": release.Version, + "state": release.State, + "created_at": release.CreatedAt.UTC().Format(time.RFC3339), + } + if release.FrozenAt != nil { + out["frozen_at"] = release.FrozenAt.UTC().Format(time.RFC3339) + } + if release.ApprovedAt != nil { + out["approved_at"] = release.ApprovedAt.UTC().Format(time.RFC3339) + } + return out +} + +func packageRedactionProfileMetadata(profile domain.RedactionProfile) map[string]any { + return map[string]any{ + "id": profile.ID, + "name": profile.Name, + "description": profile.Description, + "allowed_types": append([]string(nil), profile.AllowedTypes...), + "excluded_fields": append([]string(nil), profile.ExcludedFields...), + "schema_version": profile.SchemaVersion, + } +} + +func (l *Ledger) packageArtifactMetadataLocked(tenantID, releaseID string) []map[string]any { + ids := l.packageReleaseArtifactIDsLocked(tenantID, releaseID) + out := []map[string]any{} + for _, id := range ids { + artifact := l.artifacts[id] + out = append(out, map[string]any{ + "id": artifact.ID, + "name": artifact.Name, + "media_type": artifact.MediaType, + "size": artifact.Size, + "digest": artifact.Digest, + "created_at": artifact.CreatedAt.UTC().Format(time.RFC3339), + }) + } + return out +} + +func (l *Ledger) packageReleaseArtifactIDsLocked(tenantID, releaseID string) []string { + ids := map[string]bool{} + if releaseID == "" { + return nil + } + for _, item := range l.evidence { + if item.TenantID != tenantID || item.ReleaseID != releaseID { + continue + } + for _, ref := range item.SubjectRefs { + if ref.Type == "artifact" && ref.ID != "" { + ids[ref.ID] = true + } + } + } + for _, sbom := range l.sboms { + if sbom.TenantID == tenantID && sbom.ReleaseID == releaseID && sbom.ArtifactID != "" { + ids[sbom.ArtifactID] = true + } + } + for _, vex := range l.vexDocuments { + if vex.TenantID == tenantID && vex.ReleaseID == releaseID && vex.ArtifactID != "" { + ids[vex.ArtifactID] = true + } + } + for _, build := range l.buildRuns { + if build.TenantID != tenantID || build.ReleaseID != releaseID { + continue + } + for _, output := range build.Outputs { + if output.ArtifactID != "" { + ids[output.ArtifactID] = true + } + } + } + return sortedStringSet(ids) +} + +func (l *Ledger) packageSBOMMetadataLocked(tenantID, releaseID string) []map[string]any { + out := []map[string]any{} + for _, sbom := range l.sboms { + if sbom.TenantID != tenantID || sbom.ReleaseID != releaseID { + continue + } + out = append(out, map[string]any{ + "id": sbom.ID, + "evidence_id": sbom.EvidenceID, + "release_id": sbom.ReleaseID, + "artifact_id": sbom.ArtifactID, + "format": sbom.Format, + "spec_version": sbom.SpecVersion, + "component_count": sbom.ComponentCount, + "created_at": sbom.CreatedAt.UTC().Format(time.RFC3339), + }) + } + sortManifestMapsByID(out) + return out +} + +func (l *Ledger) packageVulnerabilityScanMetadataLocked(tenantID, releaseID string) []map[string]any { + out := []map[string]any{} + for _, scan := range l.scans { + if scan.TenantID != tenantID || scan.ReleaseID != releaseID { + continue + } + out = append(out, map[string]any{ + "id": scan.ID, + "evidence_id": scan.EvidenceID, + "release_id": scan.ReleaseID, + "scanner": scan.Scanner, + "target_ref": scan.TargetRef, + "summary": cloneIntMap(scan.Summary), + "finding_count": len(scan.Findings), + "created_at": scan.CreatedAt.UTC().Format(time.RFC3339), + }) + } + sortManifestMapsByID(out) + return out +} + +func (l *Ledger) packageVEXMetadataLocked(tenantID, releaseID string) []map[string]any { + out := []map[string]any{} + for _, vex := range l.vexDocuments { + if vex.TenantID != tenantID || vex.ReleaseID != releaseID { + continue + } + out = append(out, map[string]any{ + "id": vex.ID, + "evidence_id": vex.EvidenceID, + "release_id": vex.ReleaseID, + "artifact_id": vex.ArtifactID, + "format": vex.Format, + "author": vex.Author, + "version": vex.Version, + "statement_count": vex.StatementCount, + "status_summary": cloneIntMap(vex.StatusSummary), + "schema_version": vex.SchemaVersion, + "created_at": vex.CreatedAt.UTC().Format(time.RFC3339), + }) + } + sortManifestMapsByID(out) + return out +} + +func (l *Ledger) packageAPIContractMetadataLocked(tenantID, releaseID string) map[string]any { + contracts := []map[string]any{} + for _, contract := range l.contracts { + if contract.TenantID != tenantID || contract.ReleaseID != releaseID { + continue + } + contracts = append(contracts, map[string]any{ + "id": contract.ID, + "evidence_id": contract.EvidenceID, + "product_id": contract.ProductID, + "release_id": contract.ReleaseID, + "version": contract.Version, + "hash": contract.Hash, + "path_count": contract.PathCount, + "operation_count": len(contract.Operations), + "operations": packageOpenAPIOperations(contract.Operations), + "created_at": contract.CreatedAt.UTC().Format(time.RFC3339), + }) + } + sortManifestMapsByID(contracts) + diffs := []map[string]any{} + for _, diff := range l.contractDiffs { + if diff.TenantID != tenantID || diff.ReleaseID != releaseID { + continue + } + diffs = append(diffs, map[string]any{ + "id": diff.ID, + "base_contract_id": diff.BaseContractID, + "target_contract_id": diff.TargetContractID, + "product_id": diff.ProductID, + "release_id": diff.ReleaseID, + "result": diff.Result, + "breaking_changes": append([]string(nil), diff.BreakingChanges...), + "non_breaking_changes": append([]string(nil), diff.NonBreakingChanges...), + "schema_version": diff.SchemaVersion, + "created_at": diff.CreatedAt.UTC().Format(time.RFC3339), + }) + } + sortManifestMapsByID(diffs) + return map[string]any{ + "openapi_contracts": contracts, + "contract_diffs": diffs, + "limitations": []string{ + "OpenAPI contract evidence includes stored metadata, hashes, and normalized operation summaries only.", + "Raw OpenAPI document bytes, object-store payload references, and private/internal extensions are not included.", + "Diff results summarize recorded contract evidence and do not make this package an API governance approval.", + }, + } +} + +func packageOpenAPIOperations(operations []domain.OpenAPIOperation) []map[string]any { + out := make([]map[string]any, 0, len(operations)) + for _, operation := range operations { + method := strings.ToUpper(strings.TrimSpace(operation.Method)) + path := strings.TrimSpace(operation.Path) + if method == "" || path == "" { + continue + } + out = append(out, map[string]any{ + "label": method + " " + path, + "path": path, + "method": method, + "operation_id": operation.OperationID, + "deprecated": operation.Deprecated, + "request_body_required": operation.RequestBodyRequired, + "required_request_fields": append([]string(nil), operation.RequiredRequestFields...), + "response_statuses": append([]string(nil), operation.ResponseStatuses...), + }) + } + sort.Slice(out, func(i, j int) bool { + left, _ := out[i]["label"].(string) + right, _ := out[j]["label"].(string) + return left < right + }) + return out +} + +func (l *Ledger) packageApprovalSummariesLocked(tenantID, productID, releaseID string) []map[string]any { + out := []map[string]any{} + for _, approval := range l.approvals { + if approval.TenantID != tenantID || !approvalBelongsToPackage(approval, productID, releaseID) { + continue + } + out = append(out, map[string]any{ + "id": approval.ID, + "subject_type": approval.SubjectType, + "subject_id": approval.SubjectID, + "decision": approval.Decision, + "reason": approval.Reason, + "evidence_id": approval.EvidenceID, + "created_at": approval.CreatedAt.UTC().Format(time.RFC3339), + }) + } + sortManifestMapsByID(out) + return out +} + +func approvalBelongsToPackage(approval domain.ApprovalRecord, productID, releaseID string) bool { + switch approval.SubjectType { + case "release": + return releaseID != "" && approval.SubjectID == releaseID + case "product": + return approval.SubjectID == productID + default: + return false + } +} + +func (l *Ledger) packageExceptionSummariesLocked(tenantID, releaseID string) []map[string]any { + out := []map[string]any{} + now := l.now() + for _, exception := range l.exceptions { + if exception.TenantID != tenantID || exception.ReleaseID != releaseID || !exception.Approved || !exception.ExpiresAt.After(now) { + continue + } + summary := map[string]any{ + "id": exception.ID, + "release_id": exception.ReleaseID, + "finding_id": exception.FindingID, + "control_id": exception.ControlID, + "reason": exception.Reason, + "owner": exception.Owner, + "expires_at": exception.ExpiresAt.UTC().Format(time.RFC3339), + "approved": exception.Approved, + "created_at": exception.CreatedAt.UTC().Format(time.RFC3339), + } + if exception.ApprovedAt != nil { + summary["approved_at"] = exception.ApprovedAt.UTC().Format(time.RFC3339) + } + out = append(out, summary) + } + sortManifestMapsByID(out) + return out +} + +func (l *Ledger) packageWaiverSummariesLocked(tenantID, productID, releaseID string) []map[string]any { + out := []map[string]any{} + now := l.now() + for _, waiver := range l.waivers { + if waiver.TenantID != tenantID || !waiver.Approved || !waiver.ExpiresAt.After(now) || !waiverBelongsToPackage(waiver, productID, releaseID) { + continue + } + summary := map[string]any{ + "id": waiver.ID, + "scope_type": waiver.ScopeType, + "scope_id": waiver.ScopeID, + "control_id": waiver.ControlID, + "policy_id": waiver.PolicyID, + "owner": waiver.Owner, + "risk": waiver.Risk, + "reason": waiver.Reason, + "expires_at": waiver.ExpiresAt.UTC().Format(time.RFC3339), + "approved": waiver.Approved, + "created_at": waiver.CreatedAt.UTC().Format(time.RFC3339), + } + if waiver.ApprovedAt != nil { + summary["approved_at"] = waiver.ApprovedAt.UTC().Format(time.RFC3339) + } + out = append(out, summary) + } + sortManifestMapsByID(out) + return out +} + +func waiverBelongsToPackage(waiver domain.Waiver, productID, releaseID string) bool { + switch waiver.ScopeType { + case "release": + return releaseID != "" && waiver.ScopeID == releaseID + case "product": + return waiver.ScopeID == productID + default: + return false + } +} + +func (l *Ledger) packageAnswerLibraryMetadataLocked(tenantID, productID, releaseID string) []map[string]any { + out := []map[string]any{} + for _, entry := range l.answerLibrary { + if entry.TenantID != tenantID { + continue + } + if entry.ProductID != "" && entry.ProductID != productID { + continue + } + if entry.ReleaseID != "" && entry.ReleaseID != releaseID { + continue + } + out = append(out, map[string]any{ + "id": entry.ID, + "question_id": entry.QuestionID, + "evidence_type": entry.EvidenceType, + "control_id": entry.ControlID, + "product_id": entry.ProductID, + "release_id": entry.ReleaseID, + "answer": entry.Answer, + "evidence_ids": append([]string(nil), entry.EvidenceIDs...), + "limitations": append([]string(nil), entry.Limitations...), + "created_at": entry.CreatedAt.UTC().Format(time.RFC3339), + }) + } + sortManifestMapsByID(out) + return out +} + +func (l *Ledger) packageObjectLockProofsLocked(tenantID string) []map[string]any { + out := []map[string]any{} + for _, policy := range l.retentionPolicies { + if policy.TenantID != tenantID { + continue + } + proof := map[string]any{ + "id": policy.ID, + "name": policy.Name, + "object_prefix_configured": policy.ObjectPrefix != "", + "sample_object_key_configured": policy.ObjectKey != "", + "require_legal_hold": policy.RequireLegalHold, + "mode": policy.Mode, + "retention_days": policy.RetentionDays, + "status": policy.Status, + "verification_hash": policy.VerificationHash, + "verification_checks": packageVerifyChecks(policy.VerificationChecks), + "verification_limitations": append([]string(nil), policy.VerificationLimitations...), + "created_at": policy.CreatedAt.UTC().Format(time.RFC3339), + "limitations": []string{ + "Object-lock proof records show configured Evydence verification results for tenant object-storage settings only.", + "They do not prove external WORM enforcement, IAM policy, lifecycle policy, backup behavior, or legal compliance.", + }, + } + if policy.VerifiedAt != nil { + proof["verified_at"] = policy.VerifiedAt.UTC().Format(time.RFC3339) + } + out = append(out, proof) + } + sortManifestMapsByID(out) + return out +} + +func packageVerifyChecks(checks []domain.VerifyCheck) []map[string]any { + out := make([]map[string]any, 0, len(checks)) + for _, check := range checks { + out = append(out, map[string]any{"name": check.Name, "result": check.Result, "detail": check.Detail}) + } + sort.Slice(out, func(i, j int) bool { + left, _ := out[i]["name"].(string) + right, _ := out[j]["name"].(string) + return left < right + }) + return out +} + +func (l *Ledger) packageProvenanceMetadataLocked(tenantID, releaseID string, profile domain.RedactionProfile) map[string]any { + builds := []map[string]any{} + buildIDs := map[string]bool{} + if profileAllowsPackageType(profile, "build") { + for _, build := range l.buildRuns { + if build.TenantID != tenantID || build.ReleaseID != releaseID { + continue + } + buildIDs[build.ID] = true + builds = append(builds, map[string]any{ + "id": build.ID, + "project_id": build.ProjectID, + "collector_id": build.CollectorID, + "provider": build.Provider, + "commit_sha": build.CommitSHA, + "repository": build.Repository, + "workflow_ref": build.WorkflowRef, + "run_id": build.RunID, + "run_attempt": build.RunAttempt, + "status": build.Status, + "started_at": build.StartedAt.UTC().Format(time.RFC3339), + "finished_at": packageOptionalTime(build.FinishedAt), + "parameters_hash": build.ParametersHash, + "environment_hash": build.EnvironmentHash, + "outputs": packageBuildOutputs(build.Outputs), + "schema_version": build.SchemaVersion, + "created_at": build.CreatedAt.UTC().Format(time.RFC3339), + }) + } + } + sortManifestMapsByID(builds) + attestations := []map[string]any{} + if profileAllowsPackageType(profile, "build_attestation") { + for _, attestation := range l.attestations { + if attestation.TenantID != tenantID || !buildIDs[attestation.BuildID] { + continue + } + attestations = append(attestations, map[string]any{ + "id": attestation.ID, + "build_id": attestation.BuildID, + "evidence_id": attestation.EvidenceID, + "payload_hash": attestation.PayloadHash, + "payload_size": attestation.PayloadSize, + "payload_type": attestation.PayloadType, + "predicate_type": attestation.PredicateType, + "subject_digests": append([]string(nil), attestation.SubjectDigests...), + "signature_count": attestation.SignatureCount, + "verification_status": attestation.VerificationStatus, + "schema_version": attestation.SchemaVersion, + "created_at": attestation.CreatedAt.UTC().Format(time.RFC3339), + }) + } + } + sortManifestMapsByID(attestations) + return map[string]any{"builds": builds, "build_attestations": attestations} +} + +func packageBuildOutputs(outputs []domain.BuildOutput) []map[string]any { + out := make([]map[string]any, 0, len(outputs)) + for _, output := range outputs { + out = append(out, map[string]any{"artifact_id": output.ArtifactID, "digest": output.Digest}) + } + sort.Slice(out, func(i, j int) bool { + return out[i]["artifact_id"].(string)+"\x00"+out[i]["digest"].(string) < out[j]["artifact_id"].(string)+"\x00"+out[j]["digest"].(string) + }) + return out +} + +func packageOptionalTime(value *time.Time) string { + if value == nil { + return "" + } + return value.UTC().Format(time.RFC3339) +} + +func (l *Ledger) packageReadinessChecksLocked(tenantID, releaseID string) []domain.PolicyCheck { + if releaseID == "" { + return nil + } + return []domain.PolicyCheck{ + l.checkReleaseHasEvidenceLocked(tenantID, releaseID, "sbom", "release_requires_sbom", "high"), + l.checkReleaseHasEvidenceLocked(tenantID, releaseID, "vulnerability_scan", "release_requires_vulnerability_scan", "high"), + l.checkReleaseHasArtifactDigestLocked(tenantID, releaseID), + l.checkReleaseHasSignedBundleLocked(tenantID, releaseID), + l.checkReleaseHasPassedBuildLocked(tenantID, releaseID), + l.checkReleaseHasBuildAttestationLocked(tenantID, releaseID), + l.checkNoOpenCriticalLocked(tenantID, releaseID), + } +} + +func packageReadinessSummary(result string, checks []domain.PolicyCheck, gaps []string) map[string]any { + checkSummaries := make([]map[string]any, 0, len(checks)) + for _, check := range checks { + checkSummaries = append(checkSummaries, map[string]any{ + "name": check.Name, + "result": check.Result, + "severity": check.Severity, + "missing": append([]string(nil), check.Missing...), + "explanation": check.Explanation, + "remediation": check.Remediation, + }) + } + return map[string]any{ + "result": result, + "checks": checkSummaries, + "gaps": append([]string(nil), gaps...), + "limitations": []string{ + "Readiness is derived from recorded package-scope evidence only.", + "Readiness output is not a compliance, certification, or secure-release conclusion.", + }, + } +} + +func packageCustomerSafeGaps(checks []domain.PolicyCheck, profile domain.RedactionProfile) []map[string]any { + out := []map[string]any{} + for _, check := range checks { + if check.Result == "passed" { + continue + } + for _, missing := range check.Missing { + if !packageGapVisibleForProfile(missing, profile) { + continue + } + out = append(out, map[string]any{ + "id": "gap_" + check.Name + "_" + missing, + "category": "missing_evidence", + "evidence_type": missing, + "source_check": check.Name, + "severity": check.Severity, + "summary": check.Explanation, + "remediation": check.Remediation, + "customer_visible": true, + "limitations": []string{ + "Gap is based only on evidence metadata included by this package redaction profile.", + }, + }) + } + } + sort.Slice(out, func(i, j int) bool { + left, _ := out[i]["id"].(string) + right, _ := out[j]["id"].(string) + return left < right + }) + return out +} + +func packageGapVisibleForProfile(missing string, profile domain.RedactionProfile) bool { + switch strings.TrimSpace(missing) { + case "artifact", "artifact_digest": + return profileAllowsPackageType(profile, "artifact") + case "sbom": + return profileAllowsPackageType(profile, "sbom") + case "vulnerability_scan": + return profileAllowsPackageType(profile, "vulnerability_scan") + case "vulnerability_decision": + return profileAllowsPackageType(profile, "vulnerability_decision") + case "signed_release_bundle": + return profileAllowsPackageType(profile, "release_bundle") + case "passed_build": + return profileAllowsPackageType(profile, "build") + case "build_attestation": + return profileAllowsPackageType(profile, "build_attestation") + default: + return false + } +} + +func (l *Ledger) packageVerificationMaterialLocked(tenantID, releaseID string) map[string]any { + bundles := []map[string]any{} + for _, bundle := range l.bundles { + if bundle.TenantID != tenantID || bundle.ReleaseID != releaseID { + continue + } + bundles = append(bundles, map[string]any{ + "id": bundle.ID, + "state": bundle.State, + "manifest_hash": bundle.ManifestHash, + "signature_refs": append([]string(nil), bundle.SignatureRefs...), + "created_at": bundle.CreatedAt.UTC().Format(time.RFC3339), + }) + } + sortManifestMapsByID(bundles) + return map[string]any{ + "hash_algorithm": "sha256", + "canonicalization": domain.CanonicalizationProfileVersion, + "manifest_hash_field": "manifest_hash", + "release_bundles": bundles, + "audit_chain": l.packageAuditChainSummaryLocked(tenantID), + } +} + +func (l *Ledger) packageAuditChainSummaryLocked(tenantID string) map[string]any { + checks := l.verifyChainLocked(tenantID) + result := "passed" + for _, check := range checks { + if check.Result == "failed" { + result = "failed" + break + } + } + entries := l.chain[tenantID] + head := "" + if len(entries) > 0 { + head = entries[len(entries)-1].EntryHash + } + return map[string]any{ + "result": result, + "latest_sequence": len(entries), + "head_hash": head, + "checks": checks, + } +} + +func sortedStringSet(in map[string]bool) []string { + out := make([]string, 0, len(in)) + for value := range in { + if strings.TrimSpace(value) != "" { + out = append(out, value) + } + } + sort.Strings(out) + return out +} + +func cloneIntMap(in map[string]int) map[string]int { + if len(in) == 0 { + return map[string]int{} + } + out := make(map[string]int, len(in)) + for key, value := range in { + out[key] = value + } + return out +} + +func sortManifestMapsByID(items []map[string]any) { + sort.Slice(items, func(i, j int) bool { + left, _ := items[i]["id"].(string) + right, _ := items[j]["id"].(string) + return left < right + }) +} + +func (s packageReportService) AccessCustomerSecurityPackage(ctx context.Context, actor domain.Actor, id string) (domain.CustomerSecurityPackage, error) { + l := s.ledger if err := ctx.Err(); err != nil { return domain.CustomerSecurityPackage{}, err } @@ -276,6 +1183,9 @@ func (l *Ledger) AccessCustomerSecurityPackage(ctx context.Context, actor domain if !ok || pkg.TenantID != actor.TenantID { return domain.CustomerSecurityPackage{}, ErrNotFound } + if err := l.authorizeResourceLocked(actor, ScopePackageRead, resourceRefs{ProductID: pkg.ProductID, ReleaseID: pkg.ReleaseID, CustomerPackageID: pkg.ID}); err != nil { + return domain.CustomerSecurityPackage{}, err + } if !pkg.ExpiresAt.After(l.now()) { return domain.CustomerSecurityPackage{}, ErrConflict } @@ -288,7 +1198,30 @@ func (l *Ledger) AccessCustomerSecurityPackage(ctx context.Context, actor domain return pkg, nil } -func (l *Ledger) SecurityReviewPackageReport(ctx context.Context, actor domain.Actor, packageID string) (domain.SecurityReviewPackageReport, error) { +func (s packageReportService) ExportCustomerSecurityPackageArchive(ctx context.Context, actor domain.Actor, id string) (CustomerPackageArchive, error) { + l := s.ledger + pkg, err := l.AccessCustomerSecurityPackage(ctx, actor, id) + if err != nil { + return CustomerPackageArchive{}, err + } + return customerPackageArchive(pkg) +} + +func (s packageReportService) ExportCustomerPortalPackageArchive(ctx context.Context, token string) (CustomerPackageArchive, error) { + return s.ExportCustomerPortalPackageArchiveWithAcceptance(ctx, token, CustomerPortalAcceptanceInput{}) +} + +func (s packageReportService) ExportCustomerPortalPackageArchiveWithAcceptance(ctx context.Context, token string, in CustomerPortalAcceptanceInput) (CustomerPackageArchive, error) { + l := s.ledger + pkg, err := l.identityService().accessCustomerPortalPackage(ctx, token, in, "customer_portal_package.downloaded") + if err != nil { + return CustomerPackageArchive{}, err + } + return customerPackageArchive(pkg) +} + +func (s packageReportService) SecurityReviewPackageReport(ctx context.Context, actor domain.Actor, packageID string) (domain.SecurityReviewPackageReport, error) { + l := s.ledger pkg, err := l.AccessCustomerSecurityPackage(ctx, actor, packageID) if err != nil { return domain.SecurityReviewPackageReport{}, err @@ -307,7 +1240,436 @@ func (l *Ledger) SecurityReviewPackageReport(ctx context.Context, actor domain.A return domain.SecurityReviewPackageReport{ReportType: "security_review_package", TemplateVersion: "security-review-package.v1.0.0", PackageID: pkg.ID, ProductID: pkg.ProductID, ReleaseID: pkg.ReleaseID, EvidenceIDs: ids, Assumptions: []string{"Report includes only package-scoped evidence metadata."}, Limitations: []string{"This report supports customer review but is not a compliance, legal, or secure-release conclusion."}, GeneratedAt: l.now()}, nil } -func (l *Ledger) CRAReadinessHTMLPackage(ctx context.Context, actor domain.Actor, productID, releaseID string) (domain.HTMLReportPackage, error) { +func packageWithDistributionWatermark(pkg domain.CustomerSecurityPackage, access domain.CustomerPortalAccess) domain.CustomerSecurityPackage { + pkg.DistributionWatermark = packageDistributionWatermark(pkg, portalReviewerLabel(access.CustomerName, access.ReviewerName, access.ReviewerEmail), access.ID) + if access.Watermark != "" { + pkg.DistributionWatermark = access.Watermark + } + return pkg +} + +func portalReviewerLabel(customerName, reviewerName, reviewerEmail string) string { + if reviewerName != "" && reviewerEmail != "" { + return reviewerName + " <" + reviewerEmail + ">" + } + if reviewerEmail != "" { + return reviewerEmail + } + if reviewerName != "" { + return reviewerName + } + return customerName +} + +func packageDistributionWatermark(pkg domain.CustomerSecurityPackage, customerName, accessID string) string { + customerName = cleanExternalLabel(customerName) + accessID = strings.TrimSpace(accessID) + if customerName == "" && accessID == "" { + return "Evydence package " + pkg.ID + " exported for scoped review." + } + return cleanExternalLabel("Evydence package " + pkg.ID + " for " + customerName + " via access " + accessID + ".") +} + +func cleanExternalLabel(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "" + } + runes := make([]rune, 0, len(value)) + for _, r := range value { + if r < 32 || r == 127 { + continue + } + runes = append(runes, r) + if len(runes) >= 160 { + break + } + } + return strings.TrimSpace(string(runes)) +} + +func customerPackageArchive(pkg domain.CustomerSecurityPackage) (CustomerPackageArchive, error) { + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + decisionExport := customerPackageDecisionExport(pkg) + metadata := map[string]any{ + "id": pkg.ID, + "product_id": pkg.ProductID, + "release_id": pkg.ReleaseID, + "redaction_profile_id": pkg.RedactionProfileID, + "title": pkg.Title, + "state": pkg.State, + "manifest_hash": pkg.ManifestHash, + "html_report_file": "report.html", + "expires_at": pkg.ExpiresAt.UTC().Format(time.RFC3339), + "schema_version": pkg.SchemaVersion, + "created_at": pkg.CreatedAt.UTC().Format(time.RFC3339), + } + if decisionExport != nil { + metadata["decision_export_file"] = customerDecisionExportFile + } + if pkg.DistributionWatermark != "" { + metadata["distribution_watermark"] = pkg.DistributionWatermark + } + verification := map[string]any{ + "package_id": pkg.ID, + "manifest_hash": pkg.ManifestHash, + "manifest_file": "manifest.json", + "metadata_file": "package.json", + "html_report_file": "report.html", + "hash_algorithm": "sha256", + "verification_note": "Verify the package manifest hash against the Evydence API or a signed bundle before relying on contents.", + "limitations": []string{ + "Archive contents are scoped and redacted.", + "Raw tenant evidence payload bytes are not included.", + "This package supports technical evidence review and does not prove legal compliance, certification, or release security status.", + }, + } + if decisionExport != nil { + verification["decision_export_file"] = customerDecisionExportFile + } + readme := "Evydence customer security package\n\n" + + "This ZIP contains a scoped package manifest, package metadata, verification guidance, and customer-safe decision export when present.\n" + + "It intentionally excludes raw tenant evidence payload bytes and bearer tokens.\n" + + "Use the manifest hash with Evydence verification records or signed bundles before relying on package contents.\n" + for _, entry := range []struct { + name string + body any + }{ + {name: "manifest.json", body: pkg.Manifest}, + {name: "package.json", body: metadata}, + {name: "verification.json", body: verification}, + } { + body, err := json.MarshalIndent(entry.body, "", " ") + if err != nil { + _ = zw.Close() + return CustomerPackageArchive{}, err + } + body = append(body, '\n') + if err := addZIPFile(zw, entry.name, body); err != nil { + _ = zw.Close() + return CustomerPackageArchive{}, err + } + } + if decisionExport != nil { + body, err := json.MarshalIndent(decisionExport, "", " ") + if err != nil { + _ = zw.Close() + return CustomerPackageArchive{}, err + } + body = append(body, '\n') + if err := addZIPFile(zw, customerDecisionExportFile, body); err != nil { + _ = zw.Close() + return CustomerPackageArchive{}, err + } + } + if err := addZIPFile(zw, "README.txt", []byte(readme)); err != nil { + _ = zw.Close() + return CustomerPackageArchive{}, err + } + if pkg.DistributionWatermark != "" { + if err := addZIPFile(zw, "WATERMARK.txt", []byte(pkg.DistributionWatermark+"\n")); err != nil { + _ = zw.Close() + return CustomerPackageArchive{}, err + } + } + if err := addZIPFile(zw, "report.html", customerPackageHTMLReport(pkg, metadata, verification)); err != nil { + _ = zw.Close() + return CustomerPackageArchive{}, err + } + if err := zw.Close(); err != nil { + return CustomerPackageArchive{}, err + } + body := buf.Bytes() + return CustomerPackageArchive{PackageID: pkg.ID, Filename: "evydence-customer-package-" + pkg.ID + ".zip", MediaType: "application/zip", Bytes: body, Hash: hashBytes(body), Size: int64(len(body))}, nil +} + +func customerPackageDecisionExport(pkg domain.CustomerSecurityPackage) map[string]any { + decisions := packageHTMLRecords(pkg.Manifest["vulnerability_decisions"]) + if len(decisions) == 0 { + return nil + } + return map[string]any{ + "schema_version": customerDecisionExportVersion, + "package_id": pkg.ID, + "product_id": pkg.ProductID, + "release_id": pkg.ReleaseID, + "source_manifest_hash": pkg.ManifestHash, + "decision_count": len(decisions), + "decisions": decisions, + "assumptions": []string{ + "This file contains only active vulnerability decisions included by the package redaction profile.", + "Supporting links are Evydence record type/id pairs scoped by the package manifest.", + }, + "limitations": []string{ + "This export supports technical evidence review and compliance readiness only; it is not legal compliance proof, certification, complete SBOM proof, authoritative vulnerability coverage, or a secure-release guarantee.", + "Decision accuracy depends on tenant-supplied scanner, VEX, SBOM, exception, waiver, approval, and review evidence.", + }, + "generated_at": pkg.CreatedAt.UTC().Format(time.RFC3339), + } +} + +func customerPackageHTMLReport(pkg domain.CustomerSecurityPackage, metadata, verification map[string]any) []byte { + manifest := pkg.Manifest + product := packageHTMLMap(manifest["product"]) + release := packageHTMLMap(manifest["release"]) + readiness := packageHTMLMap(manifest["readiness_summary"]) + apiContracts := packageHTMLMap(manifest["api_contracts"]) + vexDocuments := packageHTMLRecords(manifest["vex_documents"]) + decisions := packageHTMLRecords(manifest["vulnerability_decisions"]) + answerLibrary := packageHTMLRecords(manifest["answer_library"]) + objectLockProofs := packageHTMLRecords(manifest["object_lock_proofs"]) + limitations := packageHTMLStrings(manifest["limitations"]) + nonClaims := packageHTMLStrings(manifest["non_claims"]) + + var b strings.Builder + b.WriteString("") + b.WriteString(packageHTMLEscape(pkg.Title)) + b.WriteString("
") + b.WriteString("

") + b.WriteString(packageHTMLEscape(pkg.Title)) + b.WriteString("

This static report is generated from the redacted customer package manifest. It supports technical evidence review and compliance readiness only; it is not legal compliance proof, certification, complete SBOM proof, an authoritative vulnerability result, regulator acceptance, or a secure-release guarantee.

") + if watermark := packageHTMLString(metadata["distribution_watermark"]); watermark != "" { + b.WriteString("

Distribution watermark: ") + b.WriteString(packageHTMLEscape(watermark)) + b.WriteString("

") + } + + b.WriteString("

Release Summary

") + packageHTMLRow(&b, "Package ID", pkg.ID) + packageHTMLRow(&b, "Product", packageHTMLFirst(product, "name", "id")) + packageHTMLRow(&b, "Release", packageHTMLFirst(release, "version", "id")) + packageHTMLRow(&b, "Package State", pkg.State) + packageHTMLRow(&b, "Readiness", packageHTMLString(readiness["result"])) + packageHTMLRow(&b, "Manifest Hash", packageHTMLString(metadata["manifest_hash"])) + b.WriteString("
") + + b.WriteString("

VEX And Vulnerability Decisions

") + if len(vexDocuments) == 0 && len(decisions) == 0 { + b.WriteString("

No customer-visible VEX documents or vulnerability decisions are included by this package profile.

") + } else { + if len(vexDocuments) > 0 { + b.WriteString("

VEX Documents

") + for _, record := range vexDocuments { + b.WriteString("") + } + b.WriteString("
IDFormatAuthorStatementsStatus Summary
" + packageHTMLEscape(packageHTMLString(record["id"])) + "" + packageHTMLEscape(packageHTMLString(record["format"])) + "" + packageHTMLEscape(packageHTMLString(record["author"])) + "" + packageHTMLEscape(packageHTMLString(record["statement_count"])) + "" + packageHTMLEscape(packageHTMLJSON(record["status_summary"])) + "
") + } + if len(decisions) > 0 { + b.WriteString("

Vulnerability Decisions

") + for _, record := range decisions { + b.WriteString("") + } + b.WriteString("
VulnerabilityComponentSBOMStatusImpact StatementReviewedReview DueSupporting LinksSource
" + packageHTMLEscape(packageHTMLString(record["vulnerability"])) + "" + packageHTMLEscape(packageHTMLString(record["component"])) + "" + packageHTMLEscape(packageHTMLString(record["sbom_id"])) + "" + packageHTMLEscape(packageHTMLString(record["status"])) + "" + packageHTMLEscape(packageHTMLString(record["impact_statement"])) + "" + packageHTMLEscape(packageHTMLString(record["reviewed_at"])) + "" + packageHTMLEscape(packageHTMLString(record["review_due_at"])) + "" + packageHTMLEscape(packageHTMLJSON(record["supporting_refs"])) + "" + packageHTMLEscape(packageHTMLString(record["source"])) + "
") + } + } + + b.WriteString("

Questionnaire Answer Library

") + if len(answerLibrary) == 0 { + b.WriteString("

No customer-visible questionnaire answer library entries are included by this package profile.

") + } else { + b.WriteString("") + for _, record := range answerLibrary { + b.WriteString("") + } + b.WriteString("
QuestionEvidence TypeAnswerEvidence IDs
" + packageHTMLEscape(packageHTMLString(record["question_id"])) + "" + packageHTMLEscape(packageHTMLString(record["evidence_type"])) + "" + packageHTMLEscape(packageHTMLString(record["answer"])) + "" + packageHTMLEscape(packageHTMLJSON(record["evidence_ids"])) + "
") + } + + b.WriteString("

Object-Lock Proofs

") + if len(objectLockProofs) == 0 { + b.WriteString("

No customer-visible object-lock proof records are included by this package profile.

") + } else { + b.WriteString("") + for _, record := range objectLockProofs { + b.WriteString("") + } + b.WriteString("
IDStatusModeRetention DaysVerification Hash
" + packageHTMLEscape(packageHTMLString(record["id"])) + "" + packageHTMLEscape(packageHTMLString(record["status"])) + "" + packageHTMLEscape(packageHTMLString(record["mode"])) + "" + packageHTMLEscape(packageHTMLString(record["retention_days"])) + "" + packageHTMLEscape(packageHTMLString(record["verification_hash"])) + "
") + } + + b.WriteString("

API Contract Evidence

") + contractRecords := packageHTMLRecords(apiContracts["openapi_contracts"]) + diffRecords := packageHTMLRecords(apiContracts["contract_diffs"]) + if len(contractRecords) == 0 && len(diffRecords) == 0 { + b.WriteString("

No customer-visible OpenAPI contract evidence is included by this package profile.

") + } else { + if len(contractRecords) > 0 { + b.WriteString("

OpenAPI Contracts

") + for _, record := range contractRecords { + b.WriteString("") + } + b.WriteString("
IDVersionHashPathsOperations
" + packageHTMLEscape(packageHTMLString(record["id"])) + "" + packageHTMLEscape(packageHTMLString(record["version"])) + "" + packageHTMLEscape(packageHTMLString(record["hash"])) + "" + packageHTMLEscape(packageHTMLString(record["path_count"])) + "" + packageHTMLEscape(packageHTMLString(record["operation_count"])) + "
") + } + if len(diffRecords) > 0 { + b.WriteString("

Contract Diffs

") + for _, record := range diffRecords { + b.WriteString("") + } + b.WriteString("
IDResultBreaking ChangesNon-breaking Changes
" + packageHTMLEscape(packageHTMLString(record["id"])) + "" + packageHTMLEscape(packageHTMLString(record["result"])) + "" + packageHTMLEscape(packageHTMLJSON(record["breaking_changes"])) + "" + packageHTMLEscape(packageHTMLJSON(record["non_breaking_changes"])) + "
") + } + } + + b.WriteString("

Readiness

") + packageHTMLReadiness(&b, readiness) + b.WriteString("

Verification

") + packageHTMLRow(&b, "Manifest File", packageHTMLString(verification["manifest_file"])) + packageHTMLRow(&b, "Manifest Hash", packageHTMLString(verification["manifest_hash"])) + packageHTMLRow(&b, "Decision Export", packageHTMLString(verification["decision_export_file"])) + packageHTMLRow(&b, "Hash Algorithm", packageHTMLString(verification["hash_algorithm"])) + packageHTMLRow(&b, "Verification Note", packageHTMLString(verification["verification_note"])) + b.WriteString("
") + b.WriteString("

Limitations

") + packageHTMLList(&b, limitations) + b.WriteString("

Non-Claims

") + packageHTMLList(&b, nonClaims) + b.WriteString("
") + return []byte(b.String()) +} + +func packageHTMLReadiness(b *strings.Builder, readiness map[string]any) { + result := packageHTMLString(readiness["result"]) + if result == "" { + b.WriteString("

No release readiness summary is included in this package.

") + return + } + b.WriteString("

Result: " + packageHTMLEscape(result) + "

") + checks := packageHTMLRecords(readiness["checks"]) + if len(checks) > 0 { + b.WriteString("") + for _, check := range checks { + b.WriteString("") + } + b.WriteString("
CheckResultSeverityMissingExplanation
" + packageHTMLEscape(packageHTMLString(check["name"])) + "" + packageHTMLEscape(packageHTMLString(check["result"])) + "" + packageHTMLEscape(packageHTMLString(check["severity"])) + "" + packageHTMLEscape(packageHTMLJSON(check["missing"])) + "" + packageHTMLEscape(packageHTMLString(check["explanation"])) + "
") + } + if gaps := packageHTMLStrings(readiness["gaps"]); len(gaps) > 0 { + b.WriteString("

Gaps

") + packageHTMLList(b, gaps) + } +} + +func packageHTMLRow(b *strings.Builder, label, value string) { + b.WriteString("") + b.WriteString(packageHTMLEscape(label)) + b.WriteString("") + b.WriteString(packageHTMLEscape(value)) + b.WriteString("") +} + +func packageHTMLList(b *strings.Builder, items []string) { + if len(items) == 0 { + b.WriteString("

None included.

") + return + } + b.WriteString("
    ") + for _, item := range items { + b.WriteString("
  • ") + b.WriteString(packageHTMLEscape(item)) + b.WriteString("
  • ") + } + b.WriteString("
") +} + +func packageHTMLMap(value any) map[string]any { + if value == nil { + return map[string]any{} + } + if record, ok := value.(map[string]any); ok { + return record + } + return map[string]any{} +} + +func packageHTMLRecords(value any) []map[string]any { + switch records := value.(type) { + case []map[string]any: + return records + case []any: + out := make([]map[string]any, 0, len(records)) + for _, record := range records { + if mapped, ok := record.(map[string]any); ok { + out = append(out, mapped) + } + } + return out + default: + return nil + } +} + +func packageHTMLStrings(value any) []string { + switch records := value.(type) { + case []string: + return append([]string(nil), records...) + case []any: + out := make([]string, 0, len(records)) + for _, record := range records { + if s := packageHTMLString(record); s != "" { + out = append(out, s) + } + } + return out + default: + return nil + } +} + +func packageHTMLFirst(record map[string]any, keys ...string) string { + for _, key := range keys { + if value := packageHTMLString(record[key]); value != "" { + return value + } + } + return "" +} + +func packageHTMLString(value any) string { + switch v := value.(type) { + case string: + return v + case int: + return strconv.Itoa(v) + case int64: + return strconv.FormatInt(v, 10) + case float64: + return strconv.FormatFloat(v, 'f', -1, 64) + case bool: + if v { + return "true" + } + return "false" + default: + return "" + } +} + +func packageHTMLJSON(value any) string { + if value == nil { + return "" + } + body, err := json.Marshal(value) + if err != nil { + return "" + } + return string(body) +} + +func packageHTMLEscape(value string) string { + return html.EscapeString(value) +} + +func addZIPFile(zw *zip.Writer, name string, body []byte) error { + header := &zip.FileHeader{Name: name, Method: zip.Deflate} + header.SetMode(0o644) + header.Modified = time.Date(1980, 1, 1, 0, 0, 0, 0, time.UTC) + writer, err := zw.CreateHeader(header) + if err != nil { + return err + } + _, err = writer.Write(body) + return err +} + +func (s packageReportService) CRAReadinessHTMLPackage(ctx context.Context, actor domain.Actor, productID, releaseID string) (domain.HTMLReportPackage, error) { + l := s.ledger report, err := l.CRAReadinessReport(ctx, actor, CRAReadinessReportInput{ProductID: productID, ReleaseID: releaseID}) if err != nil { return domain.HTMLReportPackage{}, err @@ -376,7 +1738,8 @@ func (l *Ledger) InstallControlFrameworkTemplatePack(ctx context.Context, actor return framework, nil } -func (l *Ledger) CreateCustomReportTemplate(ctx context.Context, actor domain.Actor, in CreateReportTemplateInput) (domain.CustomReportTemplate, error) { +func (s packageReportService) CreateCustomReportTemplate(ctx context.Context, actor domain.Actor, in CreateReportTemplateInput) (domain.CustomReportTemplate, error) { + l := s.ledger if err := ctx.Err(); err != nil { return domain.CustomReportTemplate{}, err } @@ -398,7 +1761,8 @@ func (l *Ledger) CreateCustomReportTemplate(ctx context.Context, actor domain.Ac return tpl, nil } -func (l *Ledger) RenderCustomReport(ctx context.Context, actor domain.Actor, in RenderReportInput) (domain.RenderedCustomReport, error) { +func (s packageReportService) RenderCustomReport(ctx context.Context, actor domain.Actor, in RenderReportInput) (domain.RenderedCustomReport, error) { + l := s.ledger if err := ctx.Err(); err != nil { return domain.RenderedCustomReport{}, err } @@ -431,7 +1795,8 @@ func (l *Ledger) RenderCustomReport(ctx context.Context, actor domain.Actor, in return rendered, nil } -func (l *Ledger) ExportEvidenceBundle(ctx context.Context, actor domain.Actor, releaseID string, evidenceIDs []string) (domain.EvidenceBundle, error) { +func (s packageReportService) ExportEvidenceBundle(ctx context.Context, actor domain.Actor, releaseID string, evidenceIDs []string) (domain.EvidenceBundle, error) { + l := s.ledger if err := ctx.Err(); err != nil { return domain.EvidenceBundle{}, err } @@ -442,8 +1807,19 @@ func (l *Ledger) ExportEvidenceBundle(ctx context.Context, actor domain.Actor, r defer l.mu.Unlock() ids := []string{} if len(evidenceIDs) == 0 { + if releaseID != "" { + release, ok := l.releases[strings.TrimSpace(releaseID)] + if !ok || release.TenantID != actor.TenantID { + return domain.EvidenceBundle{}, ErrNotFound + } + if err := l.authorizeResourceLocked(actor, ScopeBundleRead, resourceRefs{ReleaseID: release.ID}); err != nil { + return domain.EvidenceBundle{}, err + } + } else if err := l.authorizeResourceLocked(actor, ScopeBundleRead, resourceRefs{}); err != nil { + return domain.EvidenceBundle{}, err + } for _, item := range l.evidence { - if item.TenantID == actor.TenantID && (releaseID == "" || item.ReleaseID == releaseID) { + if item.TenantID == actor.TenantID && (releaseID == "" || item.ReleaseID == releaseID) && l.resourceAllowedLocked(actor, ScopeBundleRead, refsForEvidence(item)) { ids = append(ids, item.ID) } } @@ -453,6 +1829,9 @@ func (l *Ledger) ExportEvidenceBundle(ctx context.Context, actor domain.Actor, r if !ok || item.TenantID != actor.TenantID { return domain.EvidenceBundle{}, ErrNotFound } + if err := l.authorizeResourceLocked(actor, ScopeBundleRead, refsForEvidence(item)); err != nil { + return domain.EvidenceBundle{}, err + } ids = append(ids, item.ID) } } @@ -461,7 +1840,16 @@ func (l *Ledger) ExportEvidenceBundle(ctx context.Context, actor domain.Actor, r if entries := l.chain[actor.TenantID]; len(entries) > 0 { head = entries[len(entries)-1].EntryHash } - manifest := map[string]any{"bundle_version": domain.EvidenceBundleSchemaVersion, "tenant_id": actor.TenantID, "release_id": releaseID, "evidence_ids": ids, "audit_chain_head": head, "verification": "Run evydence verify-evidence-bundle offline."} + manifest := map[string]any{ + "bundle_version": domain.EvidenceBundleSchemaVersion, + "tenant_id": actor.TenantID, + "release_id": releaseID, + "evidence_ids": ids, + "audit_chain_head": head, + "object_lock_proofs": l.packageObjectLockProofsLocked(actor.TenantID), + "verification": "Run evydence verify-evidence-bundle offline.", + "verification_limits": []string{"Object-lock proof records reflect Evydence verification metadata and do not prove legal compliance, provider IAM correctness, or complete WORM enforcement."}, + } hash, err := canonicalAnyHash(manifest) if err != nil { return domain.EvidenceBundle{}, err @@ -479,7 +1867,8 @@ func (l *Ledger) ExportEvidenceBundle(ctx context.Context, actor domain.Actor, r return bundle, nil } -func (l *Ledger) ImportEvidenceBundle(ctx context.Context, actor domain.Actor, bundle domain.EvidenceBundle) (domain.EvidenceBundleImport, error) { +func (s packageReportService) ImportEvidenceBundle(ctx context.Context, actor domain.Actor, bundle domain.EvidenceBundle) (domain.EvidenceBundleImport, error) { + l := s.ledger if err := ctx.Err(); err != nil { return domain.EvidenceBundleImport{}, err } @@ -602,7 +1991,14 @@ func (l *Ledger) packageEvidenceIDsLocked(tenantID, productID, releaseID string, } ids := []string{} for _, item := range l.evidence { - if item.TenantID != tenantID || (productID != "" && item.ProductID != productID) || (releaseID != "" && item.ReleaseID != releaseID) { + if item.TenantID != tenantID { + continue + } + if releaseID != "" { + if item.ReleaseID != releaseID { + continue + } + } else if productID != "" && item.ProductID != productID { continue } if len(allowed) > 0 { @@ -616,6 +2012,82 @@ func (l *Ledger) packageEvidenceIDsLocked(tenantID, productID, releaseID string, return ids } +func (l *Ledger) packageDecisionSummariesLocked(tenantID, releaseID string, profile domain.RedactionProfile) []map[string]any { + if !profileAllowsPackageType(profile, "vulnerability_decision") { + return nil + } + excluded := profileExcludedFields(profile) + summaries := []map[string]any{} + for _, decision := range l.decisions { + if decision.TenantID != tenantID || decision.ReleaseID != releaseID || decision.SupersededBy != "" || !decision.CustomerVisible { + continue + } + summary := map[string]any{ + "id": decision.ID, + "finding_id": decision.FindingID, + "scan_id": decision.ScanID, + "release_id": decision.ReleaseID, + "vulnerability": decision.Vulnerability, + "component": decision.Component, + "sbom_id": decision.SBOMID, + "sbom_component_purl": decision.SBOMComponentPURL, + "sbom_component_name": decision.SBOMComponentName, + "status": decision.Status, + "impact_statement": decision.ImpactStatement, + "source": decision.Source, + "created_at": decision.CreatedAt.UTC().Format(time.RFC3339), + } + if decision.ReviewedAt != nil && !excluded["reviewed_at"] { + summary["reviewed_at"] = decision.ReviewedAt.UTC().Format(time.RFC3339) + } + if decision.ReviewDueAt != nil && !excluded["review_due_at"] { + summary["review_due_at"] = decision.ReviewDueAt.UTC().Format(time.RFC3339) + } + if decision.ActionStatement != "" && !excluded["action_statement"] { + summary["action_statement"] = decision.ActionStatement + } + if decision.Justification != "" && !excluded["justification"] { + summary["justification"] = decision.Justification + } + if decision.EvidenceID != "" && !excluded["evidence_id"] { + summary["evidence_id"] = decision.EvidenceID + } + if decision.VEXDocumentID != "" && !excluded["vex_document_id"] { + summary["vex_document_id"] = decision.VEXDocumentID + } + if len(decision.EvidenceIDs) > 0 && !excluded["evidence_ids"] { + summary["evidence_ids"] = append([]string(nil), decision.EvidenceIDs...) + } + if len(decision.SupportingRefs) > 0 && !excluded["supporting_refs"] { + summary["supporting_refs"] = cloneSubjectRefs(decision.SupportingRefs) + } + summaries = append(summaries, summary) + } + sort.Slice(summaries, func(i, j int) bool { + left := summaries[i]["vulnerability"].(string) + "\x00" + summaries[i]["id"].(string) + right := summaries[j]["vulnerability"].(string) + "\x00" + summaries[j]["id"].(string) + return left < right + }) + return summaries +} + +func profileAllowsPackageType(profile domain.RedactionProfile, typ string) bool { + for _, allowed := range profile.AllowedTypes { + if strings.TrimSpace(allowed) == typ { + return true + } + } + return false +} + +func profileExcludedFields(profile domain.RedactionProfile) map[string]bool { + excluded := map[string]bool{} + for _, field := range profile.ExcludedFields { + excluded[strings.TrimSpace(field)] = true + } + return excluded +} + func builtinTemplatePacks() []domain.ControlFrameworkTemplatePack { return []domain.ControlFrameworkTemplatePack{ {ID: "tpl_cra_readiness", Name: "Evydence CRA Readiness", Slug: "evydence-cra-readiness", Version: "2026.05", Description: "Starter technical evidence controls for CRA readiness tracking.", SchemaVersion: "control-framework-template-pack.v1.0.0", Controls: []domain.SecurityControl{ diff --git a/internal/app/governance_packages_test.go b/internal/app/governance_packages_test.go index c76d192..549b9e8 100644 --- a/internal/app/governance_packages_test.go +++ b/internal/app/governance_packages_test.go @@ -1,12 +1,16 @@ package app import ( + "archive/zip" + "bytes" "context" "crypto/ed25519" "crypto/rand" "encoding/base64" "encoding/json" "errors" + "io" + "strings" "testing" "time" @@ -57,6 +61,43 @@ func TestWaiverApprovalAndCustomerPackageFlow(t *testing.T) { if len(report.EvidenceIDs) != 1 || report.EvidenceIDs[0] != item.ID { t.Fatalf("report evidence ids = %#v, want package evidence", report.EvidenceIDs) } + archive, err := ledger.ExportCustomerSecurityPackageArchive(ctx, actor, pkg.ID) + if err != nil { + t.Fatalf("export package archive: %v", err) + } + if archive.MediaType != "application/zip" || archive.Hash != hashBytes(archive.Bytes) || archive.Size != int64(len(archive.Bytes)) { + t.Fatalf("archive metadata invalid: %#v", archive) + } + files := packageArchiveFiles(t, archive.Bytes) + for _, name := range []string{"manifest.json", "package.json", "verification.json", "README.txt", "report.html"} { + if files[name] == "" { + t.Fatalf("archive missing %s: %#v", name, files) + } + } + if report := files["report.html"]; !strings.Contains(report, "Release Summary") || !strings.Contains(report, "VEX And Vulnerability Decisions") || !strings.Contains(report, "Readiness") || !strings.Contains(report, "Verification") || !strings.Contains(report, "Limitations") || !strings.Contains(report, "not legal compliance proof") { + t.Fatalf("HTML report missing expected sections or non-claim: %s", report) + } + if strings.Contains(files["report.html"], " 0 +} + +func (s identityService) BootstrapTenant(ctx context.Context, name, keyName string, scopes []string) (domain.Tenant, domain.APIKey, string, error) { + l := s.ledger + if err := ctx.Err(); err != nil { + return domain.Tenant{}, domain.APIKey{}, "", err + } + name = strings.TrimSpace(name) + keyName = strings.TrimSpace(keyName) + if name == "" || keyName == "" { + return domain.Tenant{}, domain.APIKey{}, "", ErrValidation + } + if len(scopes) == 0 { + scopes = []string{"*"} + } + l.mu.Lock() + defer l.mu.Unlock() + now := l.now() + tenant := domain.Tenant{ID: newID("ten"), Name: name, CreatedAt: now} + l.tenants[tenant.ID] = tenant + key, secret, err := l.createAPIKeyLocked(tenant.ID, keyName, scopes, nil) + if err != nil { + return domain.Tenant{}, domain.APIKey{}, "", err + } + if _, err := l.rotateSigningKeyLocked(tenant.ID, "bootstrap"); err != nil { + return domain.Tenant{}, domain.APIKey{}, "", err + } + _, _ = l.appendChainLocked(tenant.ID, "tenant.created", "tenant", tenant.ID, "system", "bootstrap", "", "") + if err := l.persistCriticalLocked(ctx, l.criticalMutationLocked()); err != nil { + return domain.Tenant{}, domain.APIKey{}, "", err + } + return tenant, key, secret, nil +} + +func (s identityService) Authenticate(ctx context.Context, secret string) (domain.Actor, error) { + l := s.ledger + if err := ctx.Err(); err != nil { + return domain.Actor{}, err + } + secret = strings.TrimSpace(strings.TrimPrefix(secret, "Bearer ")) + if secret == "" { + return domain.Actor{}, ErrUnauthorized + } + prefix := secretPrefix(secret) + hash := l.hashSecret(secret) + l.mu.Lock() + defer l.mu.Unlock() + for id, key := range l.apiKeys { + if key.Prefix != prefix || !secretHashEqual(key.Hash, hash) || key.RevokedAt != nil { + continue + } + if key.ExpiresAt != nil && !key.ExpiresAt.After(l.now()) { + return domain.Actor{}, ErrUnauthorized + } + now := l.now() + key.LastUsedAt = &now + l.apiKeys[id] = key + collectorID := "" + for collectorMapID, collector := range l.collectors { + if collector.TenantID == key.TenantID && collector.APIKeyID == key.ID { + collectorID = collector.ID + collector.LastSeenAt = &now + l.collectors[collectorMapID] = collector + break + } + } + _ = l.persistCriticalLocked(ctx, l.criticalMutationLocked()) + return domain.Actor{TenantID: key.TenantID, KeyID: key.ID, Name: key.Name, Scopes: append([]string(nil), key.Scopes...), CollectorID: collectorID}, nil + } + for id, session := range l.ssoSessions { + if session.Prefix != prefix || !secretHashEqual(session.Hash, hash) || session.RevokedAt != nil || !session.ExpiresAt.After(l.now()) { + continue + } + user, ok := l.users[session.UserID] + if !ok || user.TenantID != session.TenantID || user.Status != "active" { + return domain.Actor{}, ErrUnauthorized + } + grants := append(l.resourceGrantsForUserLocked(user.ID), l.resourceGrantsForSSOSessionLocked(session)...) + scopes := scopesFromResourceGrants(grants) + if len(scopes) == 0 { + return domain.Actor{}, ErrForbidden + } + l.ssoSessions[id] = session + _ = l.persistCriticalLocked(ctx, l.criticalMutationLocked()) + return domain.Actor{TenantID: user.TenantID, UserID: user.ID, SessionID: session.ID, Name: user.Email, Scopes: scopes, ResourceGrants: grants}, nil + } + return domain.Actor{}, ErrUnauthorized +} + +func (s identityService) CreateAPIKey(ctx context.Context, actor domain.Actor, name string, scopes []string, expiresAt *time.Time) (domain.APIKey, string, error) { + l := s.ledger + if err := ctx.Err(); err != nil { + return domain.APIKey{}, "", err + } + if err := require(actor, ScopeAdmin); err != nil { + return domain.APIKey{}, "", err + } + if strings.TrimSpace(name) == "" || len(scopes) == 0 { + return domain.APIKey{}, "", ErrValidation + } + if err := requireGrantableScopes(actor, scopes); err != nil { + return domain.APIKey{}, "", err + } + l.mu.Lock() + defer l.mu.Unlock() + key, secret, err := l.createAPIKeyLocked(actor.TenantID, name, scopes, expiresAt) + if err != nil { + return domain.APIKey{}, "", err + } + _, _ = l.appendChainLocked(actor.TenantID, "api_key.created", "api_key", key.ID, "api_key", actor.KeyID, "", "") + if err := l.persistCriticalLocked(ctx, l.criticalMutationLocked()); err != nil { + return domain.APIKey{}, "", err + } + return key, secret, nil +} + +func (s identityService) ListAPIKeys(ctx context.Context, actor domain.Actor) ([]domain.APIKey, error) { + l := s.ledger + if err := ctx.Err(); err != nil { + return nil, err + } + if err := require(actor, ScopeAdmin); err != nil { + return nil, err + } + l.mu.Lock() + defer l.mu.Unlock() + out := []domain.APIKey{} + for _, key := range l.apiKeys { + if key.TenantID == actor.TenantID { + key.Hash = "" + out = append(out, key) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) + return out, nil +} + +func (l *Ledger) CreateOrganization(ctx context.Context, actor domain.Actor, in CreateOrganizationInput) (domain.Organization, error) { + return l.identityService().CreateOrganization(ctx, actor, in) +} + +func (l *Ledger) CreateUser(ctx context.Context, actor domain.Actor, in CreateUserInput) (domain.HumanUser, error) { + return l.identityService().CreateUser(ctx, actor, in) +} + +func (l *Ledger) DeactivateUser(ctx context.Context, actor domain.Actor, id string) (domain.HumanUser, error) { + return l.identityService().DeactivateUser(ctx, actor, id) +} + +func (l *Ledger) CreateRoleBinding(ctx context.Context, actor domain.Actor, in CreateRoleBindingInput) (domain.RoleBinding, error) { + return l.identityService().CreateRoleBinding(ctx, actor, in) +} + +func (l *Ledger) ListRoleBindings(ctx context.Context, actor domain.Actor) ([]domain.RoleBinding, error) { + return l.identityService().ListRoleBindings(ctx, actor) +} + +func (l *Ledger) CreateSSOProvider(ctx context.Context, actor domain.Actor, in CreateSSOProviderInput) (domain.SSOProvider, error) { + return l.identityService().CreateSSOProvider(ctx, actor, in) +} + +func (l *Ledger) UpdateSSOProviderTrustMaterial(ctx context.Context, actor domain.Actor, id string, in UpdateSSOProviderTrustMaterialInput) (domain.SSOProvider, error) { + return l.identityService().UpdateSSOProviderTrustMaterial(ctx, actor, id, in) +} + +func (l *Ledger) RefreshSSOProviderOIDCTrustMaterial(ctx context.Context, actor domain.Actor, id string) (domain.SSOProvider, error) { + return l.identityService().RefreshSSOProviderOIDCTrustMaterial(ctx, actor, id) +} + +func (l *Ledger) LinkSSOIdentity(ctx context.Context, actor domain.Actor, in LinkSSOIdentityInput) (domain.UserIdentityLink, error) { + return l.identityService().LinkSSOIdentity(ctx, actor, in) +} + +func (l *Ledger) CreateSSOSession(ctx context.Context, actor domain.Actor, in CreateSSOSessionInput) (domain.SSOSession, string, error) { + return l.identityService().CreateSSOSession(ctx, actor, in) +} + +func (l *Ledger) ExchangeSSOCredential(ctx context.Context, in ExchangeSSOCredentialInput) (domain.ProviderVerification, domain.SSOSession, string, error) { + return l.identityService().ExchangeSSOCredential(ctx, in) +} + +func (l *Ledger) RevokeSSOSession(ctx context.Context, actor domain.Actor, id string) (domain.SSOSession, error) { + return l.identityService().RevokeSSOSession(ctx, actor, id) +} + +func (l *Ledger) RevokeCurrentSSOSession(ctx context.Context, actor domain.Actor) (domain.SSOSession, error) { + return l.identityService().RevokeCurrentSSOSession(ctx, actor) +} + +func (l *Ledger) CreateCustomerPortalAccess(ctx context.Context, actor domain.Actor, in CreateCustomerPortalAccessInput) (domain.CustomerPortalAccess, string, error) { + return l.identityService().CreateCustomerPortalAccess(ctx, actor, in) +} + +func (l *Ledger) ListCustomerPortalAccess(ctx context.Context, actor domain.Actor, packageID string) ([]domain.CustomerPortalAccess, error) { + return l.identityService().ListCustomerPortalAccess(ctx, actor, packageID) +} + +func (l *Ledger) RevokeCustomerPortalAccess(ctx context.Context, actor domain.Actor, id string) (domain.CustomerPortalAccess, error) { + return l.identityService().RevokeCustomerPortalAccess(ctx, actor, id) +} + +func (l *Ledger) AccessCustomerPortalPackage(ctx context.Context, token string) (domain.CustomerSecurityPackage, error) { + return l.identityService().AccessCustomerPortalPackage(ctx, token) +} + +func (l *Ledger) AccessCustomerPortalPackageWithAcceptance(ctx context.Context, token string, in CustomerPortalAcceptanceInput) (domain.CustomerSecurityPackage, error) { + return l.identityService().AccessCustomerPortalPackageWithAcceptance(ctx, token, in) +} diff --git a/internal/app/implementation_increments.go b/internal/app/implementation_increments.go index a26f740..19a0f69 100644 --- a/internal/app/implementation_increments.go +++ b/internal/app/implementation_increments.go @@ -152,6 +152,9 @@ func (l *Ledger) SearchEvidence(ctx context.Context, actor domain.Actor, in Evid if item.TenantID != actor.TenantID || !matchesEvidenceSearch(item, in) { continue } + if !l.resourceAllowedLocked(actor, ScopeEvidenceRead, refsForEvidence(item)) { + continue + } out = append(out, item) } sort.Slice(out, func(i, j int) bool { @@ -184,11 +187,17 @@ func (l *Ledger) RecordEvidenceLifecycleEvent(ctx context.Context, actor domain. if !ok || item.TenantID != actor.TenantID { return domain.EvidenceLifecycleEvent{}, ErrNotFound } + if err := l.authorizeResourceLocked(actor, ScopeEvidenceWrite, refsForEvidence(item)); err != nil { + return domain.EvidenceLifecycleEvent{}, err + } if in.ReplacementID != "" { replacement, ok := l.evidence[strings.TrimSpace(in.ReplacementID)] if !ok || replacement.TenantID != actor.TenantID { return domain.EvidenceLifecycleEvent{}, ErrNotFound } + if err := l.authorizeResourceLocked(actor, ScopeEvidenceWrite, refsForEvidence(replacement)); err != nil { + return domain.EvidenceLifecycleEvent{}, err + } } event := domain.EvidenceLifecycleEvent{ ID: newID("elc"), @@ -204,7 +213,7 @@ func (l *Ledger) RecordEvidenceLifecycleEvent(ctx context.Context, actor domain. } l.lifecycle[event.ID] = event _, _ = l.appendChainLocked(actor.TenantID, "evidence."+event.Action, "evidence_item", item.ID, actorType(actor), actorID(actor), item.PayloadHash, "") - if err := l.persistLocked(ctx); err != nil { + if err := l.persistReleaseLedgerLocked(ctx, l.releaseLedgerMutationLocked()); err != nil { return domain.EvidenceLifecycleEvent{}, err } return event, nil @@ -223,6 +232,9 @@ func (l *Ledger) ListEvidenceLifecycleEvents(ctx context.Context, actor domain.A if !ok || item.TenantID != actor.TenantID { return nil, ErrNotFound } + if err := l.authorizeResourceLocked(actor, ScopeEvidenceRead, refsForEvidence(item)); err != nil { + return nil, err + } out := []domain.EvidenceLifecycleEvent{} for _, event := range l.lifecycle { if event.TenantID == actor.TenantID && event.EvidenceID == item.ID { @@ -246,6 +258,9 @@ func (l *Ledger) CreateReleaseCandidate(ctx context.Context, actor domain.Actor, if !ok || release.TenantID != actor.TenantID { return domain.ReleaseCandidate{}, ErrNotFound } + if err := l.authorizeResourceLocked(actor, ScopeReleaseWrite, resourceRefs{ProductID: release.ProductID, ReleaseID: release.ID}); err != nil { + return domain.ReleaseCandidate{}, err + } if strings.TrimSpace(in.Name) == "" { return domain.ReleaseCandidate{}, ErrValidation } @@ -294,6 +309,9 @@ func (l *Ledger) GetReleaseCandidate(ctx context.Context, actor domain.Actor, id if !ok || candidate.TenantID != actor.TenantID { return domain.ReleaseCandidate{}, ErrNotFound } + if err := l.authorizeResourceLocked(actor, ScopeReleaseRead, resourceRefs{ReleaseID: candidate.ReleaseID}); err != nil { + return domain.ReleaseCandidate{}, err + } return candidate, nil } @@ -314,6 +332,9 @@ func (l *Ledger) ListReleaseCandidates(ctx context.Context, actor domain.Actor, if releaseID != "" && candidate.ReleaseID != releaseID { continue } + if !l.resourceAllowedLocked(actor, ScopeReleaseRead, resourceRefs{ReleaseID: candidate.ReleaseID}) { + continue + } out = append(out, candidate) } sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt.Before(out[j].CreatedAt) }) @@ -340,6 +361,9 @@ func (l *Ledger) UpdateReleaseCandidateState(ctx context.Context, actor domain.A if candidate.State != candidateOpen { return domain.ReleaseCandidate{}, ErrConflict } + if err := l.authorizeResourceLocked(actor, ScopeReleaseWrite, resourceRefs{ReleaseID: candidate.ReleaseID}); err != nil { + return domain.ReleaseCandidate{}, err + } now := l.now() candidate.State = state if state == candidatePromoted { @@ -373,6 +397,9 @@ func (l *Ledger) RegisterContainerImage(ctx context.Context, actor domain.Actor, if !ok || artifact.TenantID != actor.TenantID || artifact.Digest != in.Digest { return domain.ContainerImage{}, ErrNotFound } + if err := l.authorizeResourceLocked(actor, ScopeEvidenceWrite, resourceRefs{ArtifactID: artifact.ID}); err != nil { + return domain.ContainerImage{}, err + } } for _, existing := range l.images { if existing.TenantID == actor.TenantID && existing.Repository == in.Repository && existing.Digest == in.Digest { @@ -415,6 +442,10 @@ func (l *Ledger) CreateArtifactSignature(ctx context.Context, actor domain.Actor l.mu.Unlock() return domain.ArtifactSignature{}, ErrNotFound } + if err := l.authorizeResourceLocked(actor, ScopeEvidenceWrite, resourceRefs{ArtifactID: artifact.ID}); err != nil { + l.mu.Unlock() + return domain.ArtifactSignature{}, err + } l.mu.Unlock() payloadHash, payloadRef := "", "" if len(in.RawPayload) > 0 { @@ -462,6 +493,9 @@ func (l *Ledger) GetArtifactSignature(ctx context.Context, actor domain.Actor, i if !ok || sig.TenantID != actor.TenantID { return domain.ArtifactSignature{}, ErrNotFound } + if err := l.authorizeResourceLocked(actor, ScopeEvidenceRead, resourceRefs{ArtifactID: sig.ArtifactID}); err != nil { + return domain.ArtifactSignature{}, err + } return sig, nil } @@ -482,6 +516,9 @@ func (l *Ledger) ListSourceRepositories(ctx context.Context, actor domain.Actor, if projectID != "" && repo.ProjectID != projectID { continue } + if !l.resourceAllowedLocked(actor, ScopeSourceRead, resourceRefs{ProjectID: repo.ProjectID, SourceRepositoryID: repo.ID}) { + continue + } out = append(out, repo) } sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt.Before(out[j].CreatedAt) }) @@ -506,6 +543,9 @@ func (l *Ledger) CreateSourceRepository(ctx context.Context, actor domain.Actor, if !ok || project.TenantID != actor.TenantID { return domain.SourceRepository{}, ErrNotFound } + if err := l.authorizeResourceLocked(actor, ScopeSourceWrite, resourceRefs{ProductID: project.ProductID, ProjectID: project.ID}); err != nil { + return domain.SourceRepository{}, err + } } for _, existing := range l.repositories { if existing.TenantID == actor.TenantID && existing.Provider == in.Provider && existing.FullName == in.FullName { @@ -551,6 +591,9 @@ func (l *Ledger) RecordSourceCommit(ctx context.Context, actor domain.Actor, in if !ok || repo.TenantID != actor.TenantID { return domain.SourceCommit{}, ErrNotFound } + if err := l.authorizeResourceLocked(actor, ScopeSourceWrite, resourceRefs{ProjectID: repo.ProjectID, SourceRepositoryID: repo.ID}); err != nil { + return domain.SourceCommit{}, err + } for _, existing := range l.commits { if existing.TenantID == actor.TenantID && existing.RepositoryID == repo.ID && existing.SHA == in.SHA { return existing, nil @@ -596,6 +639,9 @@ func (l *Ledger) UpsertSourceBranch(ctx context.Context, actor domain.Actor, in if !ok || repo.TenantID != actor.TenantID { return domain.SourceBranch{}, ErrNotFound } + if err := l.authorizeResourceLocked(actor, ScopeSourceWrite, resourceRefs{ProjectID: repo.ProjectID, SourceRepositoryID: repo.ID}); err != nil { + return domain.SourceBranch{}, err + } if in.HeadCommitID != "" { commit, ok := l.commits[strings.TrimSpace(in.HeadCommitID)] if !ok || commit.TenantID != actor.TenantID || commit.RepositoryID != repo.ID { @@ -651,6 +697,9 @@ func (l *Ledger) RecordPullRequest(ctx context.Context, actor domain.Actor, in R if !ok || repo.TenantID != actor.TenantID { return domain.PullRequest{}, ErrNotFound } + if err := l.authorizeResourceLocked(actor, ScopeSourceWrite, resourceRefs{ProjectID: repo.ProjectID, SourceRepositoryID: repo.ID}); err != nil { + return domain.PullRequest{}, err + } if in.HeadCommitID != "" { commit, ok := l.commits[strings.TrimSpace(in.HeadCommitID)] if !ok || commit.TenantID != actor.TenantID || commit.RepositoryID != repo.ID { @@ -697,6 +746,9 @@ func (l *Ledger) ListDeploymentEnvironments(ctx context.Context, actor domain.Ac if productID != "" && env.ProductID != productID { continue } + if !l.resourceAllowedLocked(actor, ScopeDeploymentRead, resourceRefs{ProductID: env.ProductID, EnvironmentID: env.ID}) { + continue + } out = append(out, env) } sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt.Before(out[j].CreatedAt) }) @@ -720,6 +772,9 @@ func (l *Ledger) CreateDeploymentEnvironment(ctx context.Context, actor domain.A if !ok || product.TenantID != actor.TenantID { return domain.DeploymentEnvironment{}, ErrNotFound } + if err := l.authorizeResourceLocked(actor, ScopeDeploymentWrite, resourceRefs{ProductID: product.ID}); err != nil { + return domain.DeploymentEnvironment{}, err + } for _, existing := range l.environments { if existing.TenantID == actor.TenantID && existing.ProductID == product.ID && existing.Name == in.Name { return existing, nil @@ -767,6 +822,10 @@ func (l *Ledger) RecordDeployment(ctx context.Context, actor domain.Actor, in Re l.mu.Unlock() return domain.DeploymentEvent{}, ErrNotFound } + if err := l.authorizeResourceLocked(actor, ScopeDeploymentWrite, resourceRefs{ProductID: env.ProductID, ReleaseID: release.ID, EnvironmentID: env.ID}); err != nil { + l.mu.Unlock() + return domain.DeploymentEvent{}, err + } for _, artifactID := range in.ArtifactIDs { artifact, ok := l.artifacts[strings.TrimSpace(artifactID)] if !ok || artifact.TenantID != actor.TenantID { @@ -841,6 +900,9 @@ func (l *Ledger) GetDeployment(ctx context.Context, actor domain.Actor, id strin if !ok || deployment.TenantID != actor.TenantID { return domain.DeploymentEvent{}, ErrNotFound } + if err := l.authorizeResourceLocked(actor, ScopeDeploymentRead, resourceRefs{ReleaseID: deployment.ReleaseID, DeploymentID: deployment.ID}); err != nil { + return domain.DeploymentEvent{}, err + } return deployment, nil } @@ -864,6 +926,9 @@ func (l *Ledger) ListDeployments(ctx context.Context, actor domain.Actor, releas if environmentID != "" && deployment.EnvironmentID != environmentID { continue } + if !l.resourceAllowedLocked(actor, ScopeDeploymentRead, resourceRefs{ReleaseID: deployment.ReleaseID, DeploymentID: deployment.ID, EnvironmentID: deployment.EnvironmentID}) { + continue + } out = append(out, deployment) } sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt.Before(out[j].CreatedAt) }) diff --git a/internal/app/integrity_runtime.go b/internal/app/integrity_runtime.go index c57a9d0..b9c56b3 100644 --- a/internal/app/integrity_runtime.go +++ b/internal/app/integrity_runtime.go @@ -2,6 +2,7 @@ package app import ( "context" + "sort" "strings" "time" @@ -36,10 +37,12 @@ type CreateTransparencyCheckpointInput struct { } type CreateObjectRetentionPolicyInput struct { - Name string - ObjectPrefix string - Mode string - RetentionDays int + Name string + ObjectPrefix string + ObjectKey string + Mode string + RetentionDays int + RequireLegalHold bool } type AuditLogFilter struct { @@ -163,6 +166,12 @@ func (l *Ledger) CreateSigningProvider(ctx context.Context, actor domain.Actor, if in.Type == "local_encrypted_dev" && !in.Encrypted { return domain.SigningProvider{}, ErrValidation } + if in.Type == "native_pkcs11_hsm" && (!in.Encrypted || !strings.HasPrefix(in.KeyRef, "pkcs11:")) { + return domain.SigningProvider{}, ErrValidation + } + if in.Type == "native_pkcs11_hsm" && signingProviderRefContainsSecret(in.KeyRef) { + return domain.SigningProvider{}, ErrValidation + } l.mu.Lock() defer l.mu.Unlock() provider := domain.SigningProvider{ID: newID("sp"), TenantID: actor.TenantID, Name: in.Name, Type: in.Type, Status: "active", KeyRef: in.KeyRef, Encrypted: in.Encrypted, SchemaVersion: domain.SigningProviderSchemaVersion, CreatedAt: l.now()} @@ -293,7 +302,7 @@ func (l *Ledger) CreateObjectRetentionPolicy(ctx context.Context, actor domain.A if err := require(actor, ScopeAdmin); err != nil { return domain.ObjectRetentionPolicy{}, err } - in.Name, in.ObjectPrefix, in.Mode = strings.TrimSpace(in.Name), strings.TrimSpace(in.ObjectPrefix), strings.TrimSpace(in.Mode) + in.Name, in.ObjectPrefix, in.ObjectKey, in.Mode = strings.TrimSpace(in.Name), strings.TrimSpace(in.ObjectPrefix), strings.TrimSpace(in.ObjectKey), strings.TrimSpace(in.Mode) if in.Name == "" || in.RetentionDays <= 0 || (in.Mode != "governance" && in.Mode != "compliance") { return domain.ObjectRetentionPolicy{}, ErrValidation } @@ -304,9 +313,15 @@ func (l *Ledger) CreateObjectRetentionPolicy(ctx context.Context, actor domain.A if !strings.HasPrefix(in.ObjectPrefix, expectedPrefix) { return domain.ObjectRetentionPolicy{}, ErrValidation } + if in.ObjectKey != "" && (!strings.HasPrefix(in.ObjectKey, expectedPrefix) || !strings.HasPrefix(in.ObjectKey, in.ObjectPrefix)) { + return domain.ObjectRetentionPolicy{}, ErrValidation + } + if in.RequireLegalHold && in.ObjectKey == "" { + return domain.ObjectRetentionPolicy{}, ErrValidation + } l.mu.Lock() defer l.mu.Unlock() - policy := domain.ObjectRetentionPolicy{ID: newID("orp"), TenantID: actor.TenantID, Name: in.Name, ObjectPrefix: in.ObjectPrefix, Mode: in.Mode, RetentionDays: in.RetentionDays, Status: "configured", SchemaVersion: domain.ObjectRetentionPolicyVersion, CreatedAt: l.now()} + policy := domain.ObjectRetentionPolicy{ID: newID("orp"), TenantID: actor.TenantID, Name: in.Name, ObjectPrefix: in.ObjectPrefix, ObjectKey: in.ObjectKey, RequireLegalHold: in.RequireLegalHold, Mode: in.Mode, RetentionDays: in.RetentionDays, Status: "configured", SchemaVersion: domain.ObjectRetentionPolicyVersion, CreatedAt: l.now()} l.retentionPolicies[policy.ID] = policy _, _ = l.appendChainLocked(actor.TenantID, "object_retention_policy.created", "object_retention_policy", policy.ID, actorType(actor), actorID(actor), "", "") if err := l.persistLocked(ctx); err != nil { @@ -322,23 +337,154 @@ func (l *Ledger) VerifyObjectRetentionPolicy(ctx context.Context, actor domain.A if err := require(actor, ScopeVerifyRead); err != nil { return domain.ObjectRetentionPolicy{}, err } + id = strings.TrimSpace(id) l.mu.Lock() - defer l.mu.Unlock() - policy, ok := l.retentionPolicies[strings.TrimSpace(id)] + policy, ok := l.retentionPolicies[id] if !ok || policy.TenantID != actor.TenantID { + l.mu.Unlock() return domain.ObjectRetentionPolicy{}, ErrNotFound } + verifier := l.retention + l.mu.Unlock() + + retentionResult := ObjectRetentionResult{ + Provider: "local_record", + Checks: []domain.VerifyCheck{{ + Name: "object_retention_provider_verifier", + Result: "warning", + Detail: "No provider-backed object-lock verifier is configured; this records tenant-scoped retention intent only.", + }}, + Limitations: []string{"Provider-enforced object-lock, bucket versioning, and WORM settings were not checked by this ledger instance."}, + } + status := "verified" + if verifier != nil { + result, err := verifier.VerifyObjectRetention(ctx, ObjectRetentionRequest{ + TenantID: policy.TenantID, + ObjectPrefix: policy.ObjectPrefix, + ObjectKey: policy.ObjectKey, + Mode: policy.Mode, + RetentionDays: policy.RetentionDays, + RequireLegalHold: policy.RequireLegalHold, + }) + if err != nil { + return domain.ObjectRetentionPolicy{}, ErrVerificationFailed + } + retentionResult = result + if !result.Enforced { + status = "not_enforced" + } + } + now := l.now() - policy.Status = "verified" + policy.Status = status policy.VerifiedAt = &now + policy.VerificationChecks = append([]domain.VerifyCheck(nil), retentionResult.Checks...) + policy.VerificationLimitations = append([]string(nil), retentionResult.Limitations...) + verificationHash, err := canonicalAnyHash(struct { + ID string `json:"id"` + TenantID string `json:"tenant_id"` + ObjectPrefix string `json:"object_prefix"` + ObjectKey string `json:"object_key,omitempty"` + RequireLegalHold bool `json:"require_legal_hold"` + Mode string `json:"mode"` + Provider string `json:"provider"` + RetentionDays int `json:"retention_days"` + Status string `json:"status"` + VerifiedAt string `json:"verified_at"` + Checks []domain.VerifyCheck `json:"checks"` + Limitations []string `json:"limitations"` + }{ + ID: policy.ID, + TenantID: policy.TenantID, + ObjectPrefix: policy.ObjectPrefix, + ObjectKey: policy.ObjectKey, + RequireLegalHold: policy.RequireLegalHold, + Mode: policy.Mode, + Provider: retentionResult.Provider, + RetentionDays: policy.RetentionDays, + Status: policy.Status, + VerifiedAt: now.Format(time.RFC3339Nano), + Checks: policy.VerificationChecks, + Limitations: policy.VerificationLimitations, + }) + if err != nil { + return domain.ObjectRetentionPolicy{}, err + } + policy.VerificationHash = verificationHash + + l.mu.Lock() + defer l.mu.Unlock() l.retentionPolicies[policy.ID] = policy - _, _ = l.appendChainLocked(actor.TenantID, "object_retention_policy.verified", "object_retention_policy", policy.ID, actorType(actor), actorID(actor), "", "") + entryType := "object_retention_policy.verified" + if policy.Status != "verified" { + entryType = "object_retention_policy.verification_failed" + } + _, _ = l.appendChainLocked(actor.TenantID, entryType, "object_retention_policy", policy.ID, actorType(actor), actorID(actor), policy.VerificationHash, "") if err := l.persistLocked(ctx); err != nil { return domain.ObjectRetentionPolicy{}, err } return policy, nil } +func (l *Ledger) SigningCustodyReviewReport(ctx context.Context, actor domain.Actor) (domain.SigningCustodyReviewReport, error) { + if err := ctx.Err(); err != nil { + return domain.SigningCustodyReviewReport{}, err + } + if err := require(actor, ScopeKeysAdmin); err != nil { + return domain.SigningCustodyReviewReport{}, err + } + l.mu.Lock() + defer l.mu.Unlock() + providers := []domain.SigningProvider{} + hasProductionProvider := false + hasNativePKCS11 := false + for _, provider := range l.signingProviders { + if provider.TenantID != actor.TenantID { + continue + } + providers = append(providers, provider) + if provider.Type != "local_encrypted_dev" { + hasProductionProvider = true + } + if provider.Type == "native_pkcs11_hsm" { + hasNativePKCS11 = true + } + } + sort.Slice(providers, func(i, j int) bool { return providers[i].ID < providers[j].ID }) + retentionPolicies := []domain.ObjectRetentionPolicy{} + hasVerifiedRetention := false + for _, policy := range l.retentionPolicies { + if policy.TenantID != actor.TenantID { + continue + } + retentionPolicies = append(retentionPolicies, policy) + if policy.Status == "verified" && policy.VerificationHash != "" { + hasVerifiedRetention = true + } + } + sort.Slice(retentionPolicies, func(i, j int) bool { return retentionPolicies[i].ID < retentionPolicies[j].ID }) + return domain.SigningCustodyReviewReport{ + ReportType: "signing_custody_review", + TenantID: actor.TenantID, + SigningProviders: providers, + ObjectRetentionPolicies: retentionPolicies, + Checks: []domain.VerifyCheck{ + {Name: "production_signing_provider_recorded", Result: checkResultString(hasProductionProvider), Detail: "At least one non-local signing provider is recorded for the tenant."}, + {Name: "native_pkcs11_hsm_profile_recorded", Result: checkResultString(hasNativePKCS11), Detail: "A native PKCS#11/HSM custody profile is recorded when the deployment uses local HSM modules or slots."}, + {Name: "object_lock_proof_recorded", Result: checkResultString(hasVerifiedRetention), Detail: "At least one object-retention policy has provider verification hash material."}, + }, + Assumptions: []string{ + "Custody review uses Evydence records and configured provider verification receipts.", + "Native PKCS#11/HSM records describe operator-supplied module and key references; Evydence does not load HSM modules in this API process.", + }, + Limitations: []string{ + "This report does not prove legal compliance, certification, HSM hardware custody, WORM enforcement, or deployment security.", + "Operators remain responsible for HSM driver installation, slot access controls, IAM policy, object-store retention settings, and independent review.", + }, + GeneratedAt: l.now(), + }, nil +} + func (l *Ledger) GenerateBackupManifest(ctx context.Context, actor domain.Actor) (domain.BackupManifest, error) { if err := ctx.Err(); err != nil { return domain.BackupManifest{}, err @@ -435,7 +581,17 @@ func (l *Ledger) Metrics(ctx context.Context, actor domain.Actor) (map[string]an } l.mu.Lock() defer l.mu.Unlock() - return map[string]any{"tenant_id": actor.TenantID, "resource_counts": l.resourceCountsLocked(actor.TenantID)}, nil + portalFailures := 0 + portalRevoked := 0 + for _, access := range l.portalAccess { + if access.TenantID == actor.TenantID { + portalFailures += access.FailedAccessCount + if access.RevokedAt != nil { + portalRevoked++ + } + } + } + return map[string]any{"tenant_id": actor.TenantID, "resource_counts": l.resourceCountsLocked(actor.TenantID), "customer_portal_failed_access_count": portalFailures, "customer_portal_revoked_access_count": portalRevoked}, nil } func (l *Ledger) ListAuditLog(ctx context.Context, actor domain.Actor, filter AuditLogFilter) ([]domain.AuditChainEntry, error) { @@ -542,9 +698,19 @@ func merkleRoot(leaves []string) string { func validSigningProviderType(value string) bool { switch value { - case "local_encrypted_dev", "aws_kms", "gcp_kms", "azure_key_vault", "pkcs11_hsm": + case "local_encrypted_dev", "aws_kms", "gcp_kms", "azure_key_vault", "pkcs11_hsm", "native_pkcs11_hsm": return true default: return false } } + +func signingProviderRefContainsSecret(value string) bool { + lowered := strings.ToLower(value) + for _, marker := range []string{"pin-value=", "pin-source=", "password=", "secret=", "token=" + "secret"} { + if strings.Contains(lowered, marker) { + return true + } + } + return false +} diff --git a/internal/app/integrity_runtime_test.go b/internal/app/integrity_runtime_test.go index f91c5ff..0930af0 100644 --- a/internal/app/integrity_runtime_test.go +++ b/internal/app/integrity_runtime_test.go @@ -3,8 +3,11 @@ package app import ( "context" "errors" + "strings" "testing" "time" + + "github.com/aatuh/evydence/internal/domain" ) func TestCosignMerkleTransparencyAndKeyRevocationFlow(t *testing.T) { @@ -70,10 +73,19 @@ func TestRuntimeRetentionBackupReadinessMetricsAndAudit(t *testing.T) { if _, err := ledger.CreateSigningProvider(ctx, actor, CreateSigningProviderInput{Name: "dev", Type: "local_encrypted_dev", KeyRef: "file://dev.keys", Encrypted: true}); err != nil { t.Fatalf("signing provider: %v", err) } - policy, err := ledger.CreateObjectRetentionPolicy(ctx, actor, CreateObjectRetentionPolicyInput{Name: "tenant payload lock", Mode: "governance", RetentionDays: 30}) + policy, err := ledger.CreateObjectRetentionPolicy(ctx, actor, CreateObjectRetentionPolicyInput{Name: "tenant payload lock", ObjectKey: "tenants/" + actor.TenantID + "/raw/sample.json", Mode: "governance", RetentionDays: 30}) if err != nil { t.Fatalf("retention policy: %v", err) } + if policy.ObjectKey == "" { + t.Fatalf("policy missing object key: %#v", policy) + } + if _, err := ledger.CreateObjectRetentionPolicy(ctx, actor, CreateObjectRetentionPolicyInput{Name: "bad key", ObjectKey: "tenants/other/raw/sample.json", Mode: "governance", RetentionDays: 30}); !errors.Is(err, ErrValidation) { + t.Fatalf("foreign object key err=%v, want validation", err) + } + if _, err := ledger.CreateObjectRetentionPolicy(ctx, actor, CreateObjectRetentionPolicyInput{Name: "legal hold without object", RequireLegalHold: true, Mode: "governance", RetentionDays: 30}); !errors.Is(err, ErrValidation) { + t.Fatalf("legal hold without object err=%v, want validation", err) + } verifiedPolicy, err := ledger.VerifyObjectRetentionPolicy(ctx, actor, policy.ID) if err != nil { t.Fatalf("verify retention: %v", err) @@ -81,6 +93,9 @@ func TestRuntimeRetentionBackupReadinessMetricsAndAudit(t *testing.T) { if verifiedPolicy.Status != "verified" || verifiedPolicy.VerifiedAt == nil { t.Fatalf("verified policy = %#v", verifiedPolicy) } + if len(verifiedPolicy.VerificationChecks) == 0 || len(verifiedPolicy.VerificationLimitations) == 0 { + t.Fatalf("expected local retention verification limitations: %#v", verifiedPolicy) + } manifest, err := ledger.GenerateBackupManifest(ctx, actor) if err != nil { t.Fatalf("backup manifest: %v", err) @@ -119,6 +134,289 @@ func TestRuntimeRetentionBackupReadinessMetricsAndAudit(t *testing.T) { } } +func TestObjectRetentionVerifierRecordsProviderChecks(t *testing.T) { + verifier := &fakeObjectRetentionVerifier{result: ObjectRetentionResult{ + Provider: "s3", + Enforced: true, + Checks: []domain.VerifyCheck{ + {Name: "s3_bucket_versioning", Result: "passed"}, + {Name: "s3_object_lock_mode", Result: "passed"}, + }, + Limitations: []string{"Bucket-level settings checked only."}, + }} + ledger := NewLedger(Config{APIKeyPepper: "test-pepper", Now: fixedNow, Retention: verifier}) + ctx := context.Background() + _, _, secret, err := ledger.BootstrapTenant(ctx, "Tenant", "admin", []string{"*"}) + if err != nil { + t.Fatalf("bootstrap: %v", err) + } + actor, err := ledger.Authenticate(ctx, secret) + if err != nil { + t.Fatalf("auth: %v", err) + } + policy, err := ledger.CreateObjectRetentionPolicy(ctx, actor, CreateObjectRetentionPolicyInput{Name: "objects", Mode: "compliance", RetentionDays: 90}) + if err != nil { + t.Fatalf("create policy: %v", err) + } + verified, err := ledger.VerifyObjectRetentionPolicy(ctx, actor, policy.ID) + if err != nil { + t.Fatalf("verify policy: %v", err) + } + if verified.Status != "verified" || verified.VerificationHash == "" { + t.Fatalf("verified policy = %#v", verified) + } + if len(verifier.requests) != 1 || verifier.requests[0].ObjectPrefix != "tenants/"+actor.TenantID+"/" { + t.Fatalf("verifier requests = %#v", verifier.requests) + } + if len(verified.VerificationChecks) != 2 || verified.VerificationChecks[0].Name != "s3_bucket_versioning" { + t.Fatalf("checks = %#v", verified.VerificationChecks) + } +} + +func TestSigningCustodyReviewAndObjectLockProofExports(t *testing.T) { + verifier := &fakeObjectRetentionVerifier{result: ObjectRetentionResult{ + Provider: "s3", + Enforced: true, + Checks: []domain.VerifyCheck{ + {Name: "s3_bucket_versioning", Result: "passed"}, + {Name: "s3_object_lock_mode", Result: "passed"}, + }, + Limitations: []string{"Operator must review bucket IAM, lifecycle, and legal requirements."}, + }} + ledger := NewLedger(Config{APIKeyPepper: "test-pepper", Now: fixedNow, Retention: verifier}) + ctx := context.Background() + actor, release, _ := setupReleaseRiskFixture(t, ledger) + + if _, err := ledger.CreateSigningProvider(ctx, actor, CreateSigningProviderInput{Name: "bad native", Type: "native_pkcs11_hsm", KeyRef: "pkcs11:token=release;object=key;pin-value=1234", Encrypted: true}); !errors.Is(err, ErrValidation) { + t.Fatalf("native provider with embedded PIN err=%v, want validation", err) + } + if _, err := ledger.CreateSigningProvider(ctx, actor, CreateSigningProviderInput{Name: "bad native", Type: "native_pkcs11_hsm", KeyRef: "pkcs11:token=release;object=key"}); !errors.Is(err, ErrValidation) { + t.Fatalf("unencrypted native provider err=%v, want validation", err) + } + provider, err := ledger.CreateSigningProvider(ctx, actor, CreateSigningProviderInput{Name: "native hsm", Type: "native_pkcs11_hsm", KeyRef: "pkcs11:token=release;object=evydence-signing-key", Encrypted: true}) + if err != nil { + t.Fatalf("native provider: %v", err) + } + policy, err := ledger.CreateObjectRetentionPolicy(ctx, actor, CreateObjectRetentionPolicyInput{Name: "release objects", ObjectPrefix: "tenants/" + actor.TenantID + "/raw/", Mode: "compliance", RetentionDays: 180}) + if err != nil { + t.Fatalf("retention policy: %v", err) + } + verified, err := ledger.VerifyObjectRetentionPolicy(ctx, actor, policy.ID) + if err != nil { + t.Fatalf("verify retention: %v", err) + } + report, err := ledger.SigningCustodyReviewReport(ctx, actor) + if err != nil { + t.Fatalf("custody report: %v", err) + } + if report.ReportType != "signing_custody_review" || len(report.SigningProviders) != 1 || report.SigningProviders[0].ID != provider.ID { + t.Fatalf("custody report providers = %#v", report) + } + if len(report.ObjectRetentionPolicies) != 1 || report.ObjectRetentionPolicies[0].VerificationHash != verified.VerificationHash { + t.Fatalf("custody report retention policies = %#v", report.ObjectRetentionPolicies) + } + for _, want := range []string{"production_signing_provider_recorded", "native_pkcs11_hsm_profile_recorded", "object_lock_proof_recorded"} { + if !hasVerifyCheck(report.Checks, want, "passed") { + t.Fatalf("custody report missing passed check %q: %#v", want, report.Checks) + } + } + if !strings.Contains(strings.Join(report.Limitations, "\n"), "does not prove legal compliance") || strings.Contains(strings.Join(report.Limitations, "\n"), "certified secure") { + t.Fatalf("unsafe custody limitations: %#v", report.Limitations) + } + + profile, err := ledger.CreateRedactionProfile(ctx, actor, CreateRedactionProfileInput{Name: "object lock proof", AllowedTypes: []string{"object_lock_proof"}}) + if err != nil { + t.Fatalf("profile: %v", err) + } + pkg, err := ledger.CreateCustomerSecurityPackage(ctx, actor, CreateCustomerPackageInput{ProductID: release.ProductID, ReleaseID: release.ID, RedactionProfileID: profile.ID, Title: "Object-lock proof package", ExpiresAt: fixedNow().Add(time.Hour)}) + if err != nil { + t.Fatalf("package: %v", err) + } + proofs, ok := pkg.Manifest["object_lock_proofs"].([]map[string]any) + if !ok || len(proofs) != 1 || proofs[0]["verification_hash"] != verified.VerificationHash { + t.Fatalf("package object-lock proofs = %#v", pkg.Manifest["object_lock_proofs"]) + } + if _, ok := proofs[0]["object_key"]; ok { + t.Fatalf("customer package object-lock proof exposed object key: %#v", proofs[0]) + } + limitations, _ := proofs[0]["limitations"].([]string) + if strings.Contains(strings.Join(limitations, "\n"), "secure release") { + t.Fatalf("object-lock proof made unsafe claim: %#v", proofs[0]) + } + + bundle, err := ledger.CreateReleaseBundle(ctx, actor, release.ID) + if err != nil { + t.Fatalf("release bundle: %v", err) + } + if proofs, ok := bundle.Manifest["object_lock_proofs"].([]map[string]any); !ok || len(proofs) != 1 || proofs[0]["verification_hash"] != verified.VerificationHash { + t.Fatalf("release bundle object-lock proofs = %#v", bundle.Manifest["object_lock_proofs"]) + } + evidenceBundle, err := ledger.ExportEvidenceBundle(ctx, actor, release.ID, nil) + if err != nil { + t.Fatalf("evidence bundle: %v", err) + } + if proofs, ok := evidenceBundle.Manifest["object_lock_proofs"].([]map[string]any); !ok || len(proofs) != 1 || proofs[0]["verification_hash"] != verified.VerificationHash { + t.Fatalf("evidence bundle object-lock proofs = %#v", evidenceBundle.Manifest["object_lock_proofs"]) + } +} + +func TestObjectRetentionVerifierReceivesLegalHoldRequirement(t *testing.T) { + verifier := &fakeObjectRetentionVerifier{result: ObjectRetentionResult{ + Provider: "s3", + Enforced: true, + Checks: []domain.VerifyCheck{{Name: "s3_object_legal_hold", Result: "passed"}}, + Limitations: []string{"Sample object legal hold checked only."}, + }} + ledger := NewLedger(Config{APIKeyPepper: "test-pepper", Now: fixedNow, Retention: verifier}) + ctx := context.Background() + _, _, secret, err := ledger.BootstrapTenant(ctx, "Tenant", "admin", []string{"*"}) + if err != nil { + t.Fatalf("bootstrap: %v", err) + } + actor, err := ledger.Authenticate(ctx, secret) + if err != nil { + t.Fatalf("auth: %v", err) + } + objectKey := "tenants/" + actor.TenantID + "/raw/sample.json" + policy, err := ledger.CreateObjectRetentionPolicy(ctx, actor, CreateObjectRetentionPolicyInput{Name: "objects", ObjectPrefix: "tenants/" + actor.TenantID + "/raw/", ObjectKey: objectKey, RequireLegalHold: true, Mode: "compliance", RetentionDays: 90}) + if err != nil { + t.Fatalf("create policy: %v", err) + } + verified, err := ledger.VerifyObjectRetentionPolicy(ctx, actor, policy.ID) + if err != nil { + t.Fatalf("verify policy: %v", err) + } + if !verified.RequireLegalHold || len(verifier.requests) != 1 || !verifier.requests[0].RequireLegalHold || verifier.requests[0].ObjectKey != objectKey { + t.Fatalf("verified=%#v requests=%#v", verified, verifier.requests) + } +} + +func TestObjectRetentionVerifierMarksProviderFailure(t *testing.T) { + ledger := NewLedger(Config{APIKeyPepper: "test-pepper", Now: fixedNow, Retention: &fakeObjectRetentionVerifier{result: ObjectRetentionResult{ + Provider: "s3", + Enforced: false, + Checks: []domain.VerifyCheck{{Name: "s3_object_lock_retention", Result: "failed"}}, + Limitations: []string{"Bucket default retention is shorter than requested."}, + }}}) + ctx := context.Background() + _, _, secret, err := ledger.BootstrapTenant(ctx, "Tenant", "admin", []string{"*"}) + if err != nil { + t.Fatalf("bootstrap: %v", err) + } + actor, err := ledger.Authenticate(ctx, secret) + if err != nil { + t.Fatalf("auth: %v", err) + } + policy, err := ledger.CreateObjectRetentionPolicy(ctx, actor, CreateObjectRetentionPolicyInput{Name: "objects", Mode: "governance", RetentionDays: 365}) + if err != nil { + t.Fatalf("create policy: %v", err) + } + verified, err := ledger.VerifyObjectRetentionPolicy(ctx, actor, policy.ID) + if err != nil { + t.Fatalf("verify policy: %v", err) + } + if verified.Status != "not_enforced" || verified.VerificationChecks[0].Result != "failed" { + t.Fatalf("verified policy = %#v", verified) + } +} + +type fakeObjectRetentionVerifier struct { + result ObjectRetentionResult + err error + requests []ObjectRetentionRequest +} + +func (f *fakeObjectRetentionVerifier) VerifyObjectRetention(_ context.Context, req ObjectRetentionRequest) (ObjectRetentionResult, error) { + f.requests = append(f.requests, req) + if f.err != nil { + return ObjectRetentionResult{}, f.err + } + return f.result, nil +} + +func TestBackupRestoreRehearsalPreservesLedgerAndObjectPayloads(t *testing.T) { + ctx := context.Background() + store := NewMemoryStore() + objects := newTestObjectStore() + ledger := NewLedger(Config{APIKeyPepper: "test-pepper", Now: fixedNow, Store: store, ObjectStore: objects}) + _, _, secret, err := ledger.BootstrapTenant(ctx, "Tenant", "admin", []string{"*"}) + if err != nil { + t.Fatalf("bootstrap: %v", err) + } + actor, err := ledger.Authenticate(ctx, secret) + if err != nil { + t.Fatalf("auth: %v", err) + } + product, err := ledger.CreateProduct(ctx, actor, "Payments API", "payments") + if err != nil { + t.Fatalf("product: %v", err) + } + release, err := ledger.CreateRelease(ctx, actor, product.ID, "1.0.0") + if err != nil { + t.Fatalf("release: %v", err) + } + artifact, err := ledger.RegisterArtifact(ctx, actor, "payments-api.tar.gz", "application/gzip", sampleDigest("artifact"), 123) + if err != nil { + t.Fatalf("artifact: %v", err) + } + sbom, err := ledger.UploadSBOM(ctx, actor, release.ID, artifact.ID, []byte(`{"bomFormat":"CycloneDX","specVersion":"1.6","components":[{"name":"api","purl":"pkg:oci/api"}]}`)) + if err != nil { + t.Fatalf("upload sbom: %v", err) + } + bundle, err := ledger.CreateReleaseBundle(ctx, actor, release.ID) + if err != nil { + t.Fatalf("release bundle: %v", err) + } + manifest, err := ledger.GenerateBackupManifest(ctx, actor) + if err != nil { + t.Fatalf("backup manifest: %v", err) + } + if manifest.StateHash == "" || manifest.ResourceCounts["evidence"] == 0 { + t.Fatalf("manifest missing restore-relevant counts: %#v", manifest) + } + + dbBackup, ok, err := store.LoadState(ctx) + if err != nil || !ok { + t.Fatalf("load backed-up state ok=%v err=%v", ok, err) + } + objectBackup := map[string]Object{} + for key, object := range objects.objects { + objectBackup[key] = object + } + restoredStore := NewMemoryStore() + if err := restoredStore.SaveState(ctx, dbBackup); err != nil { + t.Fatalf("restore state: %v", err) + } + restoredObjects := &testObjectStore{objects: objectBackup} + restored := NewLedger(Config{APIKeyPepper: "test-pepper", Now: fixedNow, Store: restoredStore, ObjectStore: restoredObjects}) + + restoredActor, err := restored.Authenticate(ctx, secret) + if err != nil { + t.Fatalf("authenticate restored api key: %v", err) + } + if _, err := restored.VerifyBackupManifest(ctx, restoredActor, manifest.ID); err != nil { + t.Fatalf("verify restored backup manifest: %v", err) + } + restoredSBOM, err := restored.GetSBOM(ctx, restoredActor, sbom.ID) + if err != nil || restoredSBOM.ComponentCount != sbom.ComponentCount { + t.Fatalf("restored sbom = %#v err=%v", restoredSBOM, err) + } + evidence, err := restored.GetEvidence(ctx, restoredActor, restoredSBOM.EvidenceID) + if err != nil { + t.Fatalf("restored evidence: %v", err) + } + payloadKey := strings.TrimPrefix(evidence.PayloadRef, "object://") + if payloadKey == "" { + t.Fatalf("restored evidence missing payload ref: %#v", evidence) + } + if object, err := restoredObjects.Get(ctx, payloadKey); err != nil || object.Digest != evidence.PayloadHash { + t.Fatalf("restored object digest=%q err=%v want %q", object.Digest, err, evidence.PayloadHash) + } + if vr, err := restored.VerifySubject(ctx, restoredActor, "release_bundle", bundle.ID); err != nil || vr.Result != "passed" { + t.Fatalf("verify restored bundle = %#v err=%v", vr, err) + } +} + func TestSigningProviderRejectsPlaintextLocalDev(t *testing.T) { ledger := NewLedger(Config{APIKeyPepper: "test-pepper", Now: fixedNow}) ctx := context.Background() diff --git a/internal/app/ledger.go b/internal/app/ledger.go index 949f3ff..58aa02d 100644 --- a/internal/app/ledger.go +++ b/internal/app/ledger.go @@ -57,102 +57,132 @@ const ( ScopeReportRead = "report:read" ScopeIdentityAdmin = "identity:admin" ScopeInstanceAdmin = "instance:admin" - ScopeCustomerPortal = "customer:portal" ) +const customerPortalFailedAccessLimit = 5 + type Config struct { APIKeyPepper string Now func() time.Time Store Store ObjectStore ObjectStore + Retention ObjectRetentionVerifier + Signer SigningExecutor + OIDC OIDCDiscoveryClient + ProviderAPI ProviderIdentityValidator + Transparency TransparencyProofFetcher Outbox Outbox + // WorkerOwnedParserSideEffects stores accepted parser records first and + // lets outbox workers populate parser-derived fields from raw payloads. + WorkerOwnedParserSideEffects bool } type Ledger struct { mu sync.Mutex - pepper []byte - now func() time.Time - store Store - objects ObjectStore - outbox Outbox - - tenants map[string]domain.Tenant - organizations map[string]domain.Organization - users map[string]domain.HumanUser - roleBindings map[string]domain.RoleBinding - ssoProviders map[string]domain.SSOProvider - identityLinks map[string]domain.UserIdentityLink - ssoSessions map[string]domain.SSOSession - apiKeys map[string]domain.APIKey - collectors map[string]domain.Collector - collectorReleases map[string]domain.CollectorRelease - products map[string]domain.Product - projects map[string]domain.Project - releases map[string]domain.Release - artifacts map[string]domain.Artifact - buildRuns map[string]domain.BuildRun - attestations map[string]domain.BuildAttestation - evidence map[string]domain.EvidenceItem - lifecycle map[string]domain.EvidenceLifecycleEvent - candidates map[string]domain.ReleaseCandidate - images map[string]domain.ContainerImage - artifactSigs map[string]domain.ArtifactSignature - repositories map[string]domain.SourceRepository - commits map[string]domain.SourceCommit - branches map[string]domain.SourceBranch - pullRequests map[string]domain.PullRequest - environments map[string]domain.DeploymentEnvironment - deployments map[string]domain.DeploymentEvent - incidents map[string]domain.Incident - timeline map[string]domain.IncidentTimelineEvent - tasks map[string]domain.RemediationTask - securityScans map[string]domain.SecurityScan - manualDocs map[string]domain.ManualSecurityDocument - sbomDiffs map[string]domain.SBOMDiff - depChanges map[string]domain.DependencyChange - vulnWorkflow map[string]domain.VulnerabilityWorkflowRecord - contractDiffs map[string]domain.ContractDiff - customPolicies map[string]domain.CustomPolicy - customPolicyEvals map[string]domain.CustomPolicyEvaluation - waivers map[string]domain.Waiver - approvals map[string]domain.ApprovalRecord - redactions map[string]domain.RedactionProfile - customerPackages map[string]domain.CustomerSecurityPackage - htmlReports map[string]domain.HTMLReportPackage - reportTemplates map[string]domain.CustomReportTemplate - renderedReports map[string]domain.RenderedCustomReport - evidenceBundles map[string]domain.EvidenceBundle - bundleImports map[string]domain.EvidenceBundleImport - dsseTrustRoots map[string]domain.DSSETrustRoot - cosignVerifs map[string]domain.CosignVerification - signingProviders map[string]domain.SigningProvider - merkleBatches map[string]domain.MerkleBatch - transparency map[string]domain.TransparencyCheckpoint - retentionPolicies map[string]domain.ObjectRetentionPolicy - backupManifests map[string]domain.BackupManifest - legalHolds map[string]domain.LegalHold - retentionOverrides map[string]domain.RetentionOverride - portalAccess map[string]domain.CustomerPortalAccess - questionTemplates map[string]domain.QuestionnaireTemplate - questionPackages map[string]domain.QuestionnairePackage - commercialCollectors map[string]domain.CommercialCollectorDefinition - frameworks map[string]domain.ControlFramework - controls map[string]domain.SecurityControl - controlLinks map[string]domain.ControlEvidence - sboms map[string]domain.SBOM - scans map[string]domain.VulnerabilityScan - vexDocuments map[string]domain.VEXDocument - decisions map[string]domain.VulnerabilityDecision - contracts map[string]domain.OpenAPIContract - policies map[string]domain.PolicyEvaluation - exceptions map[string]domain.Exception - bundles map[string]domain.ReleaseBundle - signingKeys map[string]domain.SigningKey - signatures map[string]domain.Signature - verifications map[string]domain.VerificationResult - chain map[string][]domain.AuditChainEntry - idempotency map[string]IdempotencyRecord + pepper []byte + now func() time.Time + store Store + objects ObjectStore + retention ObjectRetentionVerifier + signer SigningExecutor + oidc OIDCDiscoveryClient + providerAPI ProviderIdentityValidator + transparencyProofs TransparencyProofFetcher + outbox Outbox + workerOwnedParsers bool + + tenants map[string]domain.Tenant + organizations map[string]domain.Organization + users map[string]domain.HumanUser + roleBindings map[string]domain.RoleBinding + ssoProviders map[string]domain.SSOProvider + identityLinks map[string]domain.UserIdentityLink + ssoSessions map[string]domain.SSOSession + apiKeys map[string]domain.APIKey + collectors map[string]domain.Collector + collectorReleases map[string]domain.CollectorRelease + products map[string]domain.Product + projects map[string]domain.Project + releases map[string]domain.Release + artifacts map[string]domain.Artifact + buildRuns map[string]domain.BuildRun + attestations map[string]domain.BuildAttestation + evidence map[string]domain.EvidenceItem + lifecycle map[string]domain.EvidenceLifecycleEvent + candidates map[string]domain.ReleaseCandidate + images map[string]domain.ContainerImage + artifactSigs map[string]domain.ArtifactSignature + repositories map[string]domain.SourceRepository + commits map[string]domain.SourceCommit + branches map[string]domain.SourceBranch + pullRequests map[string]domain.PullRequest + environments map[string]domain.DeploymentEnvironment + deployments map[string]domain.DeploymentEvent + incidents map[string]domain.Incident + timeline map[string]domain.IncidentTimelineEvent + webhookReceivers map[string]domain.IncidentWebhookReceiver + webhookEvents map[string]domain.IncidentWebhookEvent + tasks map[string]domain.RemediationTask + securityScans map[string]domain.SecurityScan + manualDocs map[string]domain.ManualSecurityDocument + sbomDiffs map[string]domain.SBOMDiff + depChanges map[string]domain.DependencyChange + vulnWorkflow map[string]domain.VulnerabilityWorkflowRecord + contractDiffs map[string]domain.ContractDiff + customPolicies map[string]domain.CustomPolicy + customPolicyEvals map[string]domain.CustomPolicyEvaluation + waivers map[string]domain.Waiver + approvals map[string]domain.ApprovalRecord + redactions map[string]domain.RedactionProfile + customerPackages map[string]domain.CustomerSecurityPackage + htmlReports map[string]domain.HTMLReportPackage + reportTemplates map[string]domain.CustomReportTemplate + renderedReports map[string]domain.RenderedCustomReport + evidenceBundles map[string]domain.EvidenceBundle + bundleImports map[string]domain.EvidenceBundleImport + dsseTrustRoots map[string]domain.DSSETrustRoot + cosignVerifs map[string]domain.CosignVerification + signingProviders map[string]domain.SigningProvider + merkleBatches map[string]domain.MerkleBatch + transparency map[string]domain.TransparencyCheckpoint + retentionPolicies map[string]domain.ObjectRetentionPolicy + backupManifests map[string]domain.BackupManifest + legalHolds map[string]domain.LegalHold + retentionOverrides map[string]domain.RetentionOverride + portalAccess map[string]domain.CustomerPortalAccess + questionTemplates map[string]domain.QuestionnaireTemplate + questionPackages map[string]domain.QuestionnairePackage + answerLibrary map[string]domain.QuestionnaireAnswerLibraryEntry + commercialCollectors map[string]domain.CommercialCollectorDefinition + evidenceSummaries map[string]domain.EvidenceSummary + questionDrafts map[string]domain.QuestionnaireDraft + graphSnapshots map[string]domain.EvidenceGraphSnapshot + saasProfiles map[string]domain.SaaSEditionProfile + publicLogs map[string]domain.PublicTransparencyLog + publicLogEntries map[string]domain.PublicTransparencyLogEntry + marketplaceCollectors map[string]domain.MarketplaceCollector + pdfReports map[string]domain.PDFReportPackage + anomalyReports map[string]domain.AnomalyReport + providerVerifications map[string]domain.ProviderVerification + signingOperations map[string]domain.SigningOperation + frameworks map[string]domain.ControlFramework + controls map[string]domain.SecurityControl + controlLinks map[string]domain.ControlEvidence + sboms map[string]domain.SBOM + scans map[string]domain.VulnerabilityScan + vexDocuments map[string]domain.VEXDocument + vexImportReports map[string]domain.VEXImportReport + decisions map[string]domain.VulnerabilityDecision + contracts map[string]domain.OpenAPIContract + policies map[string]domain.PolicyEvaluation + exceptions map[string]domain.Exception + bundles map[string]domain.ReleaseBundle + signingKeys map[string]domain.SigningKey + signatures map[string]domain.Signature + verifications map[string]domain.VerificationResult + chain map[string][]domain.AuditChainEntry + idempotency map[string]IdempotencyRecord } func NewLedger(cfg Config) *Ledger { @@ -172,88 +202,115 @@ func NewLedgerWithError(cfg Config) (*Ledger, error) { if pepper == "" { pepper = "local-dev-pepper-change-me" } + retention := cfg.Retention + if retention == nil && cfg.ObjectStore != nil { + if verifier, ok := cfg.ObjectStore.(ObjectRetentionVerifier); ok { + retention = verifier + } + } ledger := &Ledger{ - pepper: []byte(pepper), - now: now, - store: cfg.Store, - objects: cfg.ObjectStore, - outbox: cfg.Outbox, - tenants: map[string]domain.Tenant{}, - organizations: map[string]domain.Organization{}, - users: map[string]domain.HumanUser{}, - roleBindings: map[string]domain.RoleBinding{}, - ssoProviders: map[string]domain.SSOProvider{}, - identityLinks: map[string]domain.UserIdentityLink{}, - ssoSessions: map[string]domain.SSOSession{}, - apiKeys: map[string]domain.APIKey{}, - collectors: map[string]domain.Collector{}, - collectorReleases: map[string]domain.CollectorRelease{}, - products: map[string]domain.Product{}, - projects: map[string]domain.Project{}, - releases: map[string]domain.Release{}, - artifacts: map[string]domain.Artifact{}, - buildRuns: map[string]domain.BuildRun{}, - attestations: map[string]domain.BuildAttestation{}, - evidence: map[string]domain.EvidenceItem{}, - lifecycle: map[string]domain.EvidenceLifecycleEvent{}, - candidates: map[string]domain.ReleaseCandidate{}, - images: map[string]domain.ContainerImage{}, - artifactSigs: map[string]domain.ArtifactSignature{}, - repositories: map[string]domain.SourceRepository{}, - commits: map[string]domain.SourceCommit{}, - branches: map[string]domain.SourceBranch{}, - pullRequests: map[string]domain.PullRequest{}, - environments: map[string]domain.DeploymentEnvironment{}, - deployments: map[string]domain.DeploymentEvent{}, - incidents: map[string]domain.Incident{}, - timeline: map[string]domain.IncidentTimelineEvent{}, - tasks: map[string]domain.RemediationTask{}, - securityScans: map[string]domain.SecurityScan{}, - manualDocs: map[string]domain.ManualSecurityDocument{}, - sbomDiffs: map[string]domain.SBOMDiff{}, - depChanges: map[string]domain.DependencyChange{}, - vulnWorkflow: map[string]domain.VulnerabilityWorkflowRecord{}, - contractDiffs: map[string]domain.ContractDiff{}, - customPolicies: map[string]domain.CustomPolicy{}, - customPolicyEvals: map[string]domain.CustomPolicyEvaluation{}, - waivers: map[string]domain.Waiver{}, - approvals: map[string]domain.ApprovalRecord{}, - redactions: map[string]domain.RedactionProfile{}, - customerPackages: map[string]domain.CustomerSecurityPackage{}, - htmlReports: map[string]domain.HTMLReportPackage{}, - reportTemplates: map[string]domain.CustomReportTemplate{}, - renderedReports: map[string]domain.RenderedCustomReport{}, - evidenceBundles: map[string]domain.EvidenceBundle{}, - bundleImports: map[string]domain.EvidenceBundleImport{}, - dsseTrustRoots: map[string]domain.DSSETrustRoot{}, - cosignVerifs: map[string]domain.CosignVerification{}, - signingProviders: map[string]domain.SigningProvider{}, - merkleBatches: map[string]domain.MerkleBatch{}, - transparency: map[string]domain.TransparencyCheckpoint{}, - retentionPolicies: map[string]domain.ObjectRetentionPolicy{}, - backupManifests: map[string]domain.BackupManifest{}, - legalHolds: map[string]domain.LegalHold{}, - retentionOverrides: map[string]domain.RetentionOverride{}, - portalAccess: map[string]domain.CustomerPortalAccess{}, - questionTemplates: map[string]domain.QuestionnaireTemplate{}, - questionPackages: map[string]domain.QuestionnairePackage{}, - commercialCollectors: map[string]domain.CommercialCollectorDefinition{}, - frameworks: map[string]domain.ControlFramework{}, - controls: map[string]domain.SecurityControl{}, - controlLinks: map[string]domain.ControlEvidence{}, - sboms: map[string]domain.SBOM{}, - scans: map[string]domain.VulnerabilityScan{}, - vexDocuments: map[string]domain.VEXDocument{}, - decisions: map[string]domain.VulnerabilityDecision{}, - contracts: map[string]domain.OpenAPIContract{}, - policies: map[string]domain.PolicyEvaluation{}, - exceptions: map[string]domain.Exception{}, - bundles: map[string]domain.ReleaseBundle{}, - signingKeys: map[string]domain.SigningKey{}, - signatures: map[string]domain.Signature{}, - verifications: map[string]domain.VerificationResult{}, - chain: map[string][]domain.AuditChainEntry{}, - idempotency: map[string]IdempotencyRecord{}, + pepper: []byte(pepper), + now: now, + store: cfg.Store, + objects: cfg.ObjectStore, + retention: retention, + signer: cfg.Signer, + oidc: cfg.OIDC, + providerAPI: cfg.ProviderAPI, + transparencyProofs: cfg.Transparency, + outbox: cfg.Outbox, + workerOwnedParsers: cfg.WorkerOwnedParserSideEffects, + tenants: map[string]domain.Tenant{}, + organizations: map[string]domain.Organization{}, + users: map[string]domain.HumanUser{}, + roleBindings: map[string]domain.RoleBinding{}, + ssoProviders: map[string]domain.SSOProvider{}, + identityLinks: map[string]domain.UserIdentityLink{}, + ssoSessions: map[string]domain.SSOSession{}, + apiKeys: map[string]domain.APIKey{}, + collectors: map[string]domain.Collector{}, + collectorReleases: map[string]domain.CollectorRelease{}, + products: map[string]domain.Product{}, + projects: map[string]domain.Project{}, + releases: map[string]domain.Release{}, + artifacts: map[string]domain.Artifact{}, + buildRuns: map[string]domain.BuildRun{}, + attestations: map[string]domain.BuildAttestation{}, + evidence: map[string]domain.EvidenceItem{}, + lifecycle: map[string]domain.EvidenceLifecycleEvent{}, + candidates: map[string]domain.ReleaseCandidate{}, + images: map[string]domain.ContainerImage{}, + artifactSigs: map[string]domain.ArtifactSignature{}, + repositories: map[string]domain.SourceRepository{}, + commits: map[string]domain.SourceCommit{}, + branches: map[string]domain.SourceBranch{}, + pullRequests: map[string]domain.PullRequest{}, + environments: map[string]domain.DeploymentEnvironment{}, + deployments: map[string]domain.DeploymentEvent{}, + incidents: map[string]domain.Incident{}, + timeline: map[string]domain.IncidentTimelineEvent{}, + webhookReceivers: map[string]domain.IncidentWebhookReceiver{}, + webhookEvents: map[string]domain.IncidentWebhookEvent{}, + tasks: map[string]domain.RemediationTask{}, + securityScans: map[string]domain.SecurityScan{}, + manualDocs: map[string]domain.ManualSecurityDocument{}, + sbomDiffs: map[string]domain.SBOMDiff{}, + depChanges: map[string]domain.DependencyChange{}, + vulnWorkflow: map[string]domain.VulnerabilityWorkflowRecord{}, + contractDiffs: map[string]domain.ContractDiff{}, + customPolicies: map[string]domain.CustomPolicy{}, + customPolicyEvals: map[string]domain.CustomPolicyEvaluation{}, + waivers: map[string]domain.Waiver{}, + approvals: map[string]domain.ApprovalRecord{}, + redactions: map[string]domain.RedactionProfile{}, + customerPackages: map[string]domain.CustomerSecurityPackage{}, + htmlReports: map[string]domain.HTMLReportPackage{}, + reportTemplates: map[string]domain.CustomReportTemplate{}, + renderedReports: map[string]domain.RenderedCustomReport{}, + evidenceBundles: map[string]domain.EvidenceBundle{}, + bundleImports: map[string]domain.EvidenceBundleImport{}, + dsseTrustRoots: map[string]domain.DSSETrustRoot{}, + cosignVerifs: map[string]domain.CosignVerification{}, + signingProviders: map[string]domain.SigningProvider{}, + merkleBatches: map[string]domain.MerkleBatch{}, + transparency: map[string]domain.TransparencyCheckpoint{}, + retentionPolicies: map[string]domain.ObjectRetentionPolicy{}, + backupManifests: map[string]domain.BackupManifest{}, + legalHolds: map[string]domain.LegalHold{}, + retentionOverrides: map[string]domain.RetentionOverride{}, + portalAccess: map[string]domain.CustomerPortalAccess{}, + questionTemplates: map[string]domain.QuestionnaireTemplate{}, + questionPackages: map[string]domain.QuestionnairePackage{}, + answerLibrary: map[string]domain.QuestionnaireAnswerLibraryEntry{}, + commercialCollectors: map[string]domain.CommercialCollectorDefinition{}, + evidenceSummaries: map[string]domain.EvidenceSummary{}, + questionDrafts: map[string]domain.QuestionnaireDraft{}, + graphSnapshots: map[string]domain.EvidenceGraphSnapshot{}, + saasProfiles: map[string]domain.SaaSEditionProfile{}, + publicLogs: map[string]domain.PublicTransparencyLog{}, + publicLogEntries: map[string]domain.PublicTransparencyLogEntry{}, + marketplaceCollectors: map[string]domain.MarketplaceCollector{}, + pdfReports: map[string]domain.PDFReportPackage{}, + anomalyReports: map[string]domain.AnomalyReport{}, + providerVerifications: map[string]domain.ProviderVerification{}, + signingOperations: map[string]domain.SigningOperation{}, + frameworks: map[string]domain.ControlFramework{}, + controls: map[string]domain.SecurityControl{}, + controlLinks: map[string]domain.ControlEvidence{}, + sboms: map[string]domain.SBOM{}, + scans: map[string]domain.VulnerabilityScan{}, + vexDocuments: map[string]domain.VEXDocument{}, + vexImportReports: map[string]domain.VEXImportReport{}, + decisions: map[string]domain.VulnerabilityDecision{}, + contracts: map[string]domain.OpenAPIContract{}, + policies: map[string]domain.PolicyEvaluation{}, + exceptions: map[string]domain.Exception{}, + bundles: map[string]domain.ReleaseBundle{}, + signingKeys: map[string]domain.SigningKey{}, + signatures: map[string]domain.Signature{}, + verifications: map[string]domain.VerificationResult{}, + chain: map[string][]domain.AuditChainEntry{}, + idempotency: map[string]IdempotencyRecord{}, } if ledger.outbox == nil { ledger.outbox = nopOutbox{} @@ -271,136 +328,27 @@ func NewLedgerWithError(cfg Config) (*Ledger, error) { } func (l *Ledger) HasTenants() bool { - l.mu.Lock() - defer l.mu.Unlock() - return len(l.tenants) > 0 + return l.identityService().HasTenants() } func (l *Ledger) BootstrapTenant(ctx context.Context, name, keyName string, scopes []string) (domain.Tenant, domain.APIKey, string, error) { - if err := ctx.Err(); err != nil { - return domain.Tenant{}, domain.APIKey{}, "", err - } - name = strings.TrimSpace(name) - keyName = strings.TrimSpace(keyName) - if name == "" || keyName == "" { - return domain.Tenant{}, domain.APIKey{}, "", ErrValidation - } - if len(scopes) == 0 { - scopes = []string{"*"} - } - l.mu.Lock() - defer l.mu.Unlock() - now := l.now() - tenant := domain.Tenant{ID: newID("ten"), Name: name, CreatedAt: now} - l.tenants[tenant.ID] = tenant - key, secret, err := l.createAPIKeyLocked(tenant.ID, keyName, scopes, nil) - if err != nil { - return domain.Tenant{}, domain.APIKey{}, "", err - } - if _, err := l.rotateSigningKeyLocked(tenant.ID, "bootstrap"); err != nil { - return domain.Tenant{}, domain.APIKey{}, "", err - } - _, _ = l.appendChainLocked(tenant.ID, "tenant.created", "tenant", tenant.ID, "system", "bootstrap", "", "") - if err := l.persistLocked(ctx); err != nil { - return domain.Tenant{}, domain.APIKey{}, "", err - } - return tenant, key, secret, nil + return l.identityService().BootstrapTenant(ctx, name, keyName, scopes) } func (l *Ledger) Authenticate(ctx context.Context, secret string) (domain.Actor, error) { - if err := ctx.Err(); err != nil { - return domain.Actor{}, err - } - secret = strings.TrimSpace(strings.TrimPrefix(secret, "Bearer ")) - if secret == "" { - return domain.Actor{}, ErrUnauthorized - } - prefix := secretPrefix(secret) - hash := l.hashSecret(secret) - l.mu.Lock() - defer l.mu.Unlock() - for id, key := range l.apiKeys { - if key.Prefix != prefix || key.Hash != hash || key.RevokedAt != nil { - continue - } - if key.ExpiresAt != nil && !key.ExpiresAt.After(l.now()) { - return domain.Actor{}, ErrUnauthorized - } - now := l.now() - key.LastUsedAt = &now - l.apiKeys[id] = key - collectorID := "" - for collectorMapID, collector := range l.collectors { - if collector.TenantID == key.TenantID && collector.APIKeyID == key.ID { - collectorID = collector.ID - collector.LastSeenAt = &now - l.collectors[collectorMapID] = collector - break - } - } - _ = l.persistLocked(ctx) - return domain.Actor{TenantID: key.TenantID, KeyID: key.ID, Name: key.Name, Scopes: append([]string(nil), key.Scopes...), CollectorID: collectorID}, nil - } - for id, session := range l.ssoSessions { - if session.Prefix != prefix || session.Hash != hash || session.RevokedAt != nil || !session.ExpiresAt.After(l.now()) { - continue - } - user, ok := l.users[session.UserID] - if !ok || user.TenantID != session.TenantID || user.Status != "active" { - return domain.Actor{}, ErrUnauthorized - } - scopes := l.scopesForUserLocked(user.ID) - if len(scopes) == 0 { - return domain.Actor{}, ErrForbidden - } - l.ssoSessions[id] = session - _ = l.persistLocked(ctx) - return domain.Actor{TenantID: user.TenantID, UserID: user.ID, Name: user.Email, Scopes: scopes}, nil - } - return domain.Actor{}, ErrUnauthorized + return l.identityService().Authenticate(ctx, secret) } func (l *Ledger) CreateAPIKey(ctx context.Context, actor domain.Actor, name string, scopes []string, expiresAt *time.Time) (domain.APIKey, string, error) { - if err := require(actor, ScopeAdmin); err != nil { - return domain.APIKey{}, "", err - } - if strings.TrimSpace(name) == "" || len(scopes) == 0 { - return domain.APIKey{}, "", ErrValidation - } - l.mu.Lock() - defer l.mu.Unlock() - key, secret, err := l.createAPIKeyLocked(actor.TenantID, name, scopes, expiresAt) - if err != nil { - return domain.APIKey{}, "", err - } - _, _ = l.appendChainLocked(actor.TenantID, "api_key.created", "api_key", key.ID, "api_key", actor.KeyID, "", "") - if err := l.persistLocked(ctx); err != nil { - return domain.APIKey{}, "", err - } - return key, secret, nil + return l.identityService().CreateAPIKey(ctx, actor, name, scopes, expiresAt) } func (l *Ledger) ListAPIKeys(ctx context.Context, actor domain.Actor) ([]domain.APIKey, error) { - if err := ctx.Err(); err != nil { - return nil, err - } - if err := require(actor, ScopeAdmin); err != nil { - return nil, err - } - l.mu.Lock() - defer l.mu.Unlock() - out := []domain.APIKey{} - for _, key := range l.apiKeys { - if key.TenantID == actor.TenantID { - key.Hash = "" - out = append(out, key) - } - } - sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) - return out, nil + return l.identityService().ListAPIKeys(ctx, actor) } -func (l *Ledger) CreateProduct(ctx context.Context, actor domain.Actor, name, slug string) (domain.Product, error) { +func (s releaseEvidenceService) CreateProduct(ctx context.Context, actor domain.Actor, name, slug string) (domain.Product, error) { + l := s.ledger if err := ctx.Err(); err != nil { return domain.Product{}, err } @@ -413,6 +361,9 @@ func (l *Ledger) CreateProduct(ctx context.Context, actor domain.Actor, name, sl } l.mu.Lock() defer l.mu.Unlock() + if err := l.authorizeResourceLocked(actor, ScopeProductWrite, resourceRefs{}); err != nil { + return domain.Product{}, err + } for _, existing := range l.products { if existing.TenantID == actor.TenantID && existing.Slug == slug { return domain.Product{}, ErrConflict @@ -421,13 +372,14 @@ func (l *Ledger) CreateProduct(ctx context.Context, actor domain.Actor, name, sl product := domain.Product{ID: newID("prod"), TenantID: actor.TenantID, Name: name, Slug: slug, CreatedAt: l.now()} l.products[product.ID] = product _, _ = l.appendChainLocked(actor.TenantID, "product.created", "product", product.ID, "api_key", actor.KeyID, "", "") - if err := l.persistLocked(ctx); err != nil { + if err := l.persistReleaseLedgerLocked(ctx, l.releaseLedgerMutationLocked()); err != nil { return domain.Product{}, err } return product, nil } -func (l *Ledger) ListProducts(ctx context.Context, actor domain.Actor) ([]domain.Product, error) { +func (s releaseEvidenceService) ListProducts(ctx context.Context, actor domain.Actor) ([]domain.Product, error) { + l := s.ledger if err := ctx.Err(); err != nil { return nil, err } @@ -438,7 +390,7 @@ func (l *Ledger) ListProducts(ctx context.Context, actor domain.Actor) ([]domain defer l.mu.Unlock() out := []domain.Product{} for _, product := range l.products { - if product.TenantID == actor.TenantID { + if product.TenantID == actor.TenantID && l.resourceAllowedLocked(actor, ScopeProductRead, resourceRefs{ProductID: product.ID}) { out = append(out, product) } } @@ -446,7 +398,28 @@ func (l *Ledger) ListProducts(ctx context.Context, actor domain.Actor) ([]domain return out, nil } -func (l *Ledger) CreateProject(ctx context.Context, actor domain.Actor, productID, name string) (domain.Project, error) { +func (s releaseEvidenceService) GetProduct(ctx context.Context, actor domain.Actor, id string) (domain.Product, error) { + l := s.ledger + if err := ctx.Err(); err != nil { + return domain.Product{}, err + } + if err := require(actor, ScopeProductRead); err != nil { + return domain.Product{}, err + } + l.mu.Lock() + defer l.mu.Unlock() + product, ok := l.products[strings.TrimSpace(id)] + if !ok || product.TenantID != actor.TenantID { + return domain.Product{}, ErrNotFound + } + if err := l.authorizeResourceLocked(actor, ScopeProductRead, resourceRefs{ProductID: product.ID}); err != nil { + return domain.Product{}, err + } + return product, nil +} + +func (s releaseEvidenceService) CreateProject(ctx context.Context, actor domain.Actor, productID, name string) (domain.Project, error) { + l := s.ledger if err := ctx.Err(); err != nil { return domain.Project{}, err } @@ -463,16 +436,40 @@ func (l *Ledger) CreateProject(ctx context.Context, actor domain.Actor, productI if !ok || product.TenantID != actor.TenantID { return domain.Project{}, ErrNotFound } + if err := l.authorizeResourceLocked(actor, ScopeProjectWrite, resourceRefs{ProductID: product.ID}); err != nil { + return domain.Project{}, err + } project := domain.Project{ID: newID("proj"), TenantID: actor.TenantID, ProductID: productID, Name: name, CreatedAt: l.now()} l.projects[project.ID] = project _, _ = l.appendChainLocked(actor.TenantID, "project.created", "project", project.ID, "api_key", actor.KeyID, "", "") - if err := l.persistLocked(ctx); err != nil { + if err := l.persistReleaseLedgerLocked(ctx, l.releaseLedgerMutationLocked()); err != nil { + return domain.Project{}, err + } + return project, nil +} + +func (s releaseEvidenceService) GetProject(ctx context.Context, actor domain.Actor, id string) (domain.Project, error) { + l := s.ledger + if err := ctx.Err(); err != nil { + return domain.Project{}, err + } + if err := require(actor, ScopeProjectRead); err != nil { + return domain.Project{}, err + } + l.mu.Lock() + defer l.mu.Unlock() + project, ok := l.projects[strings.TrimSpace(id)] + if !ok || project.TenantID != actor.TenantID { + return domain.Project{}, ErrNotFound + } + if err := l.authorizeResourceLocked(actor, ScopeProjectRead, resourceRefs{ProductID: project.ProductID, ProjectID: project.ID}); err != nil { return domain.Project{}, err } return project, nil } -func (l *Ledger) CreateRelease(ctx context.Context, actor domain.Actor, productID, version string) (domain.Release, error) { +func (s releaseEvidenceService) CreateRelease(ctx context.Context, actor domain.Actor, productID, version string) (domain.Release, error) { + l := s.ledger if err := ctx.Err(); err != nil { return domain.Release{}, err } @@ -489,6 +486,9 @@ func (l *Ledger) CreateRelease(ctx context.Context, actor domain.Actor, productI if !ok || product.TenantID != actor.TenantID { return domain.Release{}, ErrNotFound } + if err := l.authorizeResourceLocked(actor, ScopeReleaseWrite, resourceRefs{ProductID: product.ID}); err != nil { + return domain.Release{}, err + } for _, existing := range l.releases { if existing.TenantID == actor.TenantID && existing.ProductID == productID && existing.Version == version { return domain.Release{}, ErrConflict @@ -497,13 +497,14 @@ func (l *Ledger) CreateRelease(ctx context.Context, actor domain.Actor, productI release := domain.Release{ID: newID("rel"), TenantID: actor.TenantID, ProductID: productID, Version: version, State: "draft", CreatedAt: l.now()} l.releases[release.ID] = release _, _ = l.appendChainLocked(actor.TenantID, "release.created", "release", release.ID, "api_key", actor.KeyID, "", "") - if err := l.persistLocked(ctx); err != nil { + if err := l.persistReleaseLedgerLocked(ctx, l.releaseLedgerMutationLocked()); err != nil { return domain.Release{}, err } return release, nil } -func (l *Ledger) GetRelease(ctx context.Context, actor domain.Actor, releaseID string) (domain.Release, error) { +func (s releaseEvidenceService) GetRelease(ctx context.Context, actor domain.Actor, releaseID string) (domain.Release, error) { + l := s.ledger if err := ctx.Err(); err != nil { return domain.Release{}, err } @@ -516,10 +517,14 @@ func (l *Ledger) GetRelease(ctx context.Context, actor domain.Actor, releaseID s if !ok || release.TenantID != actor.TenantID { return domain.Release{}, ErrNotFound } + if err := l.authorizeResourceLocked(actor, ScopeReleaseRead, resourceRefs{ReleaseID: release.ID}); err != nil { + return domain.Release{}, err + } return release, nil } -func (l *Ledger) FreezeRelease(ctx context.Context, actor domain.Actor, releaseID string) (domain.Release, error) { +func (s releaseEvidenceService) FreezeRelease(ctx context.Context, actor domain.Actor, releaseID string) (domain.Release, error) { + l := s.ledger if err := ctx.Err(); err != nil { return domain.Release{}, err } @@ -532,6 +537,9 @@ func (l *Ledger) FreezeRelease(ctx context.Context, actor domain.Actor, releaseI if !ok || release.TenantID != actor.TenantID { return domain.Release{}, ErrNotFound } + if err := l.authorizeResourceLocked(actor, ScopeReleaseWrite, resourceRefs{ReleaseID: release.ID}); err != nil { + return domain.Release{}, err + } if release.State != "draft" { return domain.Release{}, ErrConflict } @@ -540,13 +548,14 @@ func (l *Ledger) FreezeRelease(ctx context.Context, actor domain.Actor, releaseI release.FrozenAt = &now l.releases[release.ID] = release _, _ = l.appendChainLocked(actor.TenantID, "release.frozen", "release", release.ID, "api_key", actor.KeyID, "", "") - if err := l.persistLocked(ctx); err != nil { + if err := l.persistReleaseLedgerLocked(ctx, l.releaseLedgerMutationLocked()); err != nil { return domain.Release{}, err } return release, nil } -func (l *Ledger) ApproveRelease(ctx context.Context, actor domain.Actor, releaseID string) (domain.Release, error) { +func (s releaseEvidenceService) ApproveRelease(ctx context.Context, actor domain.Actor, releaseID string) (domain.Release, error) { + l := s.ledger if err := ctx.Err(); err != nil { return domain.Release{}, err } @@ -559,6 +568,9 @@ func (l *Ledger) ApproveRelease(ctx context.Context, actor domain.Actor, release if !ok || release.TenantID != actor.TenantID { return domain.Release{}, ErrNotFound } + if err := l.authorizeResourceLocked(actor, ScopeReleaseWrite, resourceRefs{ReleaseID: release.ID}); err != nil { + return domain.Release{}, err + } if release.State != "frozen" { return domain.Release{}, ErrConflict } @@ -567,13 +579,14 @@ func (l *Ledger) ApproveRelease(ctx context.Context, actor domain.Actor, release release.ApprovedAt = &now l.releases[release.ID] = release _, _ = l.appendChainLocked(actor.TenantID, "release.approved", "release", release.ID, "api_key", actor.KeyID, "", "") - if err := l.persistLocked(ctx); err != nil { + if err := l.persistReleaseLedgerLocked(ctx, l.releaseLedgerMutationLocked()); err != nil { return domain.Release{}, err } return release, nil } -func (l *Ledger) RegisterArtifact(ctx context.Context, actor domain.Actor, name, mediaType, digest string, size int64) (domain.Artifact, error) { +func (s releaseEvidenceService) RegisterArtifact(ctx context.Context, actor domain.Actor, name, mediaType, digest string, size int64) (domain.Artifact, error) { + l := s.ledger if err := ctx.Err(); err != nil { return domain.Artifact{}, err } @@ -594,7 +607,27 @@ func (l *Ledger) RegisterArtifact(ctx context.Context, actor domain.Actor, name, artifact := domain.Artifact{ID: newID("art"), TenantID: actor.TenantID, Name: name, MediaType: mediaType, Size: size, Digest: digest, CreatedAt: l.now()} l.artifacts[artifact.ID] = artifact _, _ = l.appendChainLocked(actor.TenantID, "artifact.created", "artifact", artifact.ID, "api_key", actor.KeyID, digest, "") - if err := l.persistLocked(ctx); err != nil { + if err := l.persistReleaseLedgerLocked(ctx, l.releaseLedgerMutationLocked()); err != nil { + return domain.Artifact{}, err + } + return artifact, nil +} + +func (s releaseEvidenceService) GetArtifact(ctx context.Context, actor domain.Actor, id string) (domain.Artifact, error) { + l := s.ledger + if err := ctx.Err(); err != nil { + return domain.Artifact{}, err + } + if err := require(actor, ScopeEvidenceRead); err != nil { + return domain.Artifact{}, err + } + l.mu.Lock() + defer l.mu.Unlock() + artifact, ok := l.artifacts[strings.TrimSpace(id)] + if !ok || artifact.TenantID != actor.TenantID { + return domain.Artifact{}, ErrNotFound + } + if err := l.authorizeResourceLocked(actor, ScopeEvidenceRead, resourceRefs{ArtifactID: artifact.ID}); err != nil { return domain.Artifact{}, err } return artifact, nil @@ -623,7 +656,8 @@ type CreateEvidenceInput struct { Limitations []string } -func (l *Ledger) CreateEvidence(ctx context.Context, actor domain.Actor, in CreateEvidenceInput) (domain.EvidenceItem, error) { +func (s releaseEvidenceService) CreateEvidence(ctx context.Context, actor domain.Actor, in CreateEvidenceInput) (domain.EvidenceItem, error) { + l := s.ledger if err := ctx.Err(); err != nil { return domain.EvidenceItem{}, err } @@ -647,6 +681,9 @@ func (l *Ledger) CreateEvidence(ctx context.Context, actor domain.Actor, in Crea if err := l.ensureScopeLocked(actor.TenantID, in.ProductID, in.ProjectID, in.ReleaseID); err != nil { return domain.EvidenceItem{}, err } + if err := l.authorizeResourceLocked(actor, ScopeEvidenceWrite, resourceRefs{ProductID: in.ProductID, ProjectID: in.ProjectID, ReleaseID: in.ReleaseID}); err != nil { + return domain.EvidenceItem{}, err + } now := l.now() item := domain.EvidenceItem{ ID: newID("ev"), @@ -691,13 +728,14 @@ func (l *Ledger) CreateEvidence(ctx context.Context, actor domain.Actor, in Crea } item.ChainEntryID = entry.ID l.evidence[item.ID] = item - if err := l.persistLocked(ctx); err != nil { + if err := l.persistReleaseLedgerLocked(ctx, l.releaseLedgerMutationLocked()); err != nil { return domain.EvidenceItem{}, err } return item, nil } -func (l *Ledger) GetEvidence(ctx context.Context, actor domain.Actor, id string) (domain.EvidenceItem, error) { +func (s releaseEvidenceService) GetEvidence(ctx context.Context, actor domain.Actor, id string) (domain.EvidenceItem, error) { + l := s.ledger if err := ctx.Err(); err != nil { return domain.EvidenceItem{}, err } @@ -710,10 +748,14 @@ func (l *Ledger) GetEvidence(ctx context.Context, actor domain.Actor, id string) if !ok || item.TenantID != actor.TenantID { return domain.EvidenceItem{}, ErrNotFound } + if err := l.authorizeResourceLocked(actor, ScopeEvidenceRead, refsForEvidence(item)); err != nil { + return domain.EvidenceItem{}, err + } return item, nil } -func (l *Ledger) ListEvidence(ctx context.Context, actor domain.Actor, releaseID, typ string) ([]domain.EvidenceItem, error) { +func (s releaseEvidenceService) ListEvidence(ctx context.Context, actor domain.Actor, releaseID, typ string) ([]domain.EvidenceItem, error) { + l := s.ledger if err := ctx.Err(); err != nil { return nil, err } @@ -733,13 +775,17 @@ func (l *Ledger) ListEvidence(ctx context.Context, actor domain.Actor, releaseID if typ != "" && item.Type != typ { continue } + if !l.resourceAllowedLocked(actor, ScopeEvidenceRead, refsForEvidence(item)) { + continue + } out = append(out, item) } sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) return out, nil } -func (l *Ledger) SupersedeEvidence(ctx context.Context, actor domain.Actor, id, replacementID, reason string) (domain.EvidenceItem, error) { +func (s releaseEvidenceService) SupersedeEvidence(ctx context.Context, actor domain.Actor, id, replacementID, reason string) (domain.EvidenceItem, error) { + l := s.ledger if err := ctx.Err(); err != nil { return domain.EvidenceItem{}, err } @@ -756,6 +802,12 @@ func (l *Ledger) SupersedeEvidence(ctx context.Context, actor domain.Actor, id, if !ok || !rok || item.TenantID != actor.TenantID || replacement.TenantID != actor.TenantID { return domain.EvidenceItem{}, ErrNotFound } + if err := l.authorizeResourceLocked(actor, ScopeEvidenceWrite, refsForEvidence(item)); err != nil { + return domain.EvidenceItem{}, err + } + if err := l.authorizeResourceLocked(actor, ScopeEvidenceWrite, refsForEvidence(replacement)); err != nil { + return domain.EvidenceItem{}, err + } if item.SupersededBy != "" { return domain.EvidenceItem{}, ErrConflict } @@ -764,13 +816,14 @@ func (l *Ledger) SupersedeEvidence(ctx context.Context, actor domain.Actor, id, l.evidence[item.ID] = item l.evidence[replacement.ID] = replacement _, _ = l.appendChainLocked(actor.TenantID, "evidence.superseded", "evidence_item", item.ID, "api_key", actor.KeyID, item.PayloadHash, "") - if err := l.persistLocked(ctx); err != nil { + if err := l.persistReleaseLedgerLocked(ctx, l.releaseLedgerMutationLocked()); err != nil { return domain.EvidenceItem{}, err } return item, nil } -func (l *Ledger) LinkEvidence(ctx context.Context, actor domain.Actor, id, targetType, targetID string) (domain.EvidenceItem, error) { +func (s releaseEvidenceService) LinkEvidence(ctx context.Context, actor domain.Actor, id, targetType, targetID string) (domain.EvidenceItem, error) { + l := s.ledger if err := ctx.Err(); err != nil { return domain.EvidenceItem{}, err } @@ -787,18 +840,27 @@ func (l *Ledger) LinkEvidence(ctx context.Context, actor domain.Actor, id, targe if !ok || item.TenantID != actor.TenantID { return domain.EvidenceItem{}, ErrNotFound } + if err := l.authorizeResourceLocked(actor, ScopeEvidenceWrite, refsForEvidence(item)); err != nil { + return domain.EvidenceItem{}, err + } switch targetType { case "release": rel, ok := l.releases[targetID] if !ok || rel.TenantID != actor.TenantID { return domain.EvidenceItem{}, ErrNotFound } + if err := l.authorizeResourceLocked(actor, ScopeEvidenceWrite, resourceRefs{ReleaseID: rel.ID}); err != nil { + return domain.EvidenceItem{}, err + } item.ReleaseID = targetID case "product": prod, ok := l.products[targetID] if !ok || prod.TenantID != actor.TenantID { return domain.EvidenceItem{}, ErrNotFound } + if err := l.authorizeResourceLocked(actor, ScopeEvidenceWrite, resourceRefs{ProductID: prod.ID}); err != nil { + return domain.EvidenceItem{}, err + } item.ProductID = targetID default: return domain.EvidenceItem{}, ErrValidation @@ -806,13 +868,20 @@ func (l *Ledger) LinkEvidence(ctx context.Context, actor domain.Actor, id, targe item.RelatedEvidenceRefs = append(item.RelatedEvidenceRefs, domain.EvidenceRef{Type: targetType, ID: targetID, Relationship: "linked_to"}) l.evidence[item.ID] = item _, _ = l.appendChainLocked(actor.TenantID, "evidence.linked", "evidence_item", item.ID, "api_key", actor.KeyID, item.PayloadHash, "") - if err := l.persistLocked(ctx); err != nil { + if err := l.persistReleaseLedgerLocked(ctx, l.releaseLedgerMutationLocked()); err != nil { return domain.EvidenceItem{}, err } return item, nil } -func (l *Ledger) UploadSBOM(ctx context.Context, actor domain.Actor, releaseID, artifactID string, raw []byte) (domain.SBOM, error) { +func (s releaseEvidenceService) UploadSBOM(ctx context.Context, actor domain.Actor, releaseID, artifactID string, raw []byte) (domain.SBOM, error) { + l := s.ledger + if err := ctx.Err(); err != nil { + return domain.SBOM{}, err + } + if err := require(actor, ScopeEvidenceWrite); err != nil { + return domain.SBOM{}, err + } if len(raw) == 0 || len(raw) > 20<<20 { return domain.SBOM{}, ErrValidation } @@ -820,6 +889,7 @@ func (l *Ledger) UploadSBOM(ctx context.Context, actor domain.Actor, releaseID, BOMFormat string `json:"bomFormat"` SpecVersion string `json:"specVersion"` Components []struct { + Type string `json:"type"` Name string `json:"name"` Version string `json:"version"` PURL string `json:"purl"` @@ -837,6 +907,16 @@ func (l *Ledger) UploadSBOM(ctx context.Context, actor domain.Actor, releaseID, } components = append(components, domain.SBOMComponent{Name: component.Name, Version: component.Version, PURL: component.PURL}) } + l.mu.Lock() + if err := l.ensureScopeLocked(actor.TenantID, "", "", strings.TrimSpace(releaseID)); err != nil { + l.mu.Unlock() + return domain.SBOM{}, err + } + if err := l.authorizeResourceLocked(actor, ScopeEvidenceWrite, resourceRefs{ReleaseID: strings.TrimSpace(releaseID)}); err != nil { + l.mu.Unlock() + return domain.SBOM{}, err + } + l.mu.Unlock() payloadHash := hashBytes(raw) payloadRef, err := l.storePayload(ctx, actor.TenantID, "sbom", "application/vnd.cyclonedx+json", payloadHash, raw) if err != nil { @@ -866,18 +946,31 @@ func (l *Ledger) UploadSBOM(ctx context.Context, actor domain.Actor, releaseID, l.mu.Lock() defer l.mu.Unlock() sbom := domain.SBOM{ID: newID("sbom"), TenantID: actor.TenantID, EvidenceID: item.ID, ReleaseID: releaseID, ArtifactID: artifactID, Format: "cyclonedx", SpecVersion: doc.SpecVersion, ComponentCount: len(components), Components: components, CreatedAt: l.now()} - l.sboms[sbom.ID] = sbom - _, _ = l.appendChainLocked(actor.TenantID, "sbom.parsed", "sbom", sbom.ID, "api_key", actor.KeyID, payloadHash, "") - if err := l.enqueue(ctx, actor.TenantID, "parse_sbom", "sbom", sbom.ID, map[string]any{"payload_ref": payloadRef, "payload_hash": payloadHash}); err != nil { - return domain.SBOM{}, err - } - if err := l.persistLocked(ctx); err != nil { + persistedSBOM := sbom + chainAction := "sbom.parsed" + if l.workerOwnedParsers { + persistedSBOM.SpecVersion = "" + persistedSBOM.ComponentCount = 0 + persistedSBOM.Components = nil + chainAction = "sbom.accepted" + } + l.sboms[sbom.ID] = persistedSBOM + _, _ = l.appendChainLocked(actor.TenantID, chainAction, "sbom", sbom.ID, "api_key", actor.KeyID, payloadHash, "") + job := l.newOutboxJob(actor.TenantID, "parse_sbom", "sbom", sbom.ID, map[string]any{"payload_ref": payloadRef, "payload_hash": payloadHash, "parser_version": ParserVersionCycloneDXJSON}) + if err := l.persistReleaseLedgerWithOutboxLocked(ctx, job); err != nil { return domain.SBOM{}, err } return sbom, nil } -func (l *Ledger) UploadVulnerabilityScan(ctx context.Context, actor domain.Actor, raw []byte) (domain.VulnerabilityScan, error) { +func (s releaseEvidenceService) UploadVulnerabilityScan(ctx context.Context, actor domain.Actor, raw []byte) (domain.VulnerabilityScan, error) { + l := s.ledger + if err := ctx.Err(); err != nil { + return domain.VulnerabilityScan{}, err + } + if err := require(actor, ScopeEvidenceWrite); err != nil { + return domain.VulnerabilityScan{}, err + } if len(raw) == 0 || len(raw) > 20<<20 { return domain.VulnerabilityScan{}, ErrValidation } @@ -900,6 +993,29 @@ func (l *Ledger) UploadVulnerabilityScan(ctx context.Context, actor domain.Actor if doc.ReleaseID == "" { return domain.VulnerabilityScan{}, ErrValidation } + l.mu.Lock() + release, ok := l.releases[strings.TrimSpace(doc.ReleaseID)] + if !ok || release.TenantID != actor.TenantID { + l.mu.Unlock() + return domain.VulnerabilityScan{}, ErrNotFound + } + if err := l.authorizeResourceLocked(actor, ScopeEvidenceWrite, resourceRefs{ProductID: release.ProductID, ReleaseID: release.ID}); err != nil { + l.mu.Unlock() + return domain.VulnerabilityScan{}, err + } + l.mu.Unlock() + scanID := newID("scan") + summary := map[string]int{} + findings := make([]domain.VulnerabilityFinding, 0, len(doc.Findings)) + for i, finding := range doc.Findings { + if finding.Vulnerability == "" || finding.Severity == "" { + return domain.VulnerabilityScan{}, ErrValidation + } + severity := strings.ToLower(finding.Severity) + summary[severity]++ + state := nonEmpty(finding.State, "open") + findings = append(findings, domain.VulnerabilityFinding{ID: fmt.Sprintf("%s:finding:%d", scanID, i+1), Vulnerability: finding.Vulnerability, Component: finding.Component, Severity: severity, State: state}) + } payloadHash := hashBytes(raw) payloadRef, err := l.storePayload(ctx, actor.TenantID, "vulnerability-scan", "application/json", payloadHash, raw) if err != nil { @@ -921,32 +1037,32 @@ func (l *Ledger) UploadVulnerabilityScan(ctx context.Context, actor domain.Actor if err != nil { return domain.VulnerabilityScan{}, err } - summary := map[string]int{} - findings := make([]domain.VulnerabilityFinding, 0, len(doc.Findings)) - for _, finding := range doc.Findings { - if finding.Vulnerability == "" || finding.Severity == "" { - return domain.VulnerabilityScan{}, ErrValidation - } - severity := strings.ToLower(finding.Severity) - summary[severity]++ - state := nonEmpty(finding.State, "open") - findings = append(findings, domain.VulnerabilityFinding{ID: newID("vf"), Vulnerability: finding.Vulnerability, Component: finding.Component, Severity: severity, State: state}) - } l.mu.Lock() defer l.mu.Unlock() - scan := domain.VulnerabilityScan{ID: newID("scan"), TenantID: actor.TenantID, EvidenceID: item.ID, ReleaseID: doc.ReleaseID, Scanner: doc.Scanner, TargetRef: doc.TargetRef, Summary: summary, Findings: findings, CreatedAt: l.now()} - l.scans[scan.ID] = scan - _, _ = l.appendChainLocked(actor.TenantID, "vulnerability_scan.parsed", "vulnerability_scan", scan.ID, "api_key", actor.KeyID, payloadHash, "") - if err := l.enqueue(ctx, actor.TenantID, "parse_vulnerability_scan", "vulnerability_scan", scan.ID, map[string]any{"payload_ref": payloadRef, "payload_hash": payloadHash}); err != nil { - return domain.VulnerabilityScan{}, err - } - if err := l.persistLocked(ctx); err != nil { + scan := domain.VulnerabilityScan{ID: scanID, TenantID: actor.TenantID, EvidenceID: item.ID, ReleaseID: doc.ReleaseID, Scanner: doc.Scanner, TargetRef: doc.TargetRef, Summary: summary, Findings: findings, CreatedAt: l.now()} + persistedScan := scan + chainAction := "vulnerability_scan.parsed" + if l.workerOwnedParsers { + persistedScan.Scanner = "" + persistedScan.TargetRef = "" + persistedScan.Summary = nil + persistedScan.Findings = nil + chainAction = "vulnerability_scan.accepted" + } + l.scans[scan.ID] = persistedScan + _, _ = l.appendChainLocked(actor.TenantID, chainAction, "vulnerability_scan", scan.ID, "api_key", actor.KeyID, payloadHash, "") + job := l.newOutboxJob(actor.TenantID, "parse_vulnerability_scan", "vulnerability_scan", scan.ID, map[string]any{"payload_ref": payloadRef, "payload_hash": payloadHash, "parser_version": ParserVersionGenericVulnerabilityJSON}) + if err := l.persistReleaseLedgerWithOutboxLocked(ctx, job); err != nil { return domain.VulnerabilityScan{}, err } return scan, nil } -func (l *Ledger) UploadOpenAPIContract(ctx context.Context, actor domain.Actor, productID, releaseID, version string, raw []byte) (domain.OpenAPIContract, error) { +func (s releaseEvidenceService) UploadOpenAPIContract(ctx context.Context, actor domain.Actor, productID, releaseID, version string, raw []byte) (domain.OpenAPIContract, error) { + l := s.ledger + if err := ctx.Err(); err != nil { + return domain.OpenAPIContract{}, err + } if err := require(actor, ScopeEvidenceWrite); err != nil { return domain.OpenAPIContract{}, err } @@ -958,6 +1074,17 @@ func (l *Ledger) UploadOpenAPIContract(ctx context.Context, actor domain.Actor, if err := doc.Validate(ctx); err != nil { return domain.OpenAPIContract{}, ErrValidation } + operations := extractOpenAPIOperations(doc) + l.mu.Lock() + if err := l.ensureScopeLocked(actor.TenantID, strings.TrimSpace(productID), "", strings.TrimSpace(releaseID)); err != nil { + l.mu.Unlock() + return domain.OpenAPIContract{}, err + } + if err := l.authorizeResourceLocked(actor, ScopeEvidenceWrite, resourceRefs{ProductID: strings.TrimSpace(productID), ReleaseID: strings.TrimSpace(releaseID)}); err != nil { + l.mu.Unlock() + return domain.OpenAPIContract{}, err + } + l.mu.Unlock() payloadHash := hashBytes(raw) payloadRef, err := l.storePayload(ctx, actor.TenantID, "openapi-contract", "application/vnd.oai.openapi+json", payloadHash, raw) if err != nil { @@ -982,18 +1109,116 @@ func (l *Ledger) UploadOpenAPIContract(ctx context.Context, actor domain.Actor, } l.mu.Lock() defer l.mu.Unlock() - contract := domain.OpenAPIContract{ID: newID("oas"), TenantID: actor.TenantID, ProductID: productID, ReleaseID: releaseID, Version: version, Hash: payloadHash, PathCount: len(doc.Paths.Map()), EvidenceID: item.ID, CreatedAt: l.now()} - l.contracts[contract.ID] = contract - _, _ = l.appendChainLocked(actor.TenantID, "openapi_contract.parsed", "openapi_contract", contract.ID, "api_key", actor.KeyID, contract.Hash, "") - if err := l.enqueue(ctx, actor.TenantID, "parse_openapi_contract", "openapi_contract", contract.ID, map[string]any{"payload_ref": payloadRef, "payload_hash": payloadHash}); err != nil { - return domain.OpenAPIContract{}, err - } - if err := l.persistLocked(ctx); err != nil { + contract := domain.OpenAPIContract{ID: newID("oas"), TenantID: actor.TenantID, ProductID: productID, ReleaseID: releaseID, Version: version, Hash: payloadHash, PathCount: len(doc.Paths.Map()), Operations: operations, EvidenceID: item.ID, CreatedAt: l.now()} + persistedContract := contract + chainAction := "openapi_contract.parsed" + if l.workerOwnedParsers { + persistedContract.PathCount = 0 + persistedContract.Operations = nil + chainAction = "openapi_contract.accepted" + } + l.contracts[contract.ID] = persistedContract + _, _ = l.appendChainLocked(actor.TenantID, chainAction, "openapi_contract", contract.ID, "api_key", actor.KeyID, contract.Hash, "") + job := l.newOutboxJob(actor.TenantID, "parse_openapi_contract", "openapi_contract", contract.ID, map[string]any{"payload_ref": payloadRef, "payload_hash": payloadHash, "parser_version": ParserVersionOpenAPIJSON}) + if err := l.persistReleaseLedgerWithOutboxLocked(ctx, job); err != nil { return domain.OpenAPIContract{}, err } return contract, nil } +func extractOpenAPIOperations(doc *openapi3.T) []domain.OpenAPIOperation { + if doc == nil || doc.Paths == nil { + return nil + } + paths := doc.Paths.Map() + pathNames := make([]string, 0, len(paths)) + for path := range paths { + pathNames = append(pathNames, path) + } + sort.Strings(pathNames) + out := make([]domain.OpenAPIOperation, 0) + for _, path := range pathNames { + item := paths[path] + for _, methodOperation := range openAPIMethodOperations(item) { + operation := methodOperation.operation + if operation == nil { + continue + } + out = append(out, domain.OpenAPIOperation{ + Path: path, + Method: strings.ToUpper(methodOperation.method), + OperationID: operation.OperationID, + Deprecated: operation.Deprecated, + RequestBodyRequired: openAPIRequestBodyRequired(operation), + RequiredRequestFields: openAPIRequiredRequestFields(operation), + ResponseStatuses: openAPIResponseStatuses(operation), + }) + } + } + return out +} + +type openAPIMethodOperation struct { + method string + operation *openapi3.Operation +} + +func openAPIMethodOperations(item *openapi3.PathItem) []openAPIMethodOperation { + if item == nil { + return nil + } + return []openAPIMethodOperation{ + {method: "connect", operation: item.Connect}, + {method: "delete", operation: item.Delete}, + {method: "get", operation: item.Get}, + {method: "head", operation: item.Head}, + {method: "options", operation: item.Options}, + {method: "patch", operation: item.Patch}, + {method: "post", operation: item.Post}, + {method: "put", operation: item.Put}, + {method: "trace", operation: item.Trace}, + } +} + +func openAPIRequestBodyRequired(operation *openapi3.Operation) bool { + return operation != nil && operation.RequestBody != nil && operation.RequestBody.Value != nil && operation.RequestBody.Value.Required +} + +func openAPIRequiredRequestFields(operation *openapi3.Operation) []string { + if operation == nil || operation.RequestBody == nil || operation.RequestBody.Value == nil { + return nil + } + fields := map[string]struct{}{} + for _, media := range operation.RequestBody.Value.Content { + if media == nil || media.Schema == nil || media.Schema.Value == nil { + continue + } + for _, field := range media.Schema.Value.Required { + if strings.TrimSpace(field) != "" { + fields[strings.TrimSpace(field)] = struct{}{} + } + } + } + out := make([]string, 0, len(fields)) + for field := range fields { + out = append(out, field) + } + sort.Strings(out) + return out +} + +func openAPIResponseStatuses(operation *openapi3.Operation) []string { + if operation == nil || operation.Responses == nil { + return nil + } + statuses := make([]string, 0, len(operation.Responses.Map())) + for status := range operation.Responses.Map() { + statuses = append(statuses, status) + } + sort.Strings(statuses) + return statuses +} + func (l *Ledger) EvaluateRelease(ctx context.Context, actor domain.Actor, releaseID string) (domain.PolicyEvaluation, error) { if err := ctx.Err(); err != nil { return domain.PolicyEvaluation{}, err @@ -1007,22 +1232,11 @@ func (l *Ledger) EvaluateRelease(ctx context.Context, actor domain.Actor, releas if !ok || release.TenantID != actor.TenantID { return domain.PolicyEvaluation{}, ErrNotFound } - checks := []domain.PolicyCheck{ - l.checkReleaseHasEvidenceLocked(actor.TenantID, release.ID, "sbom", "release_requires_sbom", "high"), - l.checkReleaseHasEvidenceLocked(actor.TenantID, release.ID, "vulnerability_scan", "release_requires_vulnerability_scan", "high"), - l.checkReleaseHasArtifactDigestLocked(actor.TenantID, release.ID), - l.checkReleaseHasSignedBundleLocked(actor.TenantID, release.ID), - l.checkReleaseHasPassedBuildLocked(actor.TenantID, release.ID), - l.checkReleaseHasBuildAttestationLocked(actor.TenantID, release.ID), - l.checkNoOpenCriticalLocked(actor.TenantID, release.ID), - } - result := "passed" - for _, check := range checks { - if check.Result == "failed" { - result = "failed" - break - } + if err := l.authorizeResourceLocked(actor, ScopeVerifyRead, resourceRefs{ReleaseID: release.ID}); err != nil { + return domain.PolicyEvaluation{}, err } + checks := l.releasePolicyChecksLocked(actor.TenantID, release.ID) + result := releasePolicyResult(checks) eval := domain.PolicyEvaluation{ID: newID("pe"), TenantID: actor.TenantID, ReleaseID: release.ID, Result: result, PolicySet: domain.PolicySetVersion, Checks: checks, CreatedAt: l.now()} l.policies[eval.ID] = eval _, _ = l.appendChainLocked(actor.TenantID, "policy.evaluated", "policy_evaluation", eval.ID, "api_key", actor.KeyID, "", "") @@ -1032,6 +1246,33 @@ func (l *Ledger) EvaluateRelease(ctx context.Context, actor domain.Actor, releas return eval, nil } +func (l *Ledger) releasePolicyChecksLocked(tenantID, releaseID string) []domain.PolicyCheck { + return []domain.PolicyCheck{ + l.checkReleaseHasArtifactLocked(tenantID, releaseID), + l.checkReleaseHasEvidenceLocked(tenantID, releaseID, "sbom", "release_requires_sbom", "high"), + l.checkReleaseHasEvidenceLocked(tenantID, releaseID, "vulnerability_scan", "release_requires_vulnerability_scan", "high"), + l.checkReleaseHasArtifactDigestLocked(tenantID, releaseID), + l.checkReleaseHasSignedBundleLocked(tenantID, releaseID), + l.checkReleaseHasPassedBuildLocked(tenantID, releaseID), + l.checkReleaseHasBuildAttestationLocked(tenantID, releaseID), + l.checkNoOpenCriticalLocked(tenantID, releaseID), + l.checkNoOpenHighLocked(tenantID, releaseID), + l.checkCustomerVisibleDecisionsHaveStatementsLocked(tenantID, releaseID), + l.checkNotAffectedDecisionsHaveJustificationLocked(tenantID, releaseID), + l.checkExceptionsCompleteLocked(tenantID, releaseID), + l.checkPackageRedactionProfilesValidLocked(tenantID, releaseID), + } +} + +func releasePolicyResult(checks []domain.PolicyCheck) string { + for _, check := range checks { + if check.Result == "failed" { + return "failed" + } + } + return "passed" +} + func (l *Ledger) CreateReleaseBundle(ctx context.Context, actor domain.Actor, releaseID string) (domain.ReleaseBundle, error) { if err := ctx.Err(); err != nil { return domain.ReleaseBundle{}, err @@ -1045,6 +1286,9 @@ func (l *Ledger) CreateReleaseBundle(ctx context.Context, actor domain.Actor, re if !ok || release.TenantID != actor.TenantID { return domain.ReleaseBundle{}, ErrNotFound } + if err := l.authorizeResourceLocked(actor, ScopeBundleWrite, resourceRefs{ReleaseID: release.ID}); err != nil { + return domain.ReleaseBundle{}, err + } evidenceIDs := []string{} for _, item := range l.evidence { if item.TenantID == actor.TenantID && item.ReleaseID == release.ID { @@ -1077,6 +1321,7 @@ func (l *Ledger) CreateReleaseBundle(ctx context.Context, actor domain.Actor, re "name": "evydence", "version": "dev", }, + "object_lock_proofs": l.packageObjectLockProofsLocked(actor.TenantID), } manifestHash, err := canonicalAnyHash(manifest) if err != nil { @@ -1089,10 +1334,15 @@ func (l *Ledger) CreateReleaseBundle(ctx context.Context, actor domain.Actor, re bundle := domain.ReleaseBundle{ID: bundleID, TenantID: actor.TenantID, ReleaseID: release.ID, State: "generated", Manifest: manifest, ManifestHash: manifestHash, SignatureRefs: []string{sig.ID}, CreatedAt: l.now()} l.bundles[bundle.ID] = bundle _, _ = l.appendChainLocked(actor.TenantID, "bundle.generated", "release_bundle", bundle.ID, "api_key", actor.KeyID, manifestHash, sig.ID) - if err := l.enqueue(ctx, actor.TenantID, "sign_bundle", "release_bundle", bundle.ID, map[string]any{"manifest_hash": manifestHash}); err != nil { - return domain.ReleaseBundle{}, err + job := l.newOutboxJob(actor.TenantID, "sign_bundle", "release_bundle", bundle.ID, map[string]any{"manifest_hash": manifestHash}) + mutation := l.criticalMutationLocked() + mutation.OutboxJobs = append(mutation.OutboxJobs, job) + if _, ok := l.store.(CriticalMutationStore); !ok { + if err := l.enqueueJob(ctx, job); err != nil { + return domain.ReleaseBundle{}, err + } } - if err := l.persistLocked(ctx); err != nil { + if err := l.persistCriticalLocked(ctx, mutation); err != nil { return domain.ReleaseBundle{}, err } return bundle, nil @@ -1111,10 +1361,14 @@ func (l *Ledger) GetReleaseBundle(ctx context.Context, actor domain.Actor, id st if !ok || bundle.TenantID != actor.TenantID { return domain.ReleaseBundle{}, ErrNotFound } + if err := l.authorizeResourceLocked(actor, ScopeBundleRead, resourceRefs{ReleaseID: bundle.ReleaseID}); err != nil { + return domain.ReleaseBundle{}, err + } return bundle, nil } -func (l *Ledger) GetSBOM(ctx context.Context, actor domain.Actor, id string) (domain.SBOM, error) { +func (s releaseEvidenceService) GetSBOM(ctx context.Context, actor domain.Actor, id string) (domain.SBOM, error) { + l := s.ledger if err := ctx.Err(); err != nil { return domain.SBOM{}, err } @@ -1127,10 +1381,97 @@ func (l *Ledger) GetSBOM(ctx context.Context, actor domain.Actor, id string) (do if !ok || sbom.TenantID != actor.TenantID { return domain.SBOM{}, ErrNotFound } + if err := l.authorizeResourceLocked(actor, ScopeEvidenceRead, resourceRefs{ReleaseID: sbom.ReleaseID}); err != nil { + return domain.SBOM{}, err + } return sbom, nil } -func (l *Ledger) GetVulnerabilityScan(ctx context.Context, actor domain.Actor, id string) (domain.VulnerabilityScan, error) { +type ListSBOMComponentsInput struct { + SBOMID string + ReleaseID string + ArtifactID string + Query string + PURL string + Limit int +} + +func (s releaseEvidenceService) ListSBOMComponents(ctx context.Context, actor domain.Actor, in ListSBOMComponentsInput) ([]domain.SBOMComponentRecord, error) { + l := s.ledger + if err := ctx.Err(); err != nil { + return nil, err + } + if err := require(actor, ScopeEvidenceRead); err != nil { + return nil, err + } + in.SBOMID = strings.TrimSpace(in.SBOMID) + in.ReleaseID = strings.TrimSpace(in.ReleaseID) + in.ArtifactID = strings.TrimSpace(in.ArtifactID) + in.Query = strings.ToLower(strings.TrimSpace(in.Query)) + in.PURL = strings.TrimSpace(in.PURL) + if in.Limit < 0 || in.Limit > 500 { + return nil, ErrValidation + } + if in.Limit == 0 { + in.Limit = 100 + } + l.mu.Lock() + defer l.mu.Unlock() + if in.SBOMID != "" { + sbom, ok := l.sboms[in.SBOMID] + if !ok || sbom.TenantID != actor.TenantID { + return nil, ErrNotFound + } + } + ids := make([]string, 0, len(l.sboms)) + for id := range l.sboms { + ids = append(ids, id) + } + sort.Strings(ids) + out := make([]domain.SBOMComponentRecord, 0) + for _, id := range ids { + sbom := l.sboms[id] + if sbom.TenantID != actor.TenantID { + continue + } + if in.SBOMID != "" && sbom.ID != in.SBOMID { + continue + } + if in.ReleaseID != "" && sbom.ReleaseID != in.ReleaseID { + continue + } + if in.ArtifactID != "" && sbom.ArtifactID != in.ArtifactID { + continue + } + if err := l.authorizeResourceLocked(actor, ScopeEvidenceRead, resourceRefs{ReleaseID: sbom.ReleaseID, ArtifactID: sbom.ArtifactID}); err != nil { + return nil, err + } + for _, component := range sbom.Components { + if !sbomComponentMatches(component, in.Query, in.PURL) { + continue + } + out = append(out, domain.SBOMComponentRecord{SBOMID: sbom.ID, ReleaseID: sbom.ReleaseID, ArtifactID: sbom.ArtifactID, Format: sbom.Format, SpecVersion: sbom.SpecVersion, Component: component}) + if len(out) >= in.Limit { + return out, nil + } + } + } + return out, nil +} + +func sbomComponentMatches(component domain.SBOMComponent, query, purl string) bool { + if purl != "" && component.PURL != purl { + return false + } + if query == "" { + return true + } + haystack := strings.ToLower(component.Name + "\n" + component.Version + "\n" + component.PURL) + return strings.Contains(haystack, query) +} + +func (s releaseEvidenceService) GetVulnerabilityScan(ctx context.Context, actor domain.Actor, id string) (domain.VulnerabilityScan, error) { + l := s.ledger if err := ctx.Err(); err != nil { return domain.VulnerabilityScan{}, err } @@ -1143,10 +1484,14 @@ func (l *Ledger) GetVulnerabilityScan(ctx context.Context, actor domain.Actor, i if !ok || scan.TenantID != actor.TenantID { return domain.VulnerabilityScan{}, ErrNotFound } + if err := l.authorizeResourceLocked(actor, ScopeEvidenceRead, resourceRefs{ReleaseID: scan.ReleaseID}); err != nil { + return domain.VulnerabilityScan{}, err + } return scan, nil } -func (l *Ledger) GetOpenAPIContract(ctx context.Context, actor domain.Actor, id string) (domain.OpenAPIContract, error) { +func (s releaseEvidenceService) GetOpenAPIContract(ctx context.Context, actor domain.Actor, id string) (domain.OpenAPIContract, error) { + l := s.ledger if err := ctx.Err(); err != nil { return domain.OpenAPIContract{}, err } @@ -1159,6 +1504,9 @@ func (l *Ledger) GetOpenAPIContract(ctx context.Context, actor domain.Actor, id if !ok || contract.TenantID != actor.TenantID { return domain.OpenAPIContract{}, ErrNotFound } + if err := l.authorizeResourceLocked(actor, ScopeEvidenceRead, resourceRefs{ProductID: contract.ProductID, ReleaseID: contract.ReleaseID}); err != nil { + return domain.OpenAPIContract{}, err + } return contract, nil } @@ -1175,12 +1523,18 @@ func (l *Ledger) VerifySubject(ctx context.Context, actor domain.Actor, subjectT result := "passed" switch strings.TrimSpace(subjectType) { case "audit_chain": + if err := l.authorizeResourceLocked(actor, ScopeVerifyRead, resourceRefs{}); err != nil { + return domain.VerificationResult{}, err + } checks = l.verifyChainLocked(actor.TenantID) case "evidence_item": item, ok := l.evidence[strings.TrimSpace(subjectID)] if !ok || item.TenantID != actor.TenantID { return domain.VerificationResult{}, ErrNotFound } + if err := l.authorizeResourceLocked(actor, ScopeVerifyRead, refsForEvidence(item)); err != nil { + return domain.VerificationResult{}, err + } hash, err := canonicalHash(item) if err != nil || hash != item.CanonicalHash { result = "failed" @@ -1193,6 +1547,9 @@ func (l *Ledger) VerifySubject(ctx context.Context, actor domain.Actor, subjectT if !ok || bundle.TenantID != actor.TenantID { return domain.VerificationResult{}, ErrNotFound } + if err := l.authorizeResourceLocked(actor, ScopeVerifyRead, resourceRefs{ReleaseID: bundle.ReleaseID}); err != nil { + return domain.VerificationResult{}, err + } hash, err := canonicalAnyHash(bundle.Manifest) if err != nil || hash != bundle.ManifestHash { result = "failed" @@ -1211,6 +1568,9 @@ func (l *Ledger) VerifySubject(ctx context.Context, actor domain.Actor, subjectT if !ok || sig.TenantID != actor.TenantID { return domain.VerificationResult{}, ErrNotFound } + if err := l.authorizeResourceLocked(actor, ScopeVerifyRead, resourceRefs{ArtifactID: sig.ArtifactID}); err != nil { + return domain.VerificationResult{}, err + } artifact, ok := l.artifacts[sig.ArtifactID] if !ok || artifact.TenantID != actor.TenantID { return domain.VerificationResult{}, ErrNotFound @@ -1237,10 +1597,15 @@ func (l *Ledger) VerifySubject(ctx context.Context, actor domain.Actor, subjectT } vr := domain.VerificationResult{ID: newID("vr"), TenantID: actor.TenantID, SubjectType: subjectType, SubjectID: subjectID, Result: result, Checks: checks, VerifiedAt: l.now()} l.verifications[vr.ID] = vr - if err := l.enqueue(ctx, actor.TenantID, "verify_subject", subjectType, subjectID, map[string]any{"result_id": vr.ID}); err != nil { - return domain.VerificationResult{}, err + job := l.newOutboxJob(actor.TenantID, "verify_subject", subjectType, subjectID, map[string]any{"result_id": vr.ID}) + mutation := l.criticalMutationLocked() + mutation.OutboxJobs = append(mutation.OutboxJobs, job) + if _, ok := l.store.(CriticalMutationStore); !ok { + if err := l.enqueueJob(ctx, job); err != nil { + return domain.VerificationResult{}, err + } } - if err := l.persistLocked(ctx); err != nil { + if err := l.persistCriticalLocked(ctx, mutation); err != nil { return domain.VerificationResult{}, err } if result != "passed" { @@ -1289,7 +1654,8 @@ func (l *Ledger) ListSigningKeys(ctx context.Context, actor domain.Actor) ([]dom return out, nil } -func (l *Ledger) MissingEvidenceReport(ctx context.Context, actor domain.Actor, releaseID string) (map[string]any, error) { +func (s packageReportService) MissingEvidenceReport(ctx context.Context, actor domain.Actor, releaseID string) (map[string]any, error) { + l := s.ledger eval, err := l.EvaluateRelease(ctx, actor, releaseID) if err != nil && !errors.Is(err, ErrVerificationFailed) { return nil, err @@ -1319,7 +1685,7 @@ func (l *Ledger) WithIdempotency(ctx context.Context, actor domain.Actor, method return 0, nil, ErrValidation } requestHash := hashBytes(append([]byte(method+"\n"+path+"\n"), body...)) - storeKey := actor.TenantID + "\x00" + actor.KeyID + "\x00" + method + "\x00" + path + "\x00" + key + storeKey := NewIdempotencyRecordKey(actor.TenantID, idempotencyActorID(actor), method, path, key) l.mu.Lock() record, ok := l.idempotency[storeKey] l.mu.Unlock() @@ -1335,7 +1701,7 @@ func (l *Ledger) WithIdempotency(ctx context.Context, actor domain.Actor, method } l.mu.Lock() l.idempotency[storeKey] = IdempotencyRecord{RequestHash: requestHash, Status: status, Response: response, CreatedAt: l.now()} - if err := l.persistLocked(ctx); err != nil { + if err := l.persistCriticalLocked(ctx, l.criticalMutationLocked()); err != nil { l.mu.Unlock() return 0, nil, err } @@ -1343,6 +1709,61 @@ func (l *Ledger) WithIdempotency(ctx context.Context, actor domain.Actor, method return status, response, nil } +func idempotencyActorID(actor domain.Actor) string { + switch { + case strings.TrimSpace(actor.KeyID) != "": + return "api_key:" + strings.TrimSpace(actor.KeyID) + case strings.TrimSpace(actor.UserID) != "": + return "user:" + strings.TrimSpace(actor.UserID) + case strings.TrimSpace(actor.CollectorID) != "": + return "collector:" + strings.TrimSpace(actor.CollectorID) + default: + return "anonymous" + } +} + +func NewIdempotencyRecordKey(tenantID, actorID, method, path, key string) string { + parts := []string{tenantID, actorID, method, path, key} + encoded := make([]string, 0, len(parts)) + for _, part := range parts { + encoded = append(encoded, base64.RawURLEncoding.EncodeToString([]byte(part))) + } + return "v2:" + strings.Join(encoded, ".") +} + +func ParseIdempotencyRecordKey(value string) (IdempotencyRecordKey, bool) { + value = strings.TrimSpace(value) + if strings.HasPrefix(value, "v2:") { + parts := strings.Split(strings.TrimPrefix(value, "v2:"), ".") + if len(parts) != 5 { + return IdempotencyRecordKey{}, false + } + decoded := make([]string, 0, len(parts)) + for _, part := range parts { + raw, err := base64.RawURLEncoding.DecodeString(part) + if err != nil { + return IdempotencyRecordKey{}, false + } + decoded = append(decoded, string(raw)) + } + return idempotencyRecordKeyFromParts(decoded) + } + return idempotencyRecordKeyFromParts(strings.Split(value, "\x00")) +} + +func idempotencyRecordKeyFromParts(parts []string) (IdempotencyRecordKey, bool) { + if len(parts) != 5 || parts[0] == "" || parts[1] == "" || parts[4] == "" { + return IdempotencyRecordKey{}, false + } + return IdempotencyRecordKey{ + TenantID: parts[0], + ActorID: parts[1], + Method: parts[2], + Path: parts[3], + IdempotencyKey: parts[4], + }, true +} + func (l *Ledger) createAPIKeyLocked(tenantID, name string, scopes []string, expiresAt *time.Time) (domain.APIKey, string, error) { secret := "evy_" + randomToken(32) key := domain.APIKey{ID: newID("key"), TenantID: tenantID, Name: name, Prefix: secretPrefix(secret), Scopes: sortedStrings(scopes), CreatedAt: l.now(), ExpiresAt: expiresAt, Hash: l.hashSecret(secret)} @@ -1358,6 +1779,13 @@ func (l *Ledger) hashSecret(secret string) string { return hex.EncodeToString(mac.Sum(nil)) } +func secretHashEqual(stored, candidate string) bool { + if len(stored) != sha256.Size*2 || len(candidate) != sha256.Size*2 { + return false + } + return hmac.Equal([]byte(stored), []byte(candidate)) +} + func (l *Ledger) ensureScopeLocked(tenantID, productID, projectID, releaseID string) error { if productID != "" { product, ok := l.products[productID] @@ -1518,178 +1946,41 @@ func (l *Ledger) verifySignatureLocked(tenantID string, signatureRefs []string, return false } +func (l *Ledger) checkReleaseHasArtifactLocked(tenantID, releaseID string) domain.PolicyCheck { + for _, item := range l.evidence { + if item.TenantID != tenantID || item.ReleaseID != releaseID { + continue + } + for _, ref := range item.SubjectRefs { + if ref.Type == "artifact" && ref.ID != "" { + return domain.PolicyCheck{Name: "release_has_artifact", Result: "passed", Severity: "high", Explanation: "artifact evidence is linked to the release"} + } + } + } + return domain.PolicyCheck{Name: "release_has_artifact", Result: "failed", Severity: "high", Missing: []string{"artifact"}, Explanation: "release artifact evidence is missing", Remediation: "Register an artifact digest and link it to release evidence such as SBOM, scan, build output, or artifact digest evidence."} +} + func (l *Ledger) checkReleaseHasEvidenceLocked(tenantID, releaseID, typ, name, severity string) domain.PolicyCheck { for _, item := range l.evidence { if item.TenantID == tenantID && item.ReleaseID == releaseID && item.Type == typ { return domain.PolicyCheck{Name: name, Result: "passed", Severity: severity, Explanation: typ + " evidence exists"} } } - return domain.PolicyCheck{Name: name, Result: "failed", Severity: severity, Missing: []string{typ}, Explanation: typ + " evidence is missing"} + return domain.PolicyCheck{Name: name, Result: "failed", Severity: severity, Missing: []string{typ}, Explanation: typ + " evidence is missing", Remediation: "Upload " + typ + " evidence for this release."} } func (l *Ledger) checkNoOpenCriticalLocked(tenantID, releaseID string) domain.PolicyCheck { blocking := l.unhandledCriticalFindingsLocked(tenantID, releaseID) if len(blocking) > 0 { - return domain.PolicyCheck{Name: "critical_exploitable_blocks_release", Result: "failed", Severity: "critical", Missing: []string{"vulnerability_decision"}, Explanation: "open critical finding requires remediation, a valid VEX decision, or an approved unexpired exception"} + return domain.PolicyCheck{Name: "critical_exploitable_blocks_release", Result: "failed", Severity: "critical", Missing: []string{"vulnerability_decision"}, Explanation: "open critical finding requires remediation, a valid VEX decision, or an approved unexpired exception", Remediation: "Record a fixed or not_affected vulnerability decision, upload VEX evidence, remediate the finding, or approve an unexpired scoped exception."} } return domain.PolicyCheck{Name: "critical_exploitable_blocks_release", Result: "passed", Severity: "critical", Explanation: "no open critical findings recorded"} } -func require(actor domain.Actor, scope string) error { - if actor.TenantID == "" || (actor.KeyID == "" && actor.UserID == "" && actor.CollectorID == "") { - return ErrUnauthorized - } - if actor.HasScope(scope) || actor.HasScope(ScopeAdmin) { - return nil - } - return ErrForbidden -} - -func canonicalHash(item domain.EvidenceItem) (string, error) { - item.CanonicalHash = "" - item.ChainEntryID = "" - item.SignatureRefs = nil - return canonicalAnyHash(item) -} - -func canonicalAnyHash(v any) (string, error) { - body, err := json.Marshal(v) - if err != nil { - return "", err - } - var normalized any - if err := json.Unmarshal(body, &normalized); err != nil { - return "", err - } - body, err = json.Marshal(normalized) - if err != nil { - return "", err - } - return hashBytes(body), nil -} - -func hashBytes(body []byte) string { - sum := sha256.Sum256(body) - return "sha256:" + hex.EncodeToString(sum[:]) -} - -func validDigest(value string) bool { - if !strings.HasPrefix(value, "sha256:") { - return false - } - _, err := hex.DecodeString(strings.TrimPrefix(value, "sha256:")) - return err == nil && len(strings.TrimPrefix(value, "sha256:")) == 64 -} - -func newID(prefix string) string { - var b [16]byte - if _, err := rand.Read(b[:]); err != nil { - panic(err) - } - return prefix + "_" + hex.EncodeToString(b[:]) -} - -func randomToken(n int) string { - buf := make([]byte, n) - if _, err := rand.Read(buf); err != nil { - panic(err) - } - return base64.RawURLEncoding.EncodeToString(buf) -} - -func secretPrefix(secret string) string { - if len(secret) <= 12 { - return secret - } - return secret[:12] -} - -func sortedStrings(in []string) []string { - out := append([]string(nil), in...) - for i := range out { - out[i] = strings.TrimSpace(out[i]) - } - sort.Strings(out) - return out -} - -func cloneMap(in map[string]any) map[string]any { - if len(in) == 0 { - return nil - } - out := map[string]any{} - for k, v := range in { - out[k] = v - } - return out -} - -func nonEmpty(value, fallback string) string { - value = strings.TrimSpace(value) - if value == "" { - return fallback - } - return value -} - -func subjectForArtifact(artifactID string) []domain.SubjectRef { - if strings.TrimSpace(artifactID) == "" { - return nil - } - return []domain.SubjectRef{{Type: "artifact", ID: artifactID}} -} - -func IsValidation(err error) bool { - return errors.Is(err, ErrValidation) -} - -func ProblemCode(err error) string { - switch { - case errors.Is(err, ErrUnauthorized): - return "UNAUTHORIZED" - case errors.Is(err, ErrForbidden): - return "FORBIDDEN" - case errors.Is(err, ErrNotFound): - return "NOT_FOUND" - case errors.Is(err, ErrConflict): - return "CONFLICT" - case errors.Is(err, ErrImmutable): - return "EVIDENCE_IMMUTABLE" - case errors.Is(err, ErrIdempotencyConflict): - return "IDEMPOTENCY_KEY_REUSED" - case errors.Is(err, ErrVerificationFailed): - return "VERIFICATION_FAILED" - case errors.Is(err, ErrValidation): - return "VALIDATION_FAILED" - default: - return "INTERNAL_ERROR" - } -} - -func StatusCode(err error) int { - switch { - case errors.Is(err, ErrUnauthorized): - return 401 - case errors.Is(err, ErrForbidden): - return 403 - case errors.Is(err, ErrNotFound): - return 404 - case errors.Is(err, ErrConflict), errors.Is(err, ErrImmutable), errors.Is(err, ErrIdempotencyConflict): - return 409 - case errors.Is(err, ErrValidation): - return 400 - case errors.Is(err, ErrVerificationFailed): - return 422 - default: - return 500 - } -} - -func SafeErrorDetail(err error) string { - switch StatusCode(err) { - case 500: - return "internal server error" - default: - return fmt.Sprintf("%s", err) +func (l *Ledger) checkNoOpenHighLocked(tenantID, releaseID string) domain.PolicyCheck { + blocking := l.unhandledFindingsBySeverityLocked(tenantID, releaseID, "high") + if len(blocking) > 0 { + return domain.PolicyCheck{Name: "high_findings_require_triage", Result: "failed", Severity: "high", Missing: []string{"vulnerability_decision"}, Explanation: "open high finding requires a valid decision, remediation, or an approved unexpired exception", Remediation: "Record a fixed or not_affected vulnerability decision, upload VEX evidence, remediate the finding, or approve an unexpired scoped exception."} } + return domain.PolicyCheck{Name: "high_findings_require_triage", Result: "passed", Severity: "high", Explanation: "no unhandled open high findings recorded"} } diff --git a/internal/app/ledger_helpers.go b/internal/app/ledger_helpers.go new file mode 100644 index 0000000..c3aa695 --- /dev/null +++ b/internal/app/ledger_helpers.go @@ -0,0 +1,209 @@ +package app + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "sort" + "strings" + + "github.com/aatuh/evydence/internal/domain" +) + +func require(actor domain.Actor, scope string) error { + if actor.TenantID == "" || (actor.KeyID == "" && actor.UserID == "" && actor.CollectorID == "") { + return ErrUnauthorized + } + if requiresExplicitScope(scope) { + if actorHasExactScope(actor, scope) { + return nil + } + return ErrForbidden + } + if actor.HasScope(scope) || actor.HasScope(ScopeAdmin) { + return nil + } + return ErrForbidden +} + +func requireGrantableScopes(actor domain.Actor, scopes []string) error { + for _, scope := range scopes { + scope = strings.TrimSpace(scope) + if requiresExplicitScope(scope) && !actorHasExactScope(actor, scope) { + return ErrForbidden + } + } + return nil +} + +func requiresExplicitScope(scope string) bool { + return scope == ScopeInstanceAdmin +} + +func actorHasExactScope(actor domain.Actor, scope string) bool { + for _, got := range actor.Scopes { + if got == scope { + return true + } + } + return false +} + +func canonicalHash(item domain.EvidenceItem) (string, error) { + item.CanonicalHash = "" + item.ChainEntryID = "" + item.SignatureRefs = nil + return canonicalAnyHash(item) +} + +func canonicalAnyHash(v any) (string, error) { + body, err := json.Marshal(v) + if err != nil { + return "", err + } + var normalized any + if err := json.Unmarshal(body, &normalized); err != nil { + return "", err + } + body, err = json.Marshal(normalized) + if err != nil { + return "", err + } + return hashBytes(body), nil +} + +func hashBytes(body []byte) string { + // codeql[go/weak-sensitive-data-hashing] SHA-256 is required here for + // content-addressed evidence digests, not password or bearer-token storage. + sum := sha256.Sum256(body) + return "sha256:" + hex.EncodeToString(sum[:]) +} + +func validDigest(value string) bool { + if !strings.HasPrefix(value, "sha256:") { + return false + } + _, err := hex.DecodeString(strings.TrimPrefix(value, "sha256:")) + return err == nil && len(strings.TrimPrefix(value, "sha256:")) == 64 +} + +func newID(prefix string) string { + var b [16]byte + if _, err := rand.Read(b[:]); err != nil { + panic(err) + } + return prefix + "_" + hex.EncodeToString(b[:]) +} + +func randomToken(n int) string { + buf := make([]byte, n) + if _, err := rand.Read(buf); err != nil { + panic(err) + } + return base64.RawURLEncoding.EncodeToString(buf) +} + +func secretPrefix(secret string) string { + if len(secret) <= 12 { + return secret + } + return secret[:12] +} + +func sortedStrings(in []string) []string { + out := append([]string(nil), in...) + for i := range out { + out[i] = strings.TrimSpace(out[i]) + } + sort.Strings(out) + return out +} + +func cloneMap(in map[string]any) map[string]any { + if len(in) == 0 { + return nil + } + out := map[string]any{} + for k, v := range in { + out[k] = v + } + return out +} + +func nonEmpty(value, fallback string) string { + value = strings.TrimSpace(value) + if value == "" { + return fallback + } + return value +} + +func subjectForArtifact(artifactID string) []domain.SubjectRef { + if strings.TrimSpace(artifactID) == "" { + return nil + } + return []domain.SubjectRef{{Type: "artifact", ID: artifactID}} +} + +func IsValidation(err error) bool { + return errors.Is(err, ErrValidation) +} + +func ProblemCode(err error) string { + switch { + case errors.Is(err, ErrUnauthorized): + return "UNAUTHORIZED" + case errors.Is(err, ErrForbidden): + return "FORBIDDEN" + case errors.Is(err, ErrNotFound): + return "NOT_FOUND" + case errors.Is(err, ErrConflict): + return "CONFLICT" + case errors.Is(err, ErrImmutable): + return "EVIDENCE_IMMUTABLE" + case errors.Is(err, ErrIdempotencyConflict): + return "IDEMPOTENCY_KEY_REUSED" + case errors.Is(err, ErrVerificationFailed): + return "VERIFICATION_FAILED" + case errors.Is(err, ErrRateLimited): + return "RATE_LIMITED" + case errors.Is(err, ErrValidation): + return "VALIDATION_FAILED" + default: + return "INTERNAL_ERROR" + } +} + +func StatusCode(err error) int { + switch { + case errors.Is(err, ErrUnauthorized): + return 401 + case errors.Is(err, ErrForbidden): + return 403 + case errors.Is(err, ErrNotFound): + return 404 + case errors.Is(err, ErrConflict), errors.Is(err, ErrImmutable), errors.Is(err, ErrIdempotencyConflict): + return 409 + case errors.Is(err, ErrValidation): + return 400 + case errors.Is(err, ErrVerificationFailed): + return 422 + case errors.Is(err, ErrRateLimited): + return 429 + default: + return 500 + } +} + +func SafeErrorDetail(err error) string { + switch StatusCode(err) { + case 500: + return "internal server error" + default: + return fmt.Sprintf("%s", err) + } +} diff --git a/internal/app/ledger_test.go b/internal/app/ledger_test.go index 3f9dfa4..3c95c06 100644 --- a/internal/app/ledger_test.go +++ b/internal/app/ledger_test.go @@ -12,6 +12,15 @@ import ( "github.com/aatuh/evydence/internal/domain" ) +type recordingOutbox struct { + jobs []OutboxJob +} + +func (r *recordingOutbox) Enqueue(_ context.Context, job OutboxJob) error { + r.jobs = append(r.jobs, job) + return nil +} + func TestTenantScopedEvidenceAndAPIKeyAuth(t *testing.T) { ledger := NewLedger(Config{APIKeyPepper: "test-pepper", Now: fixedNow}) ctx := context.Background() @@ -47,202 +56,1257 @@ func TestTenantScopedEvidenceAndAPIKeyAuth(t *testing.T) { PayloadHash: sampleDigest("build"), }) if err != nil { - t.Fatalf("create evidence: %v", err) + t.Fatalf("create evidence: %v", err) + } + if _, err := ledger.GetEvidence(ctx, actorB, item.ID); !errors.Is(err, ErrNotFound) { + t.Fatalf("cross-tenant read err = %v, want not found", err) + } + keys, err := ledger.ListAPIKeys(ctx, actorA) + if err != nil { + t.Fatalf("list keys: %v", err) + } + for _, key := range keys { + if key.Hash != "" { + t.Fatal("API key hash leaked in list response") + } + } + if strings.Contains(secretA, keys[0].Prefix) && keys[0].Prefix == secretA { + t.Fatal("full API key secret leaked as prefix") + } +} + +func TestScopedAPIKeyCannotWriteEvidence(t *testing.T) { + ledger := NewLedger(Config{APIKeyPepper: "test-pepper", Now: fixedNow}) + ctx := context.Background() + _, _, adminSecret, err := ledger.BootstrapTenant(ctx, "Tenant", "admin", []string{"*"}) + if err != nil { + t.Fatalf("bootstrap: %v", err) + } + admin, err := ledger.Authenticate(ctx, adminSecret) + if err != nil { + t.Fatalf("auth admin: %v", err) + } + _, readerSecret, err := ledger.CreateAPIKey(ctx, admin, "reader", []string{ScopeEvidenceRead}, nil) + if err != nil { + t.Fatalf("create reader: %v", err) + } + reader, err := ledger.Authenticate(ctx, readerSecret) + if err != nil { + t.Fatalf("auth reader: %v", err) + } + _, err = ledger.CreateEvidence(ctx, reader, CreateEvidenceInput{Type: "build", Title: "Build", PayloadHash: sampleDigest("x")}) + if !errors.Is(err, ErrForbidden) { + t.Fatalf("reader create evidence err = %v, want forbidden", err) + } +} + +func TestUploadSBOMEnqueuesParserVersion(t *testing.T) { + outbox := &recordingOutbox{} + ledger := NewLedger(Config{APIKeyPepper: "test-pepper", Now: fixedNow, Outbox: outbox}) + ctx := context.Background() + _, _, secret, err := ledger.BootstrapTenant(ctx, "Tenant", "admin", []string{"*"}) + if err != nil { + t.Fatalf("bootstrap: %v", err) + } + actor, err := ledger.Authenticate(ctx, secret) + if err != nil { + t.Fatalf("auth: %v", err) + } + product, err := ledger.CreateProduct(ctx, actor, "Payments API", "payments-parser-version") + if err != nil { + t.Fatalf("create product: %v", err) + } + release, err := ledger.CreateRelease(ctx, actor, product.ID, "1.0.0") + if err != nil { + t.Fatalf("create release: %v", err) + } + artifact, err := ledger.RegisterArtifact(ctx, actor, "api.tar.gz", "application/gzip", sampleDigest("api"), 42) + if err != nil { + t.Fatalf("artifact: %v", err) + } + if _, err := ledger.UploadSBOM(ctx, actor, release.ID, artifact.ID, []byte(`{"bomFormat":"CycloneDX","specVersion":"1.6","components":[{"type":"application","name":"api"}]}`)); err != nil { + t.Fatalf("upload sbom: %v", err) + } + if len(outbox.jobs) != 1 { + t.Fatalf("outbox jobs = %d, want 1", len(outbox.jobs)) + } + job := outbox.jobs[0] + if job.Kind != "parse_sbom" || job.Payload["parser_version"] != ParserVersionCycloneDXJSON { + t.Fatalf("outbox job = %#v", job) + } +} + +func TestReleaseSecuritySummaryIsTenantScopedAndRedacted(t *testing.T) { + ledger := NewLedger(Config{APIKeyPepper: "test-pepper", Now: fixedNow}) + ctx := context.Background() + actor, release, artifact := setupReleaseRiskFixture(t, ledger) + if _, err := ledger.UploadSBOM(ctx, actor, release.ID, artifact.ID, []byte(`{"bomFormat":"CycloneDX","specVersion":"1.6","components":[{"name":"openssl","purl":"pkg:apk/openssl@3.1.0"}]}`)); err != nil { + t.Fatalf("sbom: %v", err) + } + scan, err := ledger.UploadVulnerabilityScan(ctx, actor, []byte(`{ + "scanner":"grype", + "target_ref":"pkg:oci/payments-api", + "release_id":"`+release.ID+`", + "findings":[{"vulnerability":"CVE-2026-0099","component":"pkg:apk/openssl@3.1.0","severity":"critical","state":"open"}] + }`)) + if err != nil { + t.Fatalf("scan: %v", err) + } + addBuildProvenance(t, ledger, actor, release, artifact) + if _, err := ledger.CreateReleaseBundle(ctx, actor, release.ID); err != nil { + t.Fatalf("bundle: %v", err) + } + + summary, err := ledger.ReleaseSecuritySummary(ctx, actor, release.ID) + if err != nil { + t.Fatalf("summary: %v", err) + } + if summary.SchemaVersion != domain.ReleaseSecuritySummaryVersion || summary.SBOMStatus != "present" || summary.VulnerabilityScanStatus != "present" { + t.Fatalf("summary basics missing: %#v", summary) + } + if summary.OpenFindingsBySeverity["critical"] != 1 || len(summary.MissingRequiredDecisions) != 1 || summary.ReadinessStatus != "failed" { + t.Fatalf("summary should show unhandled critical finding: %#v", summary) + } + body, err := json.Marshal(summary) + if err != nil { + t.Fatalf("marshal summary: %v", err) + } + for _, forbidden := range []string{"payload_ref", "payload_hash", "internal_notes", "secret", "token"} { + if strings.Contains(string(body), forbidden) { + t.Fatalf("summary leaked %q: %s", forbidden, body) + } + } + + if _, err := ledger.CreateVulnerabilityDecision(ctx, actor, scan.Findings[0].ID, CreateVulnerabilityDecisionInput{Status: decisionStatusNotAffected, Justification: "vulnerable code is not present"}); err != nil { + t.Fatalf("decision: %v", err) + } + summary, err = ledger.ReleaseSecuritySummary(ctx, actor, release.ID) + if err != nil { + t.Fatalf("summary after decision: %v", err) + } + if len(summary.MissingRequiredDecisions) != 0 || summary.DecisionsByStatus[decisionStatusNotAffected] != 1 || summary.ReadinessStatus != "passed" { + t.Fatalf("summary should reflect accepted decision: %#v", summary) + } + + _, readerSecret, err := ledger.CreateAPIKey(ctx, actor, "release-reader", []string{ScopeReleaseRead}, nil) + if err != nil { + t.Fatalf("reader key: %v", err) + } + reader, err := ledger.Authenticate(ctx, readerSecret) + if err != nil { + t.Fatalf("reader auth: %v", err) + } + if _, err := ledger.ReleaseSecuritySummary(ctx, reader, release.ID); !errors.Is(err, ErrForbidden) { + t.Fatalf("wrong-scope summary err = %v, want forbidden", err) + } + + _, _, otherSecret, err := ledger.BootstrapTenant(ctx, "Other Tenant", "admin", []string{"*"}) + if err != nil { + t.Fatalf("other bootstrap: %v", err) + } + other, err := ledger.Authenticate(ctx, otherSecret) + if err != nil { + t.Fatalf("other auth: %v", err) + } + if _, err := ledger.ReleaseSecuritySummary(ctx, other, release.ID); !errors.Is(err, ErrNotFound) { + t.Fatalf("cross-tenant summary err = %v, want not found", err) + } +} + +func TestUploadSBOMCanDeferParserSideEffectsToWorker(t *testing.T) { + outbox := &recordingOutbox{} + store := NewMemoryStore() + objects := newTestObjectStore() + ledger := NewLedger(Config{ + APIKeyPepper: "test-pepper", + Now: fixedNow, + Store: store, + ObjectStore: objects, + Outbox: outbox, + WorkerOwnedParserSideEffects: true, + }) + ctx := context.Background() + _, _, secret, err := ledger.BootstrapTenant(ctx, "Tenant", "admin", []string{"*"}) + if err != nil { + t.Fatalf("bootstrap: %v", err) + } + actor, err := ledger.Authenticate(ctx, secret) + if err != nil { + t.Fatalf("auth: %v", err) + } + product, err := ledger.CreateProduct(ctx, actor, "Payments API", "payments-worker-parser") + if err != nil { + t.Fatalf("create product: %v", err) + } + release, err := ledger.CreateRelease(ctx, actor, product.ID, "1.0.0") + if err != nil { + t.Fatalf("create release: %v", err) + } + artifact, err := ledger.RegisterArtifact(ctx, actor, "api.tar.gz", "application/gzip", sampleDigest("api"), 42) + if err != nil { + t.Fatalf("artifact: %v", err) + } + + sbom, err := ledger.UploadSBOM(ctx, actor, release.ID, artifact.ID, []byte(`{"bomFormat":"CycloneDX","specVersion":"1.6","components":[{"name":"api","purl":"pkg:oci/api"}]}`)) + if err != nil { + t.Fatalf("upload sbom: %v", err) + } + if sbom.SpecVersion != "1.6" || sbom.ComponentCount != 1 || len(sbom.Components) != 1 { + t.Fatalf("upload response should keep parsed fields: %#v", sbom) + } + + state, ok, err := store.LoadState(ctx) + if err != nil || !ok { + t.Fatalf("load state ok=%v err=%v", ok, err) + } + persisted := state.SBOMs[sbom.ID] + if persisted.SpecVersion != "" || persisted.ComponentCount != 0 || len(persisted.Components) != 0 { + t.Fatalf("persisted sbom should wait for worker parser side effects: %#v", persisted) + } + if len(outbox.jobs) != 1 { + t.Fatalf("outbox jobs = %d, want 1", len(outbox.jobs)) + } + job := outbox.jobs[0] + if job.Kind != "parse_sbom" || job.Payload["payload_ref"] == "" || job.Payload["payload_hash"] == "" { + t.Fatalf("outbox job missing replay metadata: %#v", job) + } + payloadRef, ok := job.Payload["payload_ref"].(string) + payloadKey := strings.TrimPrefix(payloadRef, "object://") + if !ok || !strings.HasPrefix(payloadKey, "tenants/"+actor.TenantID+"/") { + t.Fatalf("payload ref %q is not tenant-prefixed", job.Payload["payload_ref"]) + } + if _, err := objects.Get(ctx, payloadKey); err != nil { + t.Fatalf("stored payload missing: %v", err) + } +} + +func TestUploadVulnerabilityScanCanDeferParserSideEffectsToWorker(t *testing.T) { + outbox := &recordingOutbox{} + store := NewMemoryStore() + objects := newTestObjectStore() + ledger := NewLedger(Config{ + APIKeyPepper: "test-pepper", + Now: fixedNow, + Store: store, + ObjectStore: objects, + Outbox: outbox, + WorkerOwnedParserSideEffects: true, + }) + ctx := context.Background() + _, _, secret, err := ledger.BootstrapTenant(ctx, "Tenant", "admin", []string{"*"}) + if err != nil { + t.Fatalf("bootstrap: %v", err) + } + actor, err := ledger.Authenticate(ctx, secret) + if err != nil { + t.Fatalf("auth: %v", err) + } + product, err := ledger.CreateProduct(ctx, actor, "Payments API", "payments-worker-scan") + if err != nil { + t.Fatalf("create product: %v", err) + } + release, err := ledger.CreateRelease(ctx, actor, product.ID, "1.0.0") + if err != nil { + t.Fatalf("create release: %v", err) + } + + scan, err := ledger.UploadVulnerabilityScan(ctx, actor, []byte(`{"scanner":"grype","target_ref":"pkg:oci/api","release_id":"`+release.ID+`","findings":[{"vulnerability":"CVE-2026-0001","component":"api","severity":"critical","state":"open"}]}`)) + if err != nil { + t.Fatalf("upload scan: %v", err) + } + if scan.Scanner != "grype" || scan.Summary["critical"] != 1 || len(scan.Findings) != 1 || !strings.HasPrefix(scan.Findings[0].ID, scan.ID+":finding:") { + t.Fatalf("upload response should keep parsed scan fields with stable finding ids: %#v", scan) + } + + state, ok, err := store.LoadState(ctx) + if err != nil || !ok { + t.Fatalf("load state ok=%v err=%v", ok, err) + } + persisted := state.Scans[scan.ID] + if persisted.Scanner != "" || persisted.TargetRef != "" || persisted.Summary != nil || len(persisted.Findings) != 0 { + t.Fatalf("persisted scan should wait for worker parser side effects: %#v", persisted) + } + if len(outbox.jobs) != 1 { + t.Fatalf("outbox jobs = %d, want 1", len(outbox.jobs)) + } + job := outbox.jobs[0] + if job.Kind != "parse_vulnerability_scan" || job.Payload["payload_ref"] == "" || job.Payload["payload_hash"] == "" || job.Payload["parser_version"] != ParserVersionGenericVulnerabilityJSON { + t.Fatalf("outbox job missing replay metadata: %#v", job) + } + payloadRef, ok := job.Payload["payload_ref"].(string) + payloadKey := strings.TrimPrefix(payloadRef, "object://") + if !ok || !strings.HasPrefix(payloadKey, "tenants/"+actor.TenantID+"/") { + t.Fatalf("payload ref %q is not tenant-prefixed", job.Payload["payload_ref"]) + } + if _, err := objects.Get(ctx, payloadKey); err != nil { + t.Fatalf("stored payload missing: %v", err) + } +} + +func TestUploadOpenAPIContractCanDeferParserSideEffectsToWorker(t *testing.T) { + outbox := &recordingOutbox{} + store := NewMemoryStore() + objects := newTestObjectStore() + ledger := NewLedger(Config{ + APIKeyPepper: "test-pepper", + Now: fixedNow, + Store: store, + ObjectStore: objects, + Outbox: outbox, + WorkerOwnedParserSideEffects: true, + }) + ctx := context.Background() + _, _, secret, err := ledger.BootstrapTenant(ctx, "Tenant", "admin", []string{"*"}) + if err != nil { + t.Fatalf("bootstrap: %v", err) + } + actor, err := ledger.Authenticate(ctx, secret) + if err != nil { + t.Fatalf("auth: %v", err) + } + product, err := ledger.CreateProduct(ctx, actor, "Payments API", "payments-worker-openapi") + if err != nil { + t.Fatalf("create product: %v", err) + } + release, err := ledger.CreateRelease(ctx, actor, product.ID, "1.0.0") + if err != nil { + t.Fatalf("create release: %v", err) + } + + contract, err := ledger.UploadOpenAPIContract(ctx, actor, product.ID, release.ID, "v1", []byte(`{"openapi":"3.1.0","info":{"title":"API","version":"1"},"paths":{"/v1/a":{"get":{"responses":{"200":{"description":"ok"}}}}}}`)) + if err != nil { + t.Fatalf("upload contract: %v", err) + } + if contract.PathCount != 1 || len(contract.Operations) != 1 { + t.Fatalf("upload response should keep parsed contract fields: %#v", contract) + } + + state, ok, err := store.LoadState(ctx) + if err != nil || !ok { + t.Fatalf("load state ok=%v err=%v", ok, err) + } + persisted := state.Contracts[contract.ID] + if persisted.PathCount != 0 || len(persisted.Operations) != 0 || persisted.Hash == "" { + t.Fatalf("persisted contract should wait for worker parser side effects but keep hash: %#v", persisted) + } + if len(outbox.jobs) != 1 { + t.Fatalf("outbox jobs = %d, want 1", len(outbox.jobs)) + } + job := outbox.jobs[0] + if job.Kind != "parse_openapi_contract" || job.Payload["payload_ref"] == "" || job.Payload["payload_hash"] == "" || job.Payload["parser_version"] != ParserVersionOpenAPIJSON { + t.Fatalf("outbox job missing replay metadata: %#v", job) + } + payloadRef, ok := job.Payload["payload_ref"].(string) + payloadKey := strings.TrimPrefix(payloadRef, "object://") + if !ok || !strings.HasPrefix(payloadKey, "tenants/"+actor.TenantID+"/") { + t.Fatalf("payload ref %q is not tenant-prefixed", job.Payload["payload_ref"]) + } + if _, err := objects.Get(ctx, payloadKey); err != nil { + t.Fatalf("stored payload missing: %v", err) + } +} + +func TestUploadBuildAttestationCanDeferParserSideEffectsToWorker(t *testing.T) { + outbox := &recordingOutbox{} + store := NewMemoryStore() + objects := newTestObjectStore() + ledger := NewLedger(Config{ + APIKeyPepper: "test-pepper", + Now: fixedNow, + Store: store, + ObjectStore: objects, + Outbox: outbox, + WorkerOwnedParserSideEffects: true, + }) + ctx := context.Background() + actor, release, artifact := setupReleaseRiskFixture(t, ledger) + project, err := ledger.CreateProject(ctx, actor, release.ProductID, "api") + if err != nil { + t.Fatalf("project: %v", err) + } + _, _, secret, err := ledger.CreateCollector(ctx, actor, CreateCollectorInput{Name: "gha", Type: "github_actions", Version: "1.0.0"}) + if err != nil { + t.Fatalf("collector: %v", err) + } + collectorActor, err := ledger.Authenticate(ctx, secret) + if err != nil { + t.Fatalf("auth collector: %v", err) + } + build, err := ledger.CreateBuildRun(ctx, collectorActor, CreateBuildRunInput{ + ProjectID: project.ID, + ReleaseID: release.ID, + Provider: "github_actions", + CommitSHA: "0123456789abcdef0123456789abcdef01234567", + Repository: "aatuh/evydence", + WorkflowRef: "aatuh/evydence/.github/workflows/release.yml@refs/heads/main", + RunID: "123456789", + RunAttempt: 1, + Status: "passed", + StartedAt: fixedNow(), + Outputs: []domain.BuildOutput{{ArtifactID: artifact.ID, Digest: artifact.Digest}}, + }) + if err != nil { + t.Fatalf("build: %v", err) + } + + attestation, err := ledger.UploadBuildAttestation(ctx, collectorActor, build.ID, dsseForDigest(t, artifact.Digest)) + if err != nil { + t.Fatalf("attestation: %v", err) + } + if attestation.PredicateType == "" || len(attestation.SubjectDigests) != 1 || attestation.SignatureCount != 1 || attestation.VerificationStatus != "structurally_valid" { + t.Fatalf("upload response should keep parsed attestation fields: %#v", attestation) + } + + state, ok, err := store.LoadState(ctx) + if err != nil || !ok { + t.Fatalf("load state ok=%v err=%v", ok, err) + } + persisted := state.BuildAttestations[attestation.ID] + if persisted.TenantID != actor.TenantID || persisted.PayloadHash == "" || persisted.PayloadSize == 0 || persisted.VerificationStatus != "accepted" { + t.Fatalf("persisted attestation should keep accepted metadata: %#v", persisted) + } + if persisted.PredicateType != "" || len(persisted.SubjectDigests) != 0 || persisted.SignatureCount != 0 { + t.Fatalf("persisted attestation should wait for worker parser side effects: %#v", persisted) + } + if len(outbox.jobs) != 1 { + t.Fatalf("outbox jobs = %d, want 1", len(outbox.jobs)) + } + job := outbox.jobs[0] + if job.Kind != "verify_attestation" || job.Payload["payload_ref"] == "" || job.Payload["payload_hash"] == "" || job.Payload["parser_version"] != ParserVersionDSSEInTotoJSON { + t.Fatalf("outbox job missing replay metadata: %#v", job) + } + payloadRef, ok := job.Payload["payload_ref"].(string) + payloadKey := strings.TrimPrefix(payloadRef, "object://") + if !ok || !strings.HasPrefix(payloadKey, "tenants/"+actor.TenantID+"/") { + t.Fatalf("payload ref %q is not tenant-prefixed", job.Payload["payload_ref"]) + } + if _, err := objects.Get(ctx, payloadKey); err != nil { + t.Fatalf("stored payload missing: %v", err) + } +} + +func TestIdempotencyReplayAndConflict(t *testing.T) { + ledger := NewLedger(Config{APIKeyPepper: "test-pepper", Now: fixedNow}) + ctx := context.Background() + _, _, secret, err := ledger.BootstrapTenant(ctx, "Tenant", "admin", []string{"*"}) + if err != nil { + t.Fatalf("bootstrap: %v", err) + } + actor, err := ledger.Authenticate(ctx, secret) + if err != nil { + t.Fatalf("auth: %v", err) + } + calls := 0 + status, response, err := ledger.WithIdempotency(ctx, actor, "POST", "/v1/products", "idem-1", []byte(`{"name":"A"}`), func() (int, any, error) { + calls++ + return 201, map[string]string{"id": "prod_1"}, nil + }) + if err != nil || status != 201 || response == nil { + t.Fatalf("first idempotent call status=%d response=%v err=%v", status, response, err) + } + status, response, err = ledger.WithIdempotency(ctx, actor, "POST", "/v1/products", "idem-1", []byte(`{"name":"A"}`), func() (int, any, error) { + calls++ + return 201, map[string]string{"id": "prod_2"}, nil + }) + if err != nil || status != 201 || response == nil { + t.Fatalf("replay status=%d response=%v err=%v", status, response, err) + } + if calls != 1 { + t.Fatalf("calls = %d, want 1", calls) + } + _, _, err = ledger.WithIdempotency(ctx, actor, "POST", "/v1/products", "idem-1", []byte(`{"name":"B"}`), func() (int, any, error) { + return 201, nil, nil + }) + if !errors.Is(err, ErrIdempotencyConflict) { + t.Fatalf("conflict err = %v, want idempotency conflict", err) + } +} + +func TestIdempotencyIsScopedByHumanSessionActor(t *testing.T) { + ledger := NewLedger(Config{APIKeyPepper: "test-pepper", Now: fixedNow}) + ctx := context.Background() + _, _, secret, err := ledger.BootstrapTenant(ctx, "Tenant", "admin", []string{"*"}) + if err != nil { + t.Fatalf("bootstrap: %v", err) + } + admin, err := ledger.Authenticate(ctx, secret) + if err != nil { + t.Fatalf("auth admin: %v", err) + } + org, err := ledger.CreateOrganization(ctx, admin, CreateOrganizationInput{Name: "Org", Slug: "org"}) + if err != nil { + t.Fatalf("org: %v", err) + } + provider, err := ledger.CreateSSOProvider(ctx, admin, CreateSSOProviderInput{Name: "OIDC", Type: "oidc", Issuer: "https://idp.example.test", ClientID: "client"}) + if err != nil { + t.Fatalf("provider: %v", err) + } + userA, err := ledger.CreateUser(ctx, admin, CreateUserInput{OrganizationID: org.ID, Email: "a@example.test", DisplayName: "A"}) + if err != nil { + t.Fatalf("user a: %v", err) + } + userB, err := ledger.CreateUser(ctx, admin, CreateUserInput{OrganizationID: org.ID, Email: "b@example.test", DisplayName: "B"}) + if err != nil { + t.Fatalf("user b: %v", err) + } + for _, user := range []domain.HumanUser{userA, userB} { + if _, err := ledger.CreateRoleBinding(ctx, admin, CreateRoleBindingInput{SubjectType: "user", SubjectID: user.ID, Role: "security_engineer"}); err != nil { + t.Fatalf("role binding: %v", err) + } + } + _, secretA, err := ledger.CreateSSOSession(ctx, admin, CreateSSOSessionInput{UserID: userA.ID, ProviderID: provider.ID, ExpiresAt: fixedNow().Add(time.Hour)}) + if err != nil { + t.Fatalf("session a: %v", err) + } + _, secretB, err := ledger.CreateSSOSession(ctx, admin, CreateSSOSessionInput{UserID: userB.ID, ProviderID: provider.ID, ExpiresAt: fixedNow().Add(time.Hour)}) + if err != nil { + t.Fatalf("session b: %v", err) + } + actorA, err := ledger.Authenticate(ctx, secretA) + if err != nil { + t.Fatalf("auth a: %v", err) + } + actorB, err := ledger.Authenticate(ctx, secretB) + if err != nil { + t.Fatalf("auth b: %v", err) + } + if _, _, err := ledger.WithIdempotency(ctx, actorA, "POST", "/v1/products", "shared", []byte(`{"name":"A"}`), func() (int, any, error) { + return 201, map[string]any{"actor": "a"}, nil + }); err != nil { + t.Fatalf("idempotency a: %v", err) + } + for key := range ledger.idempotency { + if strings.ContainsRune(key, '\x00') { + t.Fatalf("idempotency key contains postgres-unsafe NUL: %q", key) + } + if parsed, ok := ParseIdempotencyRecordKey(key); !ok || parsed.ActorID == "" { + t.Fatalf("idempotency key did not parse: %q parsed=%#v ok=%v", key, parsed, ok) + } + } + status, response, err := ledger.WithIdempotency(ctx, actorB, "POST", "/v1/products", "shared", []byte(`{"name":"B"}`), func() (int, any, error) { + return 201, map[string]any{"actor": "b"}, nil + }) + if err != nil { + t.Fatalf("idempotency b should not conflict with actor a: %v", err) + } + got, _ := response.(map[string]any) + if status != 201 || got["actor"] != "b" { + t.Fatalf("unexpected actor b response status=%d response=%#v", status, response) + } +} + +func TestEvidenceCanonicalHashAndAuditChainVerification(t *testing.T) { + ledger := NewLedger(Config{APIKeyPepper: "test-pepper", Now: fixedNow}) + ctx := context.Background() + _, _, secret, err := ledger.BootstrapTenant(ctx, "Tenant", "admin", []string{"*"}) + if err != nil { + t.Fatalf("bootstrap: %v", err) + } + actor, err := ledger.Authenticate(ctx, secret) + if err != nil { + t.Fatalf("auth: %v", err) + } + item, err := ledger.CreateEvidence(ctx, actor, CreateEvidenceInput{Type: "build", Title: "Build", PayloadHash: sampleDigest("build")}) + if err != nil { + t.Fatalf("create evidence: %v", err) + } + vr, err := ledger.VerifySubject(ctx, actor, "evidence_item", item.ID) + if err != nil { + t.Fatalf("verify evidence: %v", err) + } + if vr.Result != "passed" { + t.Fatalf("evidence verify result = %s", vr.Result) + } + vr, err = ledger.VerifySubject(ctx, actor, "audit_chain", "") + if err != nil { + t.Fatalf("verify chain: %v", err) + } + if vr.Result != "passed" { + t.Fatalf("chain verify result = %s", vr.Result) + } +} + +func TestReleaseBundleSignatureVerification(t *testing.T) { + ledger := NewLedger(Config{APIKeyPepper: "test-pepper", Now: fixedNow}) + ctx := context.Background() + _, _, secret, err := ledger.BootstrapTenant(ctx, "Tenant", "admin", []string{"*"}) + if err != nil { + t.Fatalf("bootstrap: %v", err) + } + actor, err := ledger.Authenticate(ctx, secret) + if err != nil { + t.Fatalf("auth: %v", err) + } + product, err := ledger.CreateProduct(ctx, actor, "Payments API", "payments") + if err != nil { + t.Fatalf("product: %v", err) + } + release, err := ledger.CreateRelease(ctx, actor, product.ID, "1.0.0") + if err != nil { + t.Fatalf("release: %v", err) + } + if _, err := ledger.CreateEvidence(ctx, actor, CreateEvidenceInput{ReleaseID: release.ID, Type: "build", Title: "Build", PayloadHash: sampleDigest("build")}); err != nil { + t.Fatalf("evidence: %v", err) + } + bundle, err := ledger.CreateReleaseBundle(ctx, actor, release.ID) + if err != nil { + t.Fatalf("bundle: %v", err) + } + vr, err := ledger.VerifySubject(ctx, actor, "release_bundle", bundle.ID) + if err != nil { + t.Fatalf("verify bundle: %v", err) + } + if vr.Result != "passed" { + t.Fatalf("bundle verify result = %s", vr.Result) + } +} + +func TestMemoryStorePersistsLedgerState(t *testing.T) { + store := NewMemoryStore() + ctx := context.Background() + ledger, err := NewLedgerWithError(Config{APIKeyPepper: "test-pepper", Now: fixedNow, Store: store}) + if err != nil { + t.Fatalf("new ledger: %v", err) + } + _, _, secret, err := ledger.BootstrapTenant(ctx, "Tenant", "admin", []string{"*"}) + if err != nil { + t.Fatalf("bootstrap: %v", err) + } + actor, err := ledger.Authenticate(ctx, secret) + if err != nil { + t.Fatalf("auth: %v", err) + } + product, err := ledger.CreateProduct(ctx, actor, "Payments API", "payments") + if err != nil { + t.Fatalf("product: %v", err) + } + release, err := ledger.CreateRelease(ctx, actor, product.ID, "1.0.0") + if err != nil { + t.Fatalf("release: %v", err) + } + bundle, err := ledger.CreateReleaseBundle(ctx, actor, release.ID) + if err != nil { + t.Fatalf("bundle: %v", err) + } + + restarted, err := NewLedgerWithError(Config{APIKeyPepper: "test-pepper", Now: fixedNow, Store: store}) + if err != nil { + t.Fatalf("restart ledger: %v", err) + } + restartedActor, err := restarted.Authenticate(ctx, secret) + if err != nil { + t.Fatalf("auth after restart: %v", err) + } + if _, err := restarted.GetRelease(ctx, restartedActor, release.ID); err != nil { + t.Fatalf("release after restart: %v", err) + } + vr, err := restarted.VerifySubject(ctx, restartedActor, "release_bundle", bundle.ID) + if err != nil { + t.Fatalf("verify bundle after restart: %v", err) + } + if vr.Result != "passed" { + t.Fatalf("verify result = %s", vr.Result) + } +} + +func TestReleaseReadinessRequiresHandledCriticalFinding(t *testing.T) { + ledger := NewLedger(Config{APIKeyPepper: "test-pepper", Now: fixedNow}) + ctx := context.Background() + actor, release, artifact := setupReleaseRiskFixture(t, ledger) + scan, err := ledger.UploadVulnerabilityScan(ctx, actor, []byte(`{ + "scanner":"grype", + "target_ref":"pkg:oci/payments-api", + "release_id":"`+release.ID+`", + "findings":[{"vulnerability":"CVE-2026-0001","component":"pkg:apk/openssl@3.1.0","severity":"critical","state":"open"}] + }`)) + if err != nil { + t.Fatalf("scan: %v", err) + } + if _, err := ledger.UploadSBOM(ctx, actor, release.ID, artifact.ID, []byte(`{"bomFormat":"CycloneDX","specVersion":"1.6","components":[{"name":"openssl","version":"3.1.0","purl":"pkg:apk/openssl@3.1.0"}]}`)); err != nil { + t.Fatalf("sbom: %v", err) + } + if _, err := ledger.CreateReleaseBundle(ctx, actor, release.ID); err != nil { + t.Fatalf("bundle: %v", err) + } + addBuildProvenance(t, ledger, actor, release, artifact) + report, err := ledger.ReleaseReadinessReport(ctx, actor, release.ID) + if err != nil { + t.Fatalf("readiness: %v", err) + } + if report.Result != "failed" || len(report.BlockingFindings) != 1 { + t.Fatalf("expected blocking critical finding, got %#v", report) + } + if report.PolicySet == "" || report.Summary.Headline == "" || len(report.Sections) < 5 || !hasMissing(report.MissingEvidence, "vulnerability_decision") || !hasMissing(report.FailedPolicies, "critical_exploitable_blocks_release") || len(report.KnownLimitations) == 0 || len(report.NonClaims) == 0 { + t.Fatalf("readiness v2 fields missing: %#v", report) + } + criticalCheck := policyCheckByName(t, report.Checks, "critical_exploitable_blocks_release") + if criticalCheck.Remediation == "" { + t.Fatalf("critical check missing remediation: %#v", criticalCheck) + } + criticalQuestion := readinessQuestionByID(t, report, "critical_findings_triaged") + if criticalQuestion.Status != "missing_evidence" || !hasMissing(criticalQuestion.MissingEvidence, "vulnerability_decision") { + t.Fatalf("critical readiness question = %#v", criticalQuestion) + } + packageQuestion := readinessQuestionByID(t, report, "customer_package_safe_to_share") + if packageQuestion.Status != "limited" { + t.Fatalf("customer package question = %#v", packageQuestion) + } + if _, err := ledger.CreateVulnerabilityDecision(ctx, actor, scan.Findings[0].ID, CreateVulnerabilityDecisionInput{Status: decisionStatusNotAffected, Justification: "vulnerable code is not present"}); err != nil { + t.Fatalf("decision: %v", err) + } + report, err = ledger.ReleaseReadinessReport(ctx, actor, release.ID) + if err != nil { + t.Fatalf("readiness after decision: %v", err) + } + if report.Result != "passed" || len(report.BlockingFindings) != 0 { + t.Fatalf("expected readiness pass after decision, got %#v", report) + } + decisionQuestion := readinessQuestionByID(t, report, "vex_decisions_for_blockers") + if decisionQuestion.Status != "passed" || !hasMissing(decisionQuestion.Evidence, "vulnerability_decision") { + t.Fatalf("decision readiness question = %#v", decisionQuestion) + } + highScan, err := ledger.UploadVulnerabilityScan(ctx, actor, []byte(`{ + "scanner":"grype", + "target_ref":"pkg:oci/payments-api", + "release_id":"`+release.ID+`", + "findings":[{"vulnerability":"CVE-2026-0002","component":"pkg:apk/curl@8.0.0","severity":"high","state":"open"}] + }`)) + if err != nil { + t.Fatalf("high scan: %v", err) + } + report, err = ledger.ReleaseReadinessReport(ctx, actor, release.ID) + if err != nil { + t.Fatalf("readiness after high finding: %v", err) + } + highCheck := policyCheckByName(t, report.Checks, "high_findings_require_triage") + if report.Result != "failed" || highCheck.Result != "failed" || highCheck.Remediation == "" { + t.Fatalf("expected high finding triage failure, report=%#v check=%#v", report, highCheck) + } + if _, err := ledger.CreateVulnerabilityDecision(ctx, actor, highScan.Findings[0].ID, CreateVulnerabilityDecisionInput{Status: decisionStatusFixed, Justification: "fixed in release"}); err != nil { + t.Fatalf("high decision: %v", err) + } + report, err = ledger.ReleaseReadinessReport(ctx, actor, release.ID) + if err != nil { + t.Fatalf("readiness after high decision: %v", err) + } + if report.Result != "passed" { + t.Fatalf("expected readiness pass after high decision, got %#v", report) + } + profile, err := ledger.CreateRedactionProfile(ctx, actor, CreateRedactionProfileInput{Name: "unsafe package profile", AllowedTypes: []string{"vulnerability_decision"}}) + if err != nil { + t.Fatalf("redaction profile: %v", err) + } + if _, err := ledger.CreateCustomerSecurityPackage(ctx, actor, CreateCustomerPackageInput{ProductID: release.ProductID, ReleaseID: release.ID, RedactionProfileID: profile.ID, Title: "Unsafe package", ExpiresAt: fixedNow().Add(time.Hour)}); err != nil { + t.Fatalf("customer package: %v", err) + } + report, err = ledger.ReleaseReadinessReport(ctx, actor, release.ID) + if err != nil { + t.Fatalf("readiness after unsafe package: %v", err) + } + packageCheck := policyCheckByName(t, report.Checks, "package_redaction_profile_valid") + if report.Result != "failed" || packageCheck.Result != "failed" || packageCheck.Remediation == "" { + t.Fatalf("expected package redaction failure, report=%#v check=%#v", report, packageCheck) + } +} + +func TestCustomerVisibleDecisionRequiresImpactAndRedactsInternalNotes(t *testing.T) { + ledger := NewLedger(Config{APIKeyPepper: "test-pepper", Now: fixedNow}) + ctx := context.Background() + actor, release, _ := setupReleaseRiskFixture(t, ledger) + scan, err := ledger.UploadVulnerabilityScan(ctx, actor, []byte(`{ + "scanner":"grype", + "target_ref":"pkg:oci/payments-api", + "release_id":"`+release.ID+`", + "findings":[{"vulnerability":"CVE-2026-0100","component":"pkg:apk/openssl@3.1.0","severity":"critical","state":"open"}] + }`)) + if err != nil { + t.Fatalf("scan: %v", err) + } + sbom, err := ledger.UploadSBOM(ctx, actor, release.ID, "", []byte(`{ + "bomFormat":"CycloneDX", + "specVersion":"1.6", + "components":[{"name":"openssl","version":"3.1.0","purl":"pkg:apk/openssl@3.1.0"}] + }`)) + if err != nil { + t.Fatalf("sbom: %v", err) + } + if _, err := ledger.CreateVulnerabilityDecision(ctx, actor, scan.Findings[0].ID, CreateVulnerabilityDecisionInput{ + Status: decisionStatusNotAffected, + Justification: "runtime code path is not present", + CustomerVisible: true, + InternalNotes: "private triage note", + }); !errors.Is(err, ErrValidation) { + t.Fatalf("customer-visible decision without impact err=%v, want validation", err) + } + reviewedAt := fixedNow().Add(-time.Hour) + reviewDueAt := fixedNow().Add(30 * 24 * time.Hour) + decision, err := ledger.CreateVulnerabilityDecision(ctx, actor, scan.Findings[0].ID, CreateVulnerabilityDecisionInput{ + Status: decisionStatusNotAffected, + Justification: "runtime code path is not present", + ImpactStatement: "This release is not affected because the vulnerable runtime code is not included.", + ActionStatement: "No customer action is required for this finding.", + CustomerVisible: true, + InternalNotes: "private triage note", + ReviewedAt: &reviewedAt, + ReviewDueAt: &reviewDueAt, + }) + if err != nil { + t.Fatalf("decision: %v", err) + } + if !decision.CustomerVisible || decision.InternalNotes != "private triage note" { + t.Fatalf("decision customer visibility/internal notes not preserved: %#v", decision) + } + if decision.SBOMID != sbom.ID || decision.SBOMComponentPURL != "pkg:apk/openssl@3.1.0" || decision.SBOMComponentName != "openssl" { + t.Fatalf("decision sbom context = %#v, want sbom %s openssl", decision, sbom.ID) + } + profile, err := ledger.CreateRedactionProfile(ctx, actor, CreateRedactionProfileInput{Name: "customer decisions", AllowedTypes: []string{"vulnerability_decision"}}) + if err != nil { + t.Fatalf("redaction profile: %v", err) + } + pkg, err := ledger.CreateCustomerSecurityPackage(ctx, actor, CreateCustomerPackageInput{ + ProductID: release.ProductID, + ReleaseID: release.ID, + RedactionProfileID: profile.ID, + Title: "Customer decision package", + ExpiresAt: fixedNow().Add(time.Hour), + }) + if err != nil { + t.Fatalf("customer package: %v", err) + } + body, err := json.Marshal(pkg.Manifest) + if err != nil { + t.Fatalf("marshal package manifest: %v", err) + } + if !strings.Contains(string(body), decision.ImpactStatement) { + t.Fatalf("package manifest missing customer-safe impact statement: %s", body) + } + if !strings.Contains(string(body), `"reviewed_at"`) || !strings.Contains(string(body), `"review_due_at"`) { + t.Fatalf("package manifest missing decision freshness metadata: %s", body) + } + if !strings.Contains(string(body), `"sbom_id"`) || !strings.Contains(string(body), sbom.ID) { + t.Fatalf("package manifest missing decision SBOM context: %s", body) + } + if strings.Contains(string(body), "private triage note") { + t.Fatalf("package manifest leaked internal notes: %s", body) + } +} + +func TestVulnerabilityDecisionSummaryReportRedactsInternalAndOnlyIncludesActiveVisible(t *testing.T) { + ledger := NewLedger(Config{APIKeyPepper: "test-pepper", Now: fixedNow}) + ctx := context.Background() + actor, release, _ := setupReleaseRiskFixture(t, ledger) + supporting, err := ledger.CreateEvidence(ctx, actor, CreateEvidenceInput{ + ProductID: release.ProductID, ReleaseID: release.ID, Type: "security_review", Title: "Runtime review", PayloadHash: sampleDigest("summary-support"), + }) + if err != nil { + t.Fatalf("supporting evidence: %v", err) + } + scan, err := ledger.UploadVulnerabilityScan(ctx, actor, []byte(`{ + "scanner":"grype", + "target_ref":"pkg:oci/payments-api", + "release_id":"`+release.ID+`", + "findings":[ + {"vulnerability":"CVE-2026-0104","component":"pkg:apk/openssl@3.1.0","severity":"critical","state":"open"}, + {"vulnerability":"CVE-2026-0105","component":"pkg:apk/zlib@1.2.13","severity":"critical","state":"open"} + ] + }`)) + if err != nil { + t.Fatalf("scan: %v", err) + } + sbom, err := ledger.UploadSBOM(ctx, actor, release.ID, "", []byte(`{ + "bomFormat":"CycloneDX", + "specVersion":"1.6", + "components":[{"name":"openssl","version":"3.1.0","purl":"pkg:apk/openssl@3.1.0"}] + }`)) + if err != nil { + t.Fatalf("summary sbom: %v", err) + } + first, err := ledger.CreateVulnerabilityDecision(ctx, actor, scan.Findings[0].ID, CreateVulnerabilityDecisionInput{ + Status: decisionStatusUnderInvestigation, + Justification: "review started", + ImpactStatement: "Initial customer impact statement.", + CustomerVisible: true, + InternalNotes: "old private note", + }) + if err != nil { + t.Fatalf("first decision: %v", err) + } + reviewedAt := fixedNow().Add(-2 * time.Hour) + reviewDueAt := fixedNow().Add(90 * 24 * time.Hour) + if _, err := ledger.CreateVulnerabilityDecision(ctx, actor, scan.Findings[0].ID, CreateVulnerabilityDecisionInput{ + Status: decisionStatusNotAffected, + Justification: "bad freshness metadata", + ImpactStatement: "This release is not affected.", + CustomerVisible: true, + ReviewedAt: &reviewedAt, + ReviewDueAt: &reviewedAt, + }); !errors.Is(err, ErrValidation) { + t.Fatalf("decision with non-increasing review due err=%v, want validation", err) + } + second, err := ledger.CreateVulnerabilityDecision(ctx, actor, scan.Findings[0].ID, CreateVulnerabilityDecisionInput{ + Status: decisionStatusNotAffected, + Justification: "runtime path not present", + ImpactStatement: "This release is not affected because the vulnerable runtime path is not included.", + ActionStatement: "No customer action is required for this finding.", + CustomerVisible: true, + InternalNotes: "replacement private note", + EvidenceIDs: []string{supporting.ID}, + ReviewedAt: &reviewedAt, + ReviewDueAt: &reviewDueAt, + }) + if err != nil { + t.Fatalf("second decision: %v", err) + } + if _, err := ledger.CreateVulnerabilityDecision(ctx, actor, scan.Findings[1].ID, CreateVulnerabilityDecisionInput{ + Status: decisionStatusFixed, + Justification: "fixed but not customer-visible yet", + InternalNotes: "hidden private note", + }); err != nil { + t.Fatalf("hidden decision: %v", err) + } + report, err := ledger.VulnerabilityDecisionSummaryReport(ctx, actor, release.ID) + if err != nil { + t.Fatalf("summary: %v", err) + } + if report.ProductID != release.ProductID || report.ReleaseID != release.ID || len(report.Decisions) != 1 { + t.Fatalf("summary scope/decision count mismatch: %#v", report) + } + got := report.Decisions[0] + if got.ID != second.ID || got.ImpactStatement != second.ImpactStatement || len(got.EvidenceIDs) != 1 || got.EvidenceIDs[0] != supporting.ID { + t.Fatalf("summary decision=%#v, want active customer-visible decision", got) + } + if got.ReviewedAt == nil || got.ReviewDueAt == nil || !got.ReviewedAt.Equal(reviewedAt) || !got.ReviewDueAt.Equal(reviewDueAt) { + t.Fatalf("summary freshness metadata=%#v/%#v, want %#v/%#v", got.ReviewedAt, got.ReviewDueAt, reviewedAt, reviewDueAt) + } + if got.SBOMID != sbom.ID || got.SBOMComponentPURL != "pkg:apk/openssl@3.1.0" { + t.Fatalf("summary SBOM context=%#v, want sbom %s", got, sbom.ID) + } + body, err := json.Marshal(report) + if err != nil { + t.Fatalf("marshal summary: %v", err) + } + text := string(body) + if strings.Contains(text, first.ImpactStatement) || strings.Contains(text, "private note") || strings.Contains(text, "hidden private note") { + t.Fatalf("summary leaked superseded or internal decision data: %s", body) + } + if !strings.Contains(text, "not certification") || !strings.Contains(text, second.ImpactStatement) { + t.Fatalf("summary missing limitations or active impact statement: %s", body) + } +} + +func TestVulnerabilityDecisionLifecycleSupersedesAndPackagesOnlyActive(t *testing.T) { + ledger := NewLedger(Config{APIKeyPepper: "test-pepper", Now: fixedNow}) + ctx := context.Background() + actor, release, _ := setupReleaseRiskFixture(t, ledger) + scan, err := ledger.UploadVulnerabilityScan(ctx, actor, []byte(`{ + "scanner":"grype", + "target_ref":"pkg:oci/payments-api", + "release_id":"`+release.ID+`", + "findings":[{"vulnerability":"CVE-2026-0101","component":"pkg:apk/openssl@3.1.0","severity":"critical","state":"open"}] + }`)) + if err != nil { + t.Fatalf("scan: %v", err) + } + if _, err := ledger.CreateVulnerabilityDecision(ctx, actor, scan.Findings[0].ID, CreateVulnerabilityDecisionInput{ + Status: "not_a_status", + Justification: "bad status", + }); !errors.Is(err, ErrValidation) { + t.Fatalf("invalid lifecycle status err=%v, want validation", err) + } + first, err := ledger.CreateVulnerabilityDecision(ctx, actor, scan.Findings[0].ID, CreateVulnerabilityDecisionInput{ + Status: decisionStatusUnderInvestigation, + Justification: "triage started", + ImpactStatement: "The finding is under investigation.", + CustomerVisible: true, + InternalNotes: "initial private note", + }) + if err != nil { + t.Fatalf("first decision: %v", err) + } + second, err := ledger.CreateVulnerabilityDecision(ctx, actor, scan.Findings[0].ID, CreateVulnerabilityDecisionInput{ + Status: decisionStatusFixed, + Justification: "patched in release artifact", + ImpactStatement: "This issue is fixed in the release artifact.", + ActionStatement: "Upgrade to this release.", + CustomerVisible: true, + InternalNotes: "replacement private note", + }) + if err != nil { + t.Fatalf("second decision: %v", err) } - if _, err := ledger.GetEvidence(ctx, actorB, item.ID); !errors.Is(err, ErrNotFound) { - t.Fatalf("cross-tenant read err = %v, want not found", err) + if second.Supersedes != first.ID { + t.Fatalf("second supersedes=%q, want %q", second.Supersedes, first.ID) } - keys, err := ledger.ListAPIKeys(ctx, actorA) - if err != nil { - t.Fatalf("list keys: %v", err) + if got := ledger.decisions[first.ID].SupersededBy; got != second.ID { + t.Fatalf("first superseded_by=%q, want %q", got, second.ID) } - for _, key := range keys { - if key.Hash != "" { - t.Fatal("API key hash leaked in list response") + created, superseded := 0, 0 + for _, entry := range ledger.chain[actor.TenantID] { + switch { + case entry.EntryType == "vulnerability_decision.created" && entry.SubjectID == scan.Findings[0].ID: + created++ + case entry.EntryType == "vulnerability_decision.superseded" && entry.SubjectID == first.ID: + superseded++ } } - if strings.Contains(secretA, keys[0].Prefix) && keys[0].Prefix == secretA { - t.Fatal("full API key secret leaked as prefix") + if created != 2 || superseded != 1 { + t.Fatalf("decision lifecycle audit counts created=%d superseded=%d", created, superseded) } -} - -func TestScopedAPIKeyCannotWriteEvidence(t *testing.T) { - ledger := NewLedger(Config{APIKeyPepper: "test-pepper", Now: fixedNow}) - ctx := context.Background() - _, _, adminSecret, err := ledger.BootstrapTenant(ctx, "Tenant", "admin", []string{"*"}) + profile, err := ledger.CreateRedactionProfile(ctx, actor, CreateRedactionProfileInput{Name: "active decisions", AllowedTypes: []string{"vulnerability_decision"}}) if err != nil { - t.Fatalf("bootstrap: %v", err) + t.Fatalf("redaction profile: %v", err) } - admin, err := ledger.Authenticate(ctx, adminSecret) + pkg, err := ledger.CreateCustomerSecurityPackage(ctx, actor, CreateCustomerPackageInput{ + ProductID: release.ProductID, + ReleaseID: release.ID, + RedactionProfileID: profile.ID, + Title: "Active decision package", + ExpiresAt: fixedNow().Add(time.Hour), + }) if err != nil { - t.Fatalf("auth admin: %v", err) + t.Fatalf("customer package: %v", err) } - _, readerSecret, err := ledger.CreateAPIKey(ctx, admin, "reader", []string{ScopeEvidenceRead}, nil) + body, err := json.Marshal(pkg.Manifest) if err != nil { - t.Fatalf("create reader: %v", err) + t.Fatalf("marshal package manifest: %v", err) } - reader, err := ledger.Authenticate(ctx, readerSecret) - if err != nil { - t.Fatalf("auth reader: %v", err) + text := string(body) + if !strings.Contains(text, second.ImpactStatement) { + t.Fatalf("package manifest missing active impact statement: %s", body) } - _, err = ledger.CreateEvidence(ctx, reader, CreateEvidenceInput{Type: "build", Title: "Build", PayloadHash: sampleDigest("x")}) - if !errors.Is(err, ErrForbidden) { - t.Fatalf("reader create evidence err = %v, want forbidden", err) + if strings.Contains(text, first.ImpactStatement) || strings.Contains(text, "private note") { + t.Fatalf("package manifest included superseded decision or internal notes: %s", body) } } -func TestIdempotencyReplayAndConflict(t *testing.T) { +func TestListVulnerabilityDecisionsFiltersHistoryAndTenantScope(t *testing.T) { ledger := NewLedger(Config{APIKeyPepper: "test-pepper", Now: fixedNow}) ctx := context.Background() - _, _, secret, err := ledger.BootstrapTenant(ctx, "Tenant", "admin", []string{"*"}) + actor, release, _ := setupReleaseRiskFixture(t, ledger) + scan, err := ledger.UploadVulnerabilityScan(ctx, actor, []byte(`{ + "scanner":"grype", + "target_ref":"pkg:oci/payments-api", + "release_id":"`+release.ID+`", + "findings":[{"vulnerability":"CVE-2026-0103","component":"pkg:apk/openssl@3.1.0","severity":"critical","state":"open"}] + }`)) if err != nil { - t.Fatalf("bootstrap: %v", err) + t.Fatalf("scan: %v", err) } - actor, err := ledger.Authenticate(ctx, secret) + first, err := ledger.CreateVulnerabilityDecision(ctx, actor, scan.Findings[0].ID, CreateVulnerabilityDecisionInput{ + Status: decisionStatusUnderInvestigation, + Justification: "review started", + ImpactStatement: "The finding is under investigation.", + CustomerVisible: true, + }) if err != nil { - t.Fatalf("auth: %v", err) + t.Fatalf("first decision: %v", err) } - calls := 0 - status, response, err := ledger.WithIdempotency(ctx, actor, "POST", "/v1/products", "idem-1", []byte(`{"name":"A"}`), func() (int, any, error) { - calls++ - return 201, map[string]string{"id": "prod_1"}, nil + second, err := ledger.CreateVulnerabilityDecision(ctx, actor, scan.Findings[0].ID, CreateVulnerabilityDecisionInput{ + Status: decisionStatusFixed, + Justification: "fixed in release artifact", + ImpactStatement: "The finding is fixed in this release artifact.", + CustomerVisible: true, }) - if err != nil || status != 201 || response == nil { - t.Fatalf("first idempotent call status=%d response=%v err=%v", status, response, err) + if err != nil { + t.Fatalf("second decision: %v", err) } - status, response, err = ledger.WithIdempotency(ctx, actor, "POST", "/v1/products", "idem-1", []byte(`{"name":"A"}`), func() (int, any, error) { - calls++ - return 201, map[string]string{"id": "prod_2"}, nil + history, err := ledger.ListVulnerabilityDecisions(ctx, actor, ListVulnerabilityDecisionsInput{ + ProductID: release.ProductID, + ReleaseID: release.ID, + Vulnerability: "CVE-2026-0103", + Component: "pkg:apk/openssl@3.1.0", }) - if err != nil || status != 201 || response == nil { - t.Fatalf("replay status=%d response=%v err=%v", status, response, err) - } - if calls != 1 { - t.Fatalf("calls = %d, want 1", calls) + if err != nil { + t.Fatalf("list history: %v", err) } - _, _, err = ledger.WithIdempotency(ctx, actor, "POST", "/v1/products", "idem-1", []byte(`{"name":"B"}`), func() (int, any, error) { - return 201, nil, nil - }) - if !errors.Is(err, ErrIdempotencyConflict) { - t.Fatalf("conflict err = %v, want idempotency conflict", err) + if len(history) != 2 { + t.Fatalf("history length=%d want 2 history=%#v", len(history), history) } -} - -func TestEvidenceCanonicalHashAndAuditChainVerification(t *testing.T) { - ledger := NewLedger(Config{APIKeyPepper: "test-pepper", Now: fixedNow}) - ctx := context.Background() - _, _, secret, err := ledger.BootstrapTenant(ctx, "Tenant", "admin", []string{"*"}) + active := true + activeOnly, err := ledger.ListVulnerabilityDecisions(ctx, actor, ListVulnerabilityDecisionsInput{ReleaseID: release.ID, Active: &active}) if err != nil { - t.Fatalf("bootstrap: %v", err) + t.Fatalf("list active: %v", err) } - actor, err := ledger.Authenticate(ctx, secret) - if err != nil { - t.Fatalf("auth: %v", err) + if len(activeOnly) != 1 || activeOnly[0].ID != second.ID || activeOnly[0].Supersedes != first.ID { + t.Fatalf("active decisions=%#v, want second decision superseding first", activeOnly) } - item, err := ledger.CreateEvidence(ctx, actor, CreateEvidenceInput{Type: "build", Title: "Build", PayloadHash: sampleDigest("build")}) + active = false + superseded, err := ledger.ListVulnerabilityDecisions(ctx, actor, ListVulnerabilityDecisionsInput{ReleaseID: release.ID, Status: decisionStatusUnderInvestigation, Active: &active}) if err != nil { - t.Fatalf("create evidence: %v", err) + t.Fatalf("list superseded: %v", err) } - vr, err := ledger.VerifySubject(ctx, actor, "evidence_item", item.ID) + if len(superseded) != 1 || superseded[0].ID != first.ID || superseded[0].SupersededBy != second.ID { + t.Fatalf("superseded decisions=%#v, want first decision superseded by second", superseded) + } + if _, err := ledger.ListVulnerabilityDecisions(ctx, actor, ListVulnerabilityDecisionsInput{Status: "not_a_status"}); !errors.Is(err, ErrValidation) { + t.Fatalf("invalid status err=%v, want validation", err) + } + _, _, secretB, err := ledger.BootstrapTenant(ctx, "Tenant B", "admin-b", []string{"*"}) if err != nil { - t.Fatalf("verify evidence: %v", err) + t.Fatalf("bootstrap tenant B: %v", err) } - if vr.Result != "passed" { - t.Fatalf("evidence verify result = %s", vr.Result) + actorB, err := ledger.Authenticate(ctx, secretB) + if err != nil { + t.Fatalf("authenticate tenant B: %v", err) } - vr, err = ledger.VerifySubject(ctx, actor, "audit_chain", "") + foreignHistory, err := ledger.ListVulnerabilityDecisions(ctx, actorB, ListVulnerabilityDecisionsInput{}) if err != nil { - t.Fatalf("verify chain: %v", err) + t.Fatalf("foreign list: %v", err) } - if vr.Result != "passed" { - t.Fatalf("chain verify result = %s", vr.Result) + if len(foreignHistory) != 0 { + t.Fatalf("foreign tenant saw decisions: %#v", foreignHistory) } } -func TestReleaseBundleSignatureVerification(t *testing.T) { +func TestVulnerabilityDecisionEvidenceLinksAreTenantAndReleaseScoped(t *testing.T) { ledger := NewLedger(Config{APIKeyPepper: "test-pepper", Now: fixedNow}) ctx := context.Background() - _, _, secret, err := ledger.BootstrapTenant(ctx, "Tenant", "admin", []string{"*"}) + actor, release, _ := setupReleaseRiskFixture(t, ledger) + supporting, err := ledger.CreateEvidence(ctx, actor, CreateEvidenceInput{ + ProductID: release.ProductID, ReleaseID: release.ID, Type: "security_review", Title: "Runtime review", PayloadHash: sampleDigest("supporting-review"), + }) if err != nil { - t.Fatalf("bootstrap: %v", err) + t.Fatalf("supporting evidence: %v", err) } - actor, err := ledger.Authenticate(ctx, secret) + otherRelease, err := ledger.CreateRelease(ctx, actor, release.ProductID, "2.0.0") if err != nil { - t.Fatalf("auth: %v", err) + t.Fatalf("other release: %v", err) } - product, err := ledger.CreateProduct(ctx, actor, "Payments API", "payments") + wrongReleaseEvidence, err := ledger.CreateEvidence(ctx, actor, CreateEvidenceInput{ + ProductID: release.ProductID, ReleaseID: otherRelease.ID, Type: "security_review", Title: "Wrong release review", PayloadHash: sampleDigest("wrong-release-review"), + }) if err != nil { - t.Fatalf("product: %v", err) + t.Fatalf("wrong release evidence: %v", err) } - release, err := ledger.CreateRelease(ctx, actor, product.ID, "1.0.0") + _, _, secretB, err := ledger.BootstrapTenant(ctx, "Tenant B", "admin-b", []string{"*"}) if err != nil { - t.Fatalf("release: %v", err) - } - if _, err := ledger.CreateEvidence(ctx, actor, CreateEvidenceInput{ReleaseID: release.ID, Type: "build", Title: "Build", PayloadHash: sampleDigest("build")}); err != nil { - t.Fatalf("evidence: %v", err) + t.Fatalf("bootstrap tenant B: %v", err) } - bundle, err := ledger.CreateReleaseBundle(ctx, actor, release.ID) + actorB, err := ledger.Authenticate(ctx, secretB) if err != nil { - t.Fatalf("bundle: %v", err) + t.Fatalf("authenticate tenant B: %v", err) } - vr, err := ledger.VerifySubject(ctx, actor, "release_bundle", bundle.ID) + foreignEvidence, err := ledger.CreateEvidence(ctx, actorB, CreateEvidenceInput{Type: "security_review", Title: "Foreign review", PayloadHash: sampleDigest("foreign-review")}) if err != nil { - t.Fatalf("verify bundle: %v", err) - } - if vr.Result != "passed" { - t.Fatalf("bundle verify result = %s", vr.Result) + t.Fatalf("foreign evidence: %v", err) } -} - -func TestMemoryStorePersistsLedgerState(t *testing.T) { - store := NewMemoryStore() - ctx := context.Background() - ledger, err := NewLedgerWithError(Config{APIKeyPepper: "test-pepper", Now: fixedNow, Store: store}) + scan, err := ledger.UploadVulnerabilityScan(ctx, actor, []byte(`{ + "scanner":"grype", + "target_ref":"pkg:oci/payments-api", + "release_id":"`+release.ID+`", + "findings":[{"vulnerability":"CVE-2026-0102","component":"pkg:apk/openssl@3.1.0","severity":"critical","state":"open"}] + }`)) if err != nil { - t.Fatalf("new ledger: %v", err) + t.Fatalf("scan: %v", err) } - _, _, secret, err := ledger.BootstrapTenant(ctx, "Tenant", "admin", []string{"*"}) + if _, err := ledger.CreateVulnerabilityDecision(ctx, actor, scan.Findings[0].ID, CreateVulnerabilityDecisionInput{ + Status: decisionStatusNotAffected, + Justification: "reviewed runtime path", + ImpactStatement: "The vulnerable runtime path is not included.", + CustomerVisible: true, + EvidenceIDs: []string{wrongReleaseEvidence.ID}, + }); !errors.Is(err, ErrNotFound) { + t.Fatalf("wrong-release evidence link err=%v, want not found", err) + } + if _, err := ledger.CreateVulnerabilityDecision(ctx, actor, scan.Findings[0].ID, CreateVulnerabilityDecisionInput{ + Status: decisionStatusNotAffected, + Justification: "reviewed runtime path", + ImpactStatement: "The vulnerable runtime path is not included.", + CustomerVisible: true, + EvidenceIDs: []string{foreignEvidence.ID}, + }); !errors.Is(err, ErrNotFound) { + t.Fatalf("foreign evidence link err=%v, want not found", err) + } + decision, err := ledger.CreateVulnerabilityDecision(ctx, actor, scan.Findings[0].ID, CreateVulnerabilityDecisionInput{ + Status: decisionStatusNotAffected, + Justification: "reviewed runtime path", + ImpactStatement: "The vulnerable runtime path is not included.", + CustomerVisible: true, + EvidenceIDs: []string{supporting.ID, supporting.ID}, + }) if err != nil { - t.Fatalf("bootstrap: %v", err) + t.Fatalf("decision: %v", err) } - actor, err := ledger.Authenticate(ctx, secret) - if err != nil { - t.Fatalf("auth: %v", err) + if len(decision.EvidenceIDs) != 1 || decision.EvidenceIDs[0] != supporting.ID { + t.Fatalf("decision evidence links = %#v, want %s", decision.EvidenceIDs, supporting.ID) } - product, err := ledger.CreateProduct(ctx, actor, "Payments API", "payments") + profile, err := ledger.CreateRedactionProfile(ctx, actor, CreateRedactionProfileInput{Name: "decision links", AllowedTypes: []string{"vulnerability_decision"}}) if err != nil { - t.Fatalf("product: %v", err) + t.Fatalf("redaction profile: %v", err) } - release, err := ledger.CreateRelease(ctx, actor, product.ID, "1.0.0") + pkg, err := ledger.CreateCustomerSecurityPackage(ctx, actor, CreateCustomerPackageInput{ + ProductID: release.ProductID, + ReleaseID: release.ID, + RedactionProfileID: profile.ID, + Title: "Decision links package", + ExpiresAt: fixedNow().Add(time.Hour), + }) if err != nil { - t.Fatalf("release: %v", err) + t.Fatalf("customer package: %v", err) } - bundle, err := ledger.CreateReleaseBundle(ctx, actor, release.ID) + body, err := json.Marshal(pkg.Manifest) if err != nil { - t.Fatalf("bundle: %v", err) + t.Fatalf("marshal package manifest: %v", err) + } + text := string(body) + if !strings.Contains(text, supporting.ID) { + t.Fatalf("customer package missing linked evidence id: %s", body) } + if strings.Contains(text, wrongReleaseEvidence.ID) || strings.Contains(text, foreignEvidence.ID) { + t.Fatalf("customer package leaked unlinked/foreign evidence ids: %s", body) + } +} - restarted, err := NewLedgerWithError(Config{APIKeyPepper: "test-pepper", Now: fixedNow, Store: store}) +func TestOpenVEXIngestionCreatesDecisionAndRejectsMalformedInput(t *testing.T) { + ledger := NewLedger(Config{APIKeyPepper: "test-pepper", Now: fixedNow}) + ctx := context.Background() + actor, release, artifact := setupReleaseRiskFixture(t, ledger) + if _, err := ledger.UploadVulnerabilityScan(ctx, actor, []byte(`{ + "scanner":"grype", + "target_ref":"pkg:oci/payments-api", + "release_id":"`+release.ID+`", + "findings":[{"vulnerability":"CVE-2026-0002","component":"pkg:apk/openssl@3.1.0","severity":"critical","state":"open"}] + }`)); err != nil { + t.Fatalf("scan: %v", err) + } + vex, err := ledger.UploadVEX(ctx, actor, release.ID, artifact.ID, []byte(`{ + "@context":"https://openvex.dev/ns/v0.2.0", + "@id":"https://example.test/vex/1", + "author":"security@example.test", + "timestamp":"2026-05-27T12:00:00Z", + "version":1, + "statements":[{ + "vulnerability":{"name":"CVE-2026-0002"}, + "products":[{"@id":"pkg:apk/openssl@3.1.0"}], + "status":"fixed", + "justification":"fixed in release candidate", + "impact_statement":"patched before release", + "action_statement":"ship fixed artifact" + }] + }`)) if err != nil { - t.Fatalf("restart ledger: %v", err) + t.Fatalf("vex: %v", err) } - restartedActor, err := restarted.Authenticate(ctx, secret) + importReport, err := ledger.GetVEXImportReport(ctx, actor, vex.ID) if err != nil { - t.Fatalf("auth after restart: %v", err) + t.Fatalf("import report: %v", err) } - if _, err := restarted.GetRelease(ctx, restartedActor, release.ID); err != nil { - t.Fatalf("release after restart: %v", err) + if importReport.Status != "parsed" || importReport.StatementCount != 1 || importReport.DecisionsCreated != 1 || importReport.DecisionsSuperseded != 0 || len(importReport.MappingFailures) != 0 { + t.Fatalf("unexpected import report: %#v", importReport) } - vr, err := restarted.VerifySubject(ctx, restartedActor, "release_bundle", bundle.ID) + report, err := ledger.ReleaseReadinessReport(ctx, actor, release.ID) if err != nil { - t.Fatalf("verify bundle after restart: %v", err) + t.Fatalf("readiness: %v", err) } - if vr.Result != "passed" { - t.Fatalf("verify result = %s", vr.Result) + if len(report.BlockingFindings) != 0 { + t.Fatalf("VEX decision did not handle finding: %#v", report.BlockingFindings) + } + if _, err := ledger.UploadVEX(ctx, actor, release.ID, artifact.ID, []byte(`{"author":"a","timestamp":"2026-05-27T12:00:00Z","statements":[],"extra":true}`)); !errors.Is(err, ErrValidation) { + t.Fatalf("malformed VEX err = %v, want validation", err) } } -func TestReleaseReadinessRequiresHandledCriticalFinding(t *testing.T) { +func TestOpenVEXImportReportTracksSupersessionAndMappingFailures(t *testing.T) { ledger := NewLedger(Config{APIKeyPepper: "test-pepper", Now: fixedNow}) ctx := context.Background() actor, release, artifact := setupReleaseRiskFixture(t, ledger) @@ -250,52 +1314,92 @@ func TestReleaseReadinessRequiresHandledCriticalFinding(t *testing.T) { "scanner":"grype", "target_ref":"pkg:oci/payments-api", "release_id":"`+release.ID+`", - "findings":[{"vulnerability":"CVE-2026-0001","component":"pkg:apk/openssl@3.1.0","severity":"critical","state":"open"}] + "findings":[{"vulnerability":"CVE-2026-0003","component":"pkg:apk/openssl@3.1.0","severity":"critical","state":"open"}] }`)) if err != nil { t.Fatalf("scan: %v", err) } - if _, err := ledger.UploadSBOM(ctx, actor, release.ID, artifact.ID, []byte(`{"bomFormat":"CycloneDX","specVersion":"1.6","components":[{"name":"openssl","version":"3.1.0","purl":"pkg:apk/openssl@3.1.0"}]}`)); err != nil { - t.Fatalf("sbom: %v", err) + if _, err := ledger.CreateVulnerabilityDecision(ctx, actor, scan.Findings[0].ID, CreateVulnerabilityDecisionInput{ + Status: decisionStatusUnderInvestigation, + Justification: "manual triage started", + ImpactStatement: "The finding is under investigation.", + CustomerVisible: true, + }); err != nil { + t.Fatalf("manual decision: %v", err) } - if _, err := ledger.CreateReleaseBundle(ctx, actor, release.ID); err != nil { - t.Fatalf("bundle: %v", err) + vex, err := ledger.UploadVEX(ctx, actor, release.ID, artifact.ID, []byte(`{ + "@context":"https://openvex.dev/ns/v0.2.0", + "@id":"https://example.test/vex/2", + "author":"security@example.test", + "timestamp":"2026-05-27T12:00:00Z", + "version":1, + "statements":[{ + "vulnerability":{"name":"CVE-2026-0003"}, + "products":[{"@id":"pkg:apk/openssl@3.1.0"}], + "status":"fixed", + "justification":"fixed in release candidate", + "impact_statement":"patched before release" + },{ + "vulnerability":{"name":"CVE-2026-9999"}, + "products":[{"@id":"pkg:apk/missing@1.0.0"}], + "status":"not_affected", + "justification":"component not present", + "impact_statement":"not present in this release" + }] + }`)) + if err != nil { + t.Fatalf("vex: %v", err) } - addBuildProvenance(t, ledger, actor, release, artifact) - report, err := ledger.ReleaseReadinessReport(ctx, actor, release.ID) + report, err := ledger.GetVEXImportReport(ctx, actor, vex.ID) if err != nil { - t.Fatalf("readiness: %v", err) + t.Fatalf("import report: %v", err) } - if report.Result != "failed" || len(report.BlockingFindings) != 1 { - t.Fatalf("expected blocking critical finding, got %#v", report) + if report.StatementCount != 2 || report.DecisionsCreated != 1 || report.DecisionsSuperseded != 1 { + t.Fatalf("report counts = %#v", report) } - if _, err := ledger.CreateVulnerabilityDecision(ctx, actor, scan.Findings[0].ID, CreateVulnerabilityDecisionInput{Status: decisionStatusNotAffected, Justification: "vulnerable code is not present"}); err != nil { - t.Fatalf("decision: %v", err) + if len(report.MappingFailures) != 1 || report.MappingFailures[0].StatementIndex != 2 || report.MappingFailures[0].Code != "finding_not_found" { + t.Fatalf("mapping failures = %#v", report.MappingFailures) } - report, err = ledger.ReleaseReadinessReport(ctx, actor, release.ID) + body, err := json.Marshal(report) if err != nil { - t.Fatalf("readiness after decision: %v", err) + t.Fatalf("marshal report: %v", err) } - if report.Result != "passed" || len(report.BlockingFindings) != 0 { - t.Fatalf("expected readiness pass after decision, got %#v", report) + if strings.Contains(string(body), "payload") || strings.Contains(string(body), "manual triage") { + t.Fatalf("import report leaked raw payload or internal triage details: %s", body) } } -func TestOpenVEXIngestionCreatesDecisionAndRejectsMalformedInput(t *testing.T) { - ledger := NewLedger(Config{APIKeyPepper: "test-pepper", Now: fixedNow}) +func TestUploadVEXCanDeferDocumentParserSideEffectsToWorker(t *testing.T) { + outbox := &recordingOutbox{} + store := NewMemoryStore() + objects := newTestObjectStore() + ledger := NewLedger(Config{ + APIKeyPepper: "test-pepper", + Now: fixedNow, + Store: store, + ObjectStore: objects, + Outbox: outbox, + WorkerOwnedParserSideEffects: true, + }) ctx := context.Background() actor, release, artifact := setupReleaseRiskFixture(t, ledger) - if _, err := ledger.UploadVulnerabilityScan(ctx, actor, []byte(`{ + scan, err := ledger.UploadVulnerabilityScan(ctx, actor, []byte(`{ "scanner":"grype", "target_ref":"pkg:oci/payments-api", "release_id":"`+release.ID+`", "findings":[{"vulnerability":"CVE-2026-0002","component":"pkg:apk/openssl@3.1.0","severity":"critical","state":"open"}] - }`)); err != nil { + }`)) + if err != nil { t.Fatalf("scan: %v", err) } - if _, err := ledger.UploadVEX(ctx, actor, release.ID, artifact.ID, []byte(`{ + ledger.mu.Lock() + ledger.scans[scan.ID] = scan + ledger.mu.Unlock() + outbox.jobs = nil + + vex, err := ledger.UploadVEX(ctx, actor, release.ID, artifact.ID, []byte(`{ "@context":"https://openvex.dev/ns/v0.2.0", - "@id":"https://example.test/vex/1", + "@id":"https://example.test/vex/worker", "author":"security@example.test", "timestamp":"2026-05-27T12:00:00Z", "version":1, @@ -307,18 +1411,52 @@ func TestOpenVEXIngestionCreatesDecisionAndRejectsMalformedInput(t *testing.T) { "impact_statement":"patched before release", "action_statement":"ship fixed artifact" }] - }`)); err != nil { + }`)) + if err != nil { t.Fatalf("vex: %v", err) } - report, err := ledger.ReleaseReadinessReport(ctx, actor, release.ID) + if vex.Author != "security@example.test" || vex.StatementCount != 1 || vex.StatusSummary["fixed"] != 1 { + t.Fatalf("upload response should keep parsed VEX fields: %#v", vex) + } + + state, ok, err := store.LoadState(ctx) + if err != nil || !ok { + t.Fatalf("load state ok=%v err=%v", ok, err) + } + persisted := state.VEXDocuments[vex.ID] + if persisted.Author != "" || persisted.StatementCount != 0 || persisted.StatusSummary != nil { + t.Fatalf("persisted vex document should wait for worker parser side effects: %#v", persisted) + } + importReport, err := ledger.GetVEXImportReport(ctx, actor, vex.ID) if err != nil { - t.Fatalf("readiness: %v", err) + t.Fatalf("import report: %v", err) } - if len(report.BlockingFindings) != 0 { - t.Fatalf("VEX decision did not handle finding: %#v", report.BlockingFindings) + if importReport.Status != "accepted" || importReport.StatementCount != 1 || importReport.DecisionsCreated != 0 || len(importReport.Warnings) == 0 { + t.Fatalf("worker-owned import report = %#v", importReport) } - if _, err := ledger.UploadVEX(ctx, actor, release.ID, artifact.ID, []byte(`{"author":"a","timestamp":"2026-05-27T12:00:00Z","statements":[],"extra":true}`)); !errors.Is(err, ErrValidation) { - t.Fatalf("malformed VEX err = %v, want validation", err) + decisionFound := false + for _, decision := range state.Decisions { + if decision.FindingID == scan.Findings[0].ID && decision.VEXDocumentID == vex.ID && decision.Status == decisionStatusFixed { + decisionFound = true + } + } + if decisionFound { + t.Fatal("worker-owned parser mode should leave OpenVEX-derived vulnerability decisions to the worker") + } + if len(outbox.jobs) != 1 { + t.Fatalf("outbox jobs = %d, want 1", len(outbox.jobs)) + } + job := outbox.jobs[0] + if job.Kind != "parse_vex" || job.Payload["payload_ref"] == "" || job.Payload["payload_hash"] == "" || job.Payload["parser_version"] != ParserVersionOpenVEXJSON || job.Payload["worker_create_decisions"] != true || job.Payload["import_report_id"] == "" { + t.Fatalf("outbox job missing replay metadata: %#v", job) + } + payloadRef, ok := job.Payload["payload_ref"].(string) + payloadKey := strings.TrimPrefix(payloadRef, "object://") + if !ok || !strings.HasPrefix(payloadKey, "tenants/"+actor.TenantID+"/") { + t.Fatalf("payload ref %q is not tenant-prefixed", job.Payload["payload_ref"]) + } + if _, err := objects.Get(ctx, payloadKey); err != nil { + t.Fatalf("stored payload missing: %v", err) } } @@ -546,6 +1684,30 @@ func hasMissing(items []string, want string) bool { return false } +func policyCheckByName(t *testing.T, checks []domain.PolicyCheck, name string) domain.PolicyCheck { + t.Helper() + for _, check := range checks { + if check.Name == name { + return check + } + } + t.Fatalf("policy check %s not found in %#v", name, checks) + return domain.PolicyCheck{} +} + +func readinessQuestionByID(t *testing.T, report domain.ReleaseReadinessReport, id string) domain.ReadinessQuestion { + t.Helper() + for _, section := range report.Sections { + for _, question := range section.Questions { + if question.ID == id { + return question + } + } + } + t.Fatalf("readiness question %s not found in %#v", id, report.Sections) + return domain.ReadinessQuestion{} +} + func addBuildProvenance(t *testing.T, ledger *Ledger, actor domain.Actor, release domain.Release, artifact domain.Artifact) { t.Helper() ctx := context.Background() diff --git a/internal/app/manual_decisions_test.go b/internal/app/manual_decisions_test.go new file mode 100644 index 0000000..92dd0fa --- /dev/null +++ b/internal/app/manual_decisions_test.go @@ -0,0 +1,266 @@ +package app + +import ( + "context" + "encoding/json" + "errors" + "strings" + "testing" + "time" + + "github.com/aatuh/evydence/internal/domain" +) + +func TestManualDecisionCanLinkImportedVEXAndExportCustomerPackage(t *testing.T) { + ledger := NewLedger(Config{APIKeyPepper: "test-pepper", Now: fixedNow}) + ctx := context.Background() + actor, release, artifact := setupReleaseRiskFixture(t, ledger) + vulnerability := "CVE-2026-0600" + component := "pkg:apk/openssl@3.1.0" + scan := uploadVEXMappingScan(t, ctx, ledger, actor, release.ID, vulnerability, component) + vex, err := ledger.UploadVEX(ctx, actor, release.ID, artifact.ID, openVEXFixture(t, []map[string]any{ + openVEXStatementFixture("CVE-2026-9999", []map[string]any{{"@id": "pkg:apk/not-present@1.0.0"}}, decisionStatusFixed, "fixed_elsewhere"), + })) + if err != nil { + t.Fatalf("upload vex: %v", err) + } + report, err := ledger.GetVEXImportReport(ctx, actor, vex.ID) + if err != nil { + t.Fatalf("import report: %v", err) + } + if report.DecisionsCreated != 0 || len(report.MappingFailures) != 1 { + t.Fatalf("import report should show unmapped VEX statement: %#v", report) + } + + decision, err := ledger.CreateVulnerabilityDecision(ctx, actor, scan.Findings[0].ID, CreateVulnerabilityDecisionInput{ + Status: decisionStatusNotAffected, + Justification: "reviewed against imported VEX and release inventory", + ImpactStatement: "This release is not affected based on the linked VEX review and supporting release evidence.", + ActionStatement: "No customer action is required for this finding.", + CustomerVisible: true, + InternalNotes: "internal reviewer note", + VEXDocumentID: vex.ID, + }) + if err != nil { + t.Fatalf("manual decision: %v", err) + } + if decision.Source != "api" || decision.VEXDocumentID != vex.ID || decision.EvidenceID != "" { + t.Fatalf("manual linked decision = %#v", decision) + } + + profile, err := ledger.CreateRedactionProfile(ctx, actor, CreateRedactionProfileInput{Name: "linked VEX decisions", AllowedTypes: []string{"vulnerability_decision", "vex"}}) + if err != nil { + t.Fatalf("redaction profile: %v", err) + } + pkg, err := ledger.CreateCustomerSecurityPackage(ctx, actor, CreateCustomerPackageInput{ + ProductID: release.ProductID, + ReleaseID: release.ID, + RedactionProfileID: profile.ID, + Title: "Linked VEX decision package", + ExpiresAt: fixedNow().Add(time.Hour), + }) + if err != nil { + t.Fatalf("customer package: %v", err) + } + body, err := json.Marshal(pkg.Manifest) + if err != nil { + t.Fatalf("marshal package manifest: %v", err) + } + text := string(body) + if !strings.Contains(text, decision.ImpactStatement) || !strings.Contains(text, vex.ID) { + t.Fatalf("package manifest missing linked manual decision fields: %s", body) + } + if strings.Contains(text, "internal reviewer note") { + t.Fatalf("package manifest leaked internal notes: %s", body) + } + archive, err := ledger.ExportCustomerSecurityPackageArchive(ctx, actor, pkg.ID) + if err != nil { + t.Fatalf("export archive: %v", err) + } + htmlReport := packageArchiveFiles(t, archive.Bytes)["report.html"] + if !strings.Contains(htmlReport, decision.ImpactStatement) || !strings.Contains(htmlReport, vex.ID) { + t.Fatalf("HTML report missing VEX/decision content: %s", htmlReport) + } + if strings.Contains(htmlReport, "internal reviewer note") || strings.Contains(htmlReport, "payload_ref") || strings.Contains(htmlReport, " 0 { + previous = entries[len(entries)-1].EntryHash + } + entry := domain.AuditChainEntry{ + ID: newID("ace"), + TenantID: tenantID, + Sequence: int64(len(entries) + 1), + EntryType: entryType, + SubjectType: subjectType, + SubjectID: subjectID, + ActorType: actorType, + ActorID: actorID, + OccurredAt: now.UTC(), + PayloadHash: payloadHash, + PreviousEntryHash: previous, + SignatureRef: signatureRef, + SchemaVersion: domain.AuditChainEntrySchemaVersion, + } + canonical, err := canonicalAnyHash(map[string]any{ + "tenant_id": entry.TenantID, + "sequence": entry.Sequence, + "entry_type": entry.EntryType, + "subject_type": entry.SubjectType, + "subject_id": entry.SubjectID, + "actor_type": entry.ActorType, + "actor_id": entry.ActorID, + "occurred_at": entry.OccurredAt.UTC().Format(time.RFC3339Nano), + "payload_hash": entry.PayloadHash, + "previous_entry_hash": entry.PreviousEntryHash, + "signature_ref": entry.SignatureRef, + "schema_version": entry.SchemaVersion, + }) + if err != nil { + return domain.AuditChainEntry{}, err + } + entry.CanonicalEntryHash = canonical + entry.EntryHash = hashBytes([]byte(previous + "\n" + canonical)) + state.Chain[tenantID] = append(entries, entry) + return entry, nil } type IdempotencyRecord struct { @@ -111,6 +287,14 @@ type IdempotencyRecord struct { CreatedAt time.Time `json:"created_at"` } +type IdempotencyRecordKey struct { + TenantID string + ActorID string + Method string + Path string + IdempotencyKey string +} + type Object struct { Key string TenantID string @@ -130,6 +314,44 @@ type OutboxJob struct { CreatedAt time.Time `json:"created_at"` } +type CriticalMutation struct { + Tenants []domain.Tenant + APIKeys []domain.APIKey + APIKeyHashes map[string]string + Collectors []domain.Collector + SSOSessions []domain.SSOSession + SSOSessionHashes map[string]string + CustomerPortalAccess []domain.CustomerPortalAccess + CustomerPortalHashes map[string]string + Idempotency map[string]IdempotencyRecord + SigningKeys []domain.SigningKey + SigningKeyPrivate map[string][]byte + Signatures []domain.Signature + ReleaseBundles []domain.ReleaseBundle + VerificationResults []domain.VerificationResult + ProviderVerifications []domain.ProviderVerification + VulnerabilityDecisions []domain.VulnerabilityDecision + AuditChainEntries []domain.AuditChainEntry + OutboxJobs []OutboxJob +} + +type ReleaseLedgerMutation struct { + Products []domain.Product + Projects []domain.Project + Releases []domain.Release + Artifacts []domain.Artifact + Evidence []domain.EvidenceItem + EvidenceLifecycle []domain.EvidenceLifecycleEvent + SBOMs []domain.SBOM + Scans []domain.VulnerabilityScan + Contracts []domain.OpenAPIContract + VEXDocuments []domain.VEXDocument + VEXImportReports []domain.VEXImportReport + VulnerabilityDecisions []domain.VulnerabilityDecision + AuditChainEntries []domain.AuditChainEntry + OutboxJobs []OutboxJob +} + type nopOutbox struct{} func (nopOutbox) Enqueue(context.Context, OutboxJob) error { return nil } diff --git a/internal/app/productization_flow_test.go b/internal/app/productization_flow_test.go new file mode 100644 index 0000000..422e828 --- /dev/null +++ b/internal/app/productization_flow_test.go @@ -0,0 +1,236 @@ +package app + +import ( + "context" + "encoding/json" + "strings" + "testing" + "time" + + "github.com/aatuh/evydence/internal/domain" +) + +func TestVEXFirstReleaseEvidenceFlowEndToEnd(t *testing.T) { + ledger := NewLedger(Config{APIKeyPepper: "test-pepper", Now: fixedNow}) + ctx := context.Background() + _, _, secret, err := ledger.BootstrapTenant(ctx, "Design Partner", "admin", []string{"*"}) + if err != nil { + t.Fatalf("bootstrap: %v", err) + } + actor, err := ledger.Authenticate(ctx, secret) + if err != nil { + t.Fatalf("auth: %v", err) + } + + product, err := ledger.CreateProduct(ctx, actor, "Demo Payments API", "demo-payments-api") + if err != nil { + t.Fatalf("product: %v", err) + } + release, err := ledger.CreateRelease(ctx, actor, product.ID, "2026.05.0") + if err != nil { + t.Fatalf("release: %v", err) + } + project, err := ledger.CreateProject(ctx, actor, product.ID, "payments-api") + if err != nil { + t.Fatalf("project: %v", err) + } + artifact, err := ledger.RegisterArtifact(ctx, actor, "payments-api-linux-amd64.tar.gz", "application/gzip", sampleDigest("artifact"), 123456) + if err != nil { + t.Fatalf("artifact: %v", err) + } + + sbom, err := ledger.UploadSBOM(ctx, actor, release.ID, artifact.ID, []byte(`{ + "bomFormat":"CycloneDX", + "specVersion":"1.6", + "components":[ + {"name":"openssl","version":"3.1.0","purl":"pkg:apk/openssl@3.1.0"}, + {"name":"curl","version":"8.0.0","purl":"pkg:apk/curl@8.0.0"} + ] + }`)) + if err != nil { + t.Fatalf("sbom: %v", err) + } + if sbom.ComponentCount != 2 || sbom.EvidenceID == "" { + t.Fatalf("sbom parsed metadata = %#v", sbom) + } + + scan, err := ledger.UploadVulnerabilityScan(ctx, actor, []byte(`{ + "scanner":"grype", + "target_ref":"pkg:oci/demo-payments-api@sha256:example", + "release_id":"`+release.ID+`", + "findings":[ + {"vulnerability":"CVE-2026-1000","component":"pkg:apk/openssl@3.1.0","severity":"critical","state":"open"}, + {"vulnerability":"CVE-2026-1001","component":"pkg:apk/curl@8.0.0","severity":"high","state":"open"} + ] + }`)) + if err != nil { + t.Fatalf("scan: %v", err) + } + if len(scan.Findings) != 2 { + t.Fatalf("scan findings = %#v", scan.Findings) + } + + vex, err := ledger.UploadVEX(ctx, actor, release.ID, artifact.ID, openVEXFixture(t, []map[string]any{ + openVEXStatementFixture("CVE-2026-1000", []map[string]any{{"@id": "pkg:apk/openssl@3.1.0"}}, decisionStatusNotAffected, "component_not_present"), + })) + if err != nil { + t.Fatalf("vex: %v", err) + } + importReport, err := ledger.GetVEXImportReport(ctx, actor, vex.ID) + if err != nil { + t.Fatalf("vex import report: %v", err) + } + if importReport.DecisionsCreated != 1 || len(importReport.MappingFailures) != 0 { + t.Fatalf("vex import report = %#v", importReport) + } + active := true + decisions, err := ledger.ListVulnerabilityDecisions(ctx, actor, ListVulnerabilityDecisionsInput{ReleaseID: release.ID, Vulnerability: "CVE-2026-1000", Active: &active}) + if err != nil { + t.Fatalf("list decisions: %v", err) + } + if len(decisions) != 1 || decisions[0].Source != "vex" || decisions[0].VEXDocumentID != vex.ID || !decisions[0].CustomerVisible { + t.Fatalf("vex decisions = %#v", decisions) + } + + exception, err := ledger.CreateException(ctx, actor, CreateExceptionInput{ + ReleaseID: release.ID, + FindingID: scan.Findings[1].ID, + Reason: "accepted for the pilot release while vendor update is scheduled", + Owner: "security", + ExpiresAt: fixedNow().Add(24 * time.Hour), + }) + if err != nil { + t.Fatalf("exception: %v", err) + } + if _, err := ledger.ApproveException(ctx, actor, exception.ID); err != nil { + t.Fatalf("approve exception: %v", err) + } + + build, err := ledger.CreateBuildRun(ctx, actor, CreateBuildRunInput{ + ProjectID: project.ID, + ReleaseID: release.ID, + Provider: "github_actions", + CommitSHA: "0123456789abcdef0123456789abcdef01234567", + Repository: "aatuh/evydence", + WorkflowRef: "aatuh/evydence/.github/workflows/release.yml@refs/heads/main", + RunID: "123456789", + RunAttempt: 1, + Status: "passed", + StartedAt: fixedNow(), + Outputs: []domain.BuildOutput{{ArtifactID: artifact.ID, Digest: artifact.Digest}}, + }) + if err != nil { + t.Fatalf("build: %v", err) + } + if _, err := ledger.UploadBuildAttestation(ctx, actor, build.ID, dsseForDigest(t, artifact.Digest)); err != nil { + t.Fatalf("attestation: %v", err) + } + + bundle, err := ledger.CreateReleaseBundle(ctx, actor, release.ID) + if err != nil { + t.Fatalf("bundle: %v", err) + } + bundleVerification, err := ledger.VerifySubject(ctx, actor, "release_bundle", bundle.ID) + if err != nil { + t.Fatalf("verify bundle: %v", err) + } + if bundleVerification.Result != "passed" { + t.Fatalf("bundle verification = %#v", bundleVerification) + } + + readiness, err := ledger.ReleaseReadinessReport(ctx, actor, release.ID) + if err != nil { + t.Fatalf("readiness: %v", err) + } + if readiness.Result != "passed" || len(readiness.BlockingFindings) != 0 || len(readiness.AcceptedExceptions) != 1 { + t.Fatalf("readiness = %#v", readiness) + } + if policyCheckByName(t, readiness.Checks, "critical_exploitable_blocks_release").Result != "passed" { + t.Fatalf("critical policy should pass: %#v", readiness.Checks) + } + + profile, err := ledger.CreateRedactionProfile(ctx, actor, CreateRedactionProfileInput{ + Name: "customer release evidence", + AllowedTypes: []string{ + "artifact", "sbom", "vulnerability_scan", "vex", "vulnerability_decision", "exception", "build", "build_attestation", "release_bundle", + }, + }) + if err != nil { + t.Fatalf("redaction profile: %v", err) + } + pkg, err := ledger.CreateCustomerSecurityPackage(ctx, actor, CreateCustomerPackageInput{ + ProductID: product.ID, + ReleaseID: release.ID, + RedactionProfileID: profile.ID, + Title: "Demo Payments API release evidence", + ExpiresAt: fixedNow().Add(7 * 24 * time.Hour), + }) + if err != nil { + t.Fatalf("customer package: %v", err) + } + if pkg.ManifestHash == "" || pkg.SchemaVersion != domain.CustomerPackageSchemaVersion { + t.Fatalf("package metadata = %#v", pkg) + } + manifestHash, err := canonicalAnyHash(pkg.Manifest) + if err != nil { + t.Fatalf("hash manifest: %v", err) + } + if manifestHash != pkg.ManifestHash { + t.Fatalf("manifest hash = %s, want %s", manifestHash, pkg.ManifestHash) + } + + archive, err := ledger.ExportCustomerSecurityPackageArchive(ctx, actor, pkg.ID) + if err != nil { + t.Fatalf("export archive: %v", err) + } + files := packageArchiveFiles(t, archive.Bytes) + for _, name := range []string{"manifest.json", "package.json", "verification.json", "README.txt", "report.html", "vulnerability-decisions.json"} { + if strings.TrimSpace(files[name]) == "" { + t.Fatalf("archive missing %s: %#v", name, files) + } + } + manifestText := files["manifest.json"] + for _, want := range []string{product.ID, release.ID, artifact.Digest, sbom.ID, scan.ID, vex.ID, decisions[0].ID, exception.ID, bundle.ManifestHash, "audit_chain", "passed"} { + if !strings.Contains(manifestText, want) { + t.Fatalf("manifest missing %q: %s", want, manifestText) + } + } + for _, forbidden := range []string{"payload_ref", "object://", "api_key_secret", "private_key_value", "bearer_secret_value", "database_url"} { + if strings.Contains(strings.ToLower(manifestText), forbidden) { + t.Fatalf("manifest leaked %q: %s", forbidden, manifestText) + } + } + reportHTML := files["report.html"] + if !strings.Contains(reportHTML, "Release Summary") || !strings.Contains(reportHTML, "Verification") || strings.Contains(reportHTML, " 0 { + privateKeyMaterial = base64.RawStdEncoding.EncodeToString(key.Private) + break + } + } + ledger.mu.Unlock() + if privateKeyMaterial == "" { + t.Fatal("test setup did not create signing key material") + } + + profile, err := ledger.CreateRedactionProfile(ctx, actor, CreateRedactionProfileInput{ + Name: "customer leakage guard", + AllowedTypes: []string{ + "artifact", "sbom", "vulnerability_scan", "vulnerability_decision", "security_review", "secret_scan", "build", "build_attestation", "release_bundle", + }, + ExcludedFields: []string{"action_statement", "justification", "evidence_id", "evidence_ids", "internal_url"}, + }) + if err != nil { + t.Fatalf("redaction profile: %v", err) + } + pkg, err := ledger.CreateCustomerSecurityPackage(ctx, actor, CreateCustomerPackageInput{ + ProductID: release.ProductID, + ReleaseID: release.ID, + RedactionProfileID: profile.ID, + Title: "Customer leakage guard", + ExpiresAt: fixedNow().Add(24 * time.Hour), + }) + if err != nil { + t.Fatalf("package: %v", err) + } + archive, err := ledger.ExportCustomerSecurityPackageArchive(ctx, actor, pkg.ID) + if err != nil { + t.Fatalf("archive: %v", err) + } + files := packageArchiveFiles(t, archive.Bytes) + packageText := strings.Join(mapValues(files), "\n") + if !strings.Contains(packageText, decision.ImpactStatement) || !strings.Contains(packageText, bundle.ManifestHash) { + t.Fatalf("package missing expected customer-safe evidence: %s", packageText) + } + for _, forbidden := range []string{ + apiSecret, + internalNote, + internalURL, + rawScannerPayload, + tenantSecret, + privateMaterialCanary, + privateKeyMaterial, + securityScan.PayloadRef, + "payload_ref", + "object://", + "source_identity", + "uploaded_by", + "canonical_hash", + } { + if forbidden == "" { + continue + } + if strings.Contains(strings.ToLower(packageText), strings.ToLower(forbidden)) { + t.Fatalf("customer package leaked %q: %s", forbidden, packageText) + } + } +} + +func TestSampleCustomerPackageFixtureHasNoRedactionLeakage(t *testing.T) { + body, err := os.ReadFile("../../examples/end-to-end-release-evidence/sample-customer-package-manifest.json") + if err != nil { + t.Fatalf("read fixture: %v", err) + } + text := strings.ToLower(string(body)) + for _, forbidden := range []string{ + "payload_ref", + "object://", + "source_identity", + "uploaded_by", + "api_key_secret", + "private_key", + "database_url", + "raw_payload", + } { + if strings.Contains(text, forbidden) { + t.Fatalf("sample customer package fixture leaked %q: %s", forbidden, body) + } + } +} diff --git a/internal/app/release_evidence_service.go b/internal/app/release_evidence_service.go new file mode 100644 index 0000000..2edf554 --- /dev/null +++ b/internal/app/release_evidence_service.go @@ -0,0 +1,151 @@ +package app + +import ( + "context" + + "github.com/aatuh/evydence/internal/domain" +) + +type releaseEvidenceService struct { + ledger *Ledger +} + +func (l *Ledger) releaseEvidenceService() releaseEvidenceService { + return releaseEvidenceService{ledger: l} +} + +func (l *Ledger) CreateProduct(ctx context.Context, actor domain.Actor, name, slug string) (domain.Product, error) { + return l.releaseEvidenceService().CreateProduct(ctx, actor, name, slug) +} + +func (l *Ledger) ListProducts(ctx context.Context, actor domain.Actor) ([]domain.Product, error) { + return l.releaseEvidenceService().ListProducts(ctx, actor) +} + +func (l *Ledger) GetProduct(ctx context.Context, actor domain.Actor, id string) (domain.Product, error) { + return l.releaseEvidenceService().GetProduct(ctx, actor, id) +} + +func (l *Ledger) CreateProject(ctx context.Context, actor domain.Actor, productID, name string) (domain.Project, error) { + return l.releaseEvidenceService().CreateProject(ctx, actor, productID, name) +} + +func (l *Ledger) GetProject(ctx context.Context, actor domain.Actor, id string) (domain.Project, error) { + return l.releaseEvidenceService().GetProject(ctx, actor, id) +} + +func (l *Ledger) CreateRelease(ctx context.Context, actor domain.Actor, productID, version string) (domain.Release, error) { + return l.releaseEvidenceService().CreateRelease(ctx, actor, productID, version) +} + +func (l *Ledger) GetRelease(ctx context.Context, actor domain.Actor, releaseID string) (domain.Release, error) { + return l.releaseEvidenceService().GetRelease(ctx, actor, releaseID) +} + +func (l *Ledger) FreezeRelease(ctx context.Context, actor domain.Actor, releaseID string) (domain.Release, error) { + return l.releaseEvidenceService().FreezeRelease(ctx, actor, releaseID) +} + +func (l *Ledger) ApproveRelease(ctx context.Context, actor domain.Actor, releaseID string) (domain.Release, error) { + return l.releaseEvidenceService().ApproveRelease(ctx, actor, releaseID) +} + +func (l *Ledger) RegisterArtifact(ctx context.Context, actor domain.Actor, name, mediaType, digest string, size int64) (domain.Artifact, error) { + return l.releaseEvidenceService().RegisterArtifact(ctx, actor, name, mediaType, digest, size) +} + +func (l *Ledger) GetArtifact(ctx context.Context, actor domain.Actor, id string) (domain.Artifact, error) { + return l.releaseEvidenceService().GetArtifact(ctx, actor, id) +} + +func (l *Ledger) CreateEvidence(ctx context.Context, actor domain.Actor, in CreateEvidenceInput) (domain.EvidenceItem, error) { + return l.releaseEvidenceService().CreateEvidence(ctx, actor, in) +} + +func (l *Ledger) GetEvidence(ctx context.Context, actor domain.Actor, id string) (domain.EvidenceItem, error) { + return l.releaseEvidenceService().GetEvidence(ctx, actor, id) +} + +func (l *Ledger) ListEvidence(ctx context.Context, actor domain.Actor, releaseID, typ string) ([]domain.EvidenceItem, error) { + return l.releaseEvidenceService().ListEvidence(ctx, actor, releaseID, typ) +} + +func (l *Ledger) SupersedeEvidence(ctx context.Context, actor domain.Actor, id, replacementID, reason string) (domain.EvidenceItem, error) { + return l.releaseEvidenceService().SupersedeEvidence(ctx, actor, id, replacementID, reason) +} + +func (l *Ledger) LinkEvidence(ctx context.Context, actor domain.Actor, id, targetType, targetID string) (domain.EvidenceItem, error) { + return l.releaseEvidenceService().LinkEvidence(ctx, actor, id, targetType, targetID) +} + +func (l *Ledger) UploadSBOM(ctx context.Context, actor domain.Actor, releaseID, artifactID string, raw []byte) (domain.SBOM, error) { + return l.releaseEvidenceService().UploadSBOM(ctx, actor, releaseID, artifactID, raw) +} + +func (l *Ledger) UploadVulnerabilityScan(ctx context.Context, actor domain.Actor, raw []byte) (domain.VulnerabilityScan, error) { + return l.releaseEvidenceService().UploadVulnerabilityScan(ctx, actor, raw) +} + +func (l *Ledger) UploadOpenAPIContract(ctx context.Context, actor domain.Actor, productID, releaseID, version string, raw []byte) (domain.OpenAPIContract, error) { + return l.releaseEvidenceService().UploadOpenAPIContract(ctx, actor, productID, releaseID, version, raw) +} + +func (l *Ledger) GetSBOM(ctx context.Context, actor domain.Actor, id string) (domain.SBOM, error) { + return l.releaseEvidenceService().GetSBOM(ctx, actor, id) +} + +func (l *Ledger) ListSBOMComponents(ctx context.Context, actor domain.Actor, in ListSBOMComponentsInput) ([]domain.SBOMComponentRecord, error) { + return l.releaseEvidenceService().ListSBOMComponents(ctx, actor, in) +} + +func (l *Ledger) GetVulnerabilityScan(ctx context.Context, actor domain.Actor, id string) (domain.VulnerabilityScan, error) { + return l.releaseEvidenceService().GetVulnerabilityScan(ctx, actor, id) +} + +func (l *Ledger) GetOpenAPIContract(ctx context.Context, actor domain.Actor, id string) (domain.OpenAPIContract, error) { + return l.releaseEvidenceService().GetOpenAPIContract(ctx, actor, id) +} + +func (l *Ledger) UploadVEX(ctx context.Context, actor domain.Actor, releaseID, artifactID string, raw []byte) (domain.VEXDocument, error) { + return l.releaseEvidenceService().UploadVEX(ctx, actor, releaseID, artifactID, raw) +} + +func (l *Ledger) PreviewVEXImport(ctx context.Context, actor domain.Actor, releaseID, artifactID string, raw []byte) (domain.VEXImportPreview, error) { + return l.releaseEvidenceService().PreviewVEXImport(ctx, actor, releaseID, artifactID, raw) +} + +func (l *Ledger) GetVEXDocument(ctx context.Context, actor domain.Actor, id string) (domain.VEXDocument, error) { + return l.releaseEvidenceService().GetVEXDocument(ctx, actor, id) +} + +func (l *Ledger) GetVEXImportReport(ctx context.Context, actor domain.Actor, vexID string) (domain.VEXImportReport, error) { + return l.releaseEvidenceService().GetVEXImportReport(ctx, actor, vexID) +} + +func (l *Ledger) CreateVulnerabilityDecision(ctx context.Context, actor domain.Actor, findingID string, in CreateVulnerabilityDecisionInput) (domain.VulnerabilityDecision, error) { + return l.releaseEvidenceService().CreateVulnerabilityDecision(ctx, actor, findingID, in) +} + +func (l *Ledger) ListVulnerabilityDecisions(ctx context.Context, actor domain.Actor, in ListVulnerabilityDecisionsInput) ([]domain.VulnerabilityDecision, error) { + return l.releaseEvidenceService().ListVulnerabilityDecisions(ctx, actor, in) +} + +func (l *Ledger) VulnerabilityDecisionSummaryReport(ctx context.Context, actor domain.Actor, releaseID string) (domain.VulnerabilityDecisionSummaryReport, error) { + return l.releaseEvidenceService().VulnerabilityDecisionSummaryReport(ctx, actor, releaseID) +} + +func (l *Ledger) CreateException(ctx context.Context, actor domain.Actor, in CreateExceptionInput) (domain.Exception, error) { + return l.releaseEvidenceService().CreateException(ctx, actor, in) +} + +func (l *Ledger) ListExceptions(ctx context.Context, actor domain.Actor, releaseID string) ([]domain.Exception, error) { + return l.releaseEvidenceService().ListExceptions(ctx, actor, releaseID) +} + +func (l *Ledger) ApproveException(ctx context.Context, actor domain.Actor, id string) (domain.Exception, error) { + return l.releaseEvidenceService().ApproveException(ctx, actor, id) +} + +func (l *Ledger) ReleaseReadinessReport(ctx context.Context, actor domain.Actor, releaseID string) (domain.ReleaseReadinessReport, error) { + return l.releaseEvidenceService().ReleaseReadinessReport(ctx, actor, releaseID) +} diff --git a/internal/app/risk_workflows.go b/internal/app/risk_workflows.go index 7003361..b97f69b 100644 --- a/internal/app/risk_workflows.go +++ b/internal/app/risk_workflows.go @@ -3,6 +3,8 @@ package app import ( "bytes" "context" + "crypto/ed25519" + "encoding/base64" "encoding/json" "io" "sort" @@ -27,6 +29,21 @@ type RecordIncidentTimelineInput struct { OccurredAt time.Time } +type CreateIncidentWebhookReceiverInput struct { + IncidentID string + Name string + Provider string + PublicKey string +} + +type HandleIncidentWebhookInput struct { + ReceiverID string + EventID string + Timestamp time.Time + Signature string + Body []byte +} + type CreateRemediationTaskInput struct { IncidentID string ReleaseID string @@ -82,6 +99,27 @@ type CreateCustomPolicyInput struct { Rules []domain.PolicyRule } +const incidentWebhookTimestampTolerance = 5 * time.Minute + +type cycloneDXVEXDocument struct { + BOMFormat string `json:"bomFormat"` + SpecVersion string `json:"specVersion"` + Vulnerabilities []cycloneDXVEXVulnerability `json:"vulnerabilities"` +} + +type cycloneDXVEXVulnerability struct { + ID string `json:"id"` + Affects []struct { + Ref string `json:"ref"` + } `json:"affects,omitempty"` + Analysis struct { + State string `json:"state"` + Justification string `json:"justification"` + Detail string `json:"detail"` + Response []string `json:"response"` + } `json:"analysis"` +} + func (l *Ledger) CreateIncident(ctx context.Context, actor domain.Actor, in CreateIncidentInput) (domain.Incident, error) { if err := ctx.Err(); err != nil { return domain.Incident{}, err @@ -102,6 +140,9 @@ func (l *Ledger) CreateIncident(ctx context.Context, actor domain.Actor, in Crea if err := l.ensureScopeLocked(actor.TenantID, in.ProductID, "", in.ReleaseID); err != nil { return domain.Incident{}, err } + if err := l.authorizeResourceLocked(actor, ScopeIncidentWrite, resourceRefs{ProductID: in.ProductID, ReleaseID: in.ReleaseID}); err != nil { + return domain.Incident{}, err + } incident := domain.Incident{ ID: newID("inc"), TenantID: actor.TenantID, @@ -142,11 +183,17 @@ func (l *Ledger) RecordIncidentTimelineEvent(ctx context.Context, actor domain.A if !ok || incident.TenantID != actor.TenantID { return domain.IncidentTimelineEvent{}, ErrNotFound } + if err := l.authorizeResourceLocked(actor, ScopeIncidentWrite, resourceRefs{ProductID: incident.ProductID, ReleaseID: incident.ReleaseID, IncidentID: incident.ID}); err != nil { + return domain.IncidentTimelineEvent{}, err + } if in.EvidenceID != "" { item, ok := l.evidence[strings.TrimSpace(in.EvidenceID)] if !ok || item.TenantID != actor.TenantID { return domain.IncidentTimelineEvent{}, ErrNotFound } + if err := l.authorizeResourceLocked(actor, ScopeIncidentWrite, refsForEvidence(item)); err != nil { + return domain.IncidentTimelineEvent{}, err + } } event := domain.IncidentTimelineEvent{ ID: newID("it"), @@ -167,6 +214,189 @@ func (l *Ledger) RecordIncidentTimelineEvent(ctx context.Context, actor domain.A return event, nil } +func (l *Ledger) CreateIncidentWebhookReceiver(ctx context.Context, actor domain.Actor, in CreateIncidentWebhookReceiverInput) (domain.IncidentWebhookReceiver, error) { + if err := ctx.Err(); err != nil { + return domain.IncidentWebhookReceiver{}, err + } + if err := require(actor, ScopeIncidentWrite); err != nil { + return domain.IncidentWebhookReceiver{}, err + } + in.IncidentID = strings.TrimSpace(in.IncidentID) + in.Name = strings.TrimSpace(in.Name) + in.Provider = strings.TrimSpace(in.Provider) + in.PublicKey = strings.TrimSpace(in.PublicKey) + publicKey, err := decodeWebhookPublicKey(in.PublicKey) + if err != nil || in.IncidentID == "" || in.Name == "" || in.Provider == "" { + return domain.IncidentWebhookReceiver{}, ErrValidation + } + l.mu.Lock() + defer l.mu.Unlock() + incident, ok := l.incidents[in.IncidentID] + if !ok || incident.TenantID != actor.TenantID { + return domain.IncidentWebhookReceiver{}, ErrNotFound + } + if err := l.authorizeResourceLocked(actor, ScopeIncidentWrite, resourceRefs{ProductID: incident.ProductID, ReleaseID: incident.ReleaseID, IncidentID: incident.ID}); err != nil { + return domain.IncidentWebhookReceiver{}, err + } + receiver := domain.IncidentWebhookReceiver{ + ID: newID("iwr"), + TenantID: actor.TenantID, + IncidentID: incident.ID, + Name: in.Name, + Provider: in.Provider, + PublicKey: base64.RawStdEncoding.EncodeToString(publicKey), + Status: "active", + SchemaVersion: domain.IncidentWebhookReceiverVersion, + CreatedAt: l.now(), + } + l.webhookReceivers[receiver.ID] = receiver + _, _ = l.appendChainLocked(actor.TenantID, "incident_webhook_receiver.created", "incident_webhook_receiver", receiver.ID, actorType(actor), actorID(actor), "", "") + if err := l.persistLocked(ctx); err != nil { + return domain.IncidentWebhookReceiver{}, err + } + return receiver, nil +} + +func (l *Ledger) HandleIncidentWebhook(ctx context.Context, in HandleIncidentWebhookInput) (domain.IncidentWebhookEvent, domain.IncidentTimelineEvent, error) { + if err := ctx.Err(); err != nil { + return domain.IncidentWebhookEvent{}, domain.IncidentTimelineEvent{}, err + } + in.ReceiverID = strings.TrimSpace(in.ReceiverID) + in.EventID = strings.TrimSpace(in.EventID) + in.Signature = strings.TrimSpace(in.Signature) + if in.ReceiverID == "" || in.EventID == "" || in.Timestamp.IsZero() || in.Signature == "" || len(in.Body) == 0 || len(in.Body) > 2<<20 { + return domain.IncidentWebhookEvent{}, domain.IncidentTimelineEvent{}, ErrValidation + } + now := l.now() + if in.Timestamp.Before(now.Add(-incidentWebhookTimestampTolerance)) || in.Timestamp.After(now.Add(incidentWebhookTimestampTolerance)) { + return domain.IncidentWebhookEvent{}, domain.IncidentTimelineEvent{}, ErrUnauthorized + } + payloadHash := hashBytes(in.Body) + signatureBytes, err := decodeWebhookSignature(in.Signature) + if err != nil { + return domain.IncidentWebhookEvent{}, domain.IncidentTimelineEvent{}, ErrUnauthorized + } + l.mu.Lock() + defer l.mu.Unlock() + receiver, ok := l.webhookReceivers[in.ReceiverID] + if !ok || receiver.Status != "active" { + return domain.IncidentWebhookEvent{}, domain.IncidentTimelineEvent{}, ErrNotFound + } + publicKey, err := decodeWebhookPublicKey(receiver.PublicKey) + if err != nil { + return domain.IncidentWebhookEvent{}, domain.IncidentTimelineEvent{}, ErrUnauthorized + } + signedPayload := incidentWebhookSignedPayload(in.Timestamp, in.EventID, in.Body) + if !ed25519.Verify(ed25519.PublicKey(publicKey), signedPayload, signatureBytes) { + return domain.IncidentWebhookEvent{}, domain.IncidentTimelineEvent{}, ErrUnauthorized + } + for _, existing := range l.webhookEvents { + if existing.ReceiverID != receiver.ID || existing.EventID != in.EventID { + continue + } + if existing.PayloadHash != payloadHash { + return domain.IncidentWebhookEvent{}, domain.IncidentTimelineEvent{}, ErrConflict + } + timeline, ok := l.timeline[existing.TimelineEventID] + if !ok { + return domain.IncidentWebhookEvent{}, domain.IncidentTimelineEvent{}, ErrConflict + } + return existing, timeline, nil + } + incident, ok := l.incidents[receiver.IncidentID] + if !ok || incident.TenantID != receiver.TenantID { + return domain.IncidentWebhookEvent{}, domain.IncidentTimelineEvent{}, ErrNotFound + } + var payload struct { + EventType string `json:"event_type"` + Summary string `json:"summary"` + EvidenceID string `json:"evidence_id"` + OccurredAt time.Time `json:"occurred_at"` + } + dec := json.NewDecoder(bytes.NewReader(in.Body)) + dec.DisallowUnknownFields() + if err := dec.Decode(&payload); err != nil { + return domain.IncidentWebhookEvent{}, domain.IncidentTimelineEvent{}, ErrValidation + } + payload.EventType = strings.TrimSpace(payload.EventType) + payload.Summary = strings.TrimSpace(payload.Summary) + payload.EvidenceID = strings.TrimSpace(payload.EvidenceID) + if payload.EventType == "" || payload.Summary == "" { + return domain.IncidentWebhookEvent{}, domain.IncidentTimelineEvent{}, ErrValidation + } + if payload.OccurredAt.IsZero() { + payload.OccurredAt = now + } + if payload.EvidenceID != "" { + item, ok := l.evidence[payload.EvidenceID] + if !ok || item.TenantID != receiver.TenantID || (item.ReleaseID != "" && item.ReleaseID != incident.ReleaseID) { + return domain.IncidentWebhookEvent{}, domain.IncidentTimelineEvent{}, ErrNotFound + } + } + timeline := domain.IncidentTimelineEvent{ + ID: newID("it"), + TenantID: receiver.TenantID, + IncidentID: incident.ID, + EventType: payload.EventType, + Summary: payload.Summary, + EvidenceID: payload.EvidenceID, + OccurredAt: payload.OccurredAt.UTC(), + SchemaVersion: domain.IncidentTimelineSchemaVersion, + CreatedAt: now, + } + record := domain.IncidentWebhookEvent{ + ID: newID("iwe"), + TenantID: receiver.TenantID, + ReceiverID: receiver.ID, + IncidentID: incident.ID, + Provider: receiver.Provider, + EventID: in.EventID, + PayloadHash: payloadHash, + SignatureHash: hashBytes(signatureBytes), + TimelineEventID: timeline.ID, + Result: "accepted", + SchemaVersion: domain.IncidentWebhookEventVersion, + CreatedAt: now, + } + l.timeline[timeline.ID] = timeline + l.webhookEvents[record.ID] = record + _, _ = l.appendChainLocked(receiver.TenantID, "incident.webhook_timeline_recorded", "incident", incident.ID, "webhook", receiver.ID, payloadHash, "") + if err := l.persistLocked(ctx); err != nil { + return domain.IncidentWebhookEvent{}, domain.IncidentTimelineEvent{}, err + } + return record, timeline, nil +} + +func incidentWebhookSignedPayload(timestamp time.Time, eventID string, body []byte) []byte { + prefix := timestamp.UTC().Format(time.RFC3339) + "\n" + strings.TrimSpace(eventID) + "\n" + return append([]byte(prefix), body...) +} + +func decodeWebhookPublicKey(value string) ([]byte, error) { + key, err := decodeBase64Value(strings.TrimSpace(value)) + if err != nil || len(key) != ed25519.PublicKeySize { + return nil, ErrValidation + } + return key, nil +} + +func decodeWebhookSignature(value string) ([]byte, error) { + value = strings.TrimSpace(value) + value = strings.TrimPrefix(value, "ed25519=") + signature, err := decodeBase64Value(value) + if err != nil || len(signature) != ed25519.SignatureSize { + return nil, ErrUnauthorized + } + return signature, nil +} + +func decodeBase64Value(value string) ([]byte, error) { + if decoded, err := base64.RawStdEncoding.DecodeString(value); err == nil { + return decoded, nil + } + return base64.StdEncoding.DecodeString(value) +} + func (l *Ledger) CreateRemediationTask(ctx context.Context, actor domain.Actor, in CreateRemediationTaskInput) (domain.RemediationTask, error) { if err := ctx.Err(); err != nil { return domain.RemediationTask{}, err @@ -186,18 +416,27 @@ func (l *Ledger) CreateRemediationTask(ctx context.Context, actor domain.Actor, if !ok || incident.TenantID != actor.TenantID { return domain.RemediationTask{}, ErrNotFound } + if err := l.authorizeResourceLocked(actor, ScopeIncidentWrite, resourceRefs{ProductID: incident.ProductID, ReleaseID: incident.ReleaseID, IncidentID: incident.ID}); err != nil { + return domain.RemediationTask{}, err + } } if in.ReleaseID != "" { release, ok := l.releases[in.ReleaseID] if !ok || release.TenantID != actor.TenantID { return domain.RemediationTask{}, ErrNotFound } + if err := l.authorizeResourceLocked(actor, ScopeIncidentWrite, resourceRefs{ProductID: release.ProductID, ReleaseID: release.ID}); err != nil { + return domain.RemediationTask{}, err + } } if in.EvidenceID != "" { item, ok := l.evidence[strings.TrimSpace(in.EvidenceID)] if !ok || item.TenantID != actor.TenantID { return domain.RemediationTask{}, ErrNotFound } + if err := l.authorizeResourceLocked(actor, ScopeIncidentWrite, refsForEvidence(item)); err != nil { + return domain.RemediationTask{}, err + } } task := domain.RemediationTask{ ID: newID("rt"), @@ -220,7 +459,8 @@ func (l *Ledger) CreateRemediationTask(ctx context.Context, actor domain.Actor, return task, nil } -func (l *Ledger) IncidentReport(ctx context.Context, actor domain.Actor, incidentID string) (domain.IncidentReport, error) { +func (s packageReportService) IncidentReport(ctx context.Context, actor domain.Actor, incidentID string) (domain.IncidentReport, error) { + l := s.ledger if err := ctx.Err(); err != nil { return domain.IncidentReport{}, err } @@ -233,6 +473,9 @@ func (l *Ledger) IncidentReport(ctx context.Context, actor domain.Actor, inciden if !ok || incident.TenantID != actor.TenantID { return domain.IncidentReport{}, ErrNotFound } + if err := l.authorizeResourceLocked(actor, ScopeIncidentRead, resourceRefs{ProductID: incident.ProductID, ReleaseID: incident.ReleaseID, IncidentID: incident.ID}); err != nil { + return domain.IncidentReport{}, err + } timeline := []domain.IncidentTimelineEvent{} linked := []string{} for _, event := range l.timeline { @@ -300,6 +543,16 @@ func (l *Ledger) uploadSecurityScan(ctx context.Context, actor domain.Actor, in if in.Format == "" { in.Format = parsed.Format } + l.mu.Lock() + if err := l.ensureScopeLocked(actor.TenantID, strings.TrimSpace(in.ProductID), "", strings.TrimSpace(in.ReleaseID)); err != nil { + l.mu.Unlock() + return domain.SecurityScan{}, err + } + if err := l.authorizeResourceLocked(actor, ScopeSecurityWrite, resourceRefs{ProductID: strings.TrimSpace(in.ProductID), ReleaseID: strings.TrimSpace(in.ReleaseID)}); err != nil { + l.mu.Unlock() + return domain.SecurityScan{}, err + } + l.mu.Unlock() payloadHash := hashBytes(in.Raw) payloadRef, err := l.storePayload(ctx, actor.TenantID, "security-scan", "application/json", payloadHash, in.Raw) if err != nil { @@ -329,11 +582,17 @@ func (l *Ledger) uploadSecurityScan(ctx context.Context, actor domain.Actor, in if err := l.ensureScopeLocked(actor.TenantID, in.ProductID, "", in.ReleaseID); err != nil { return domain.SecurityScan{}, err } + if err := l.authorizeResourceLocked(actor, ScopeSecurityWrite, resourceRefs{ProductID: in.ProductID, ReleaseID: in.ReleaseID}); err != nil { + return domain.SecurityScan{}, err + } if in.ArtifactID != "" { artifact, ok := l.artifacts[strings.TrimSpace(in.ArtifactID)] if !ok || artifact.TenantID != actor.TenantID { return domain.SecurityScan{}, ErrNotFound } + if err := l.authorizeResourceLocked(actor, ScopeSecurityWrite, resourceRefs{ProductID: in.ProductID, ReleaseID: in.ReleaseID, ArtifactID: artifact.ID}); err != nil { + return domain.SecurityScan{}, err + } } scan := domain.SecurityScan{ ID: newID("secscan"), @@ -374,6 +633,16 @@ func (l *Ledger) UploadManualSecurityDocument(ctx context.Context, actor domain. if len(in.Raw) == 0 || len(in.Raw) > 20<<20 || !validManualDocType(in.DocumentType) || in.Title == "" || !validSensitivity(in.Sensitivity) { return domain.ManualSecurityDocument{}, ErrValidation } + l.mu.Lock() + if err := l.ensureScopeLocked(actor.TenantID, strings.TrimSpace(in.ProductID), "", strings.TrimSpace(in.ReleaseID)); err != nil { + l.mu.Unlock() + return domain.ManualSecurityDocument{}, err + } + if err := l.authorizeResourceLocked(actor, ScopeSecurityWrite, resourceRefs{ProductID: strings.TrimSpace(in.ProductID), ReleaseID: strings.TrimSpace(in.ReleaseID)}); err != nil { + l.mu.Unlock() + return domain.ManualSecurityDocument{}, err + } + l.mu.Unlock() payloadHash := hashBytes(in.Raw) payloadRef, err := l.storePayload(ctx, actor.TenantID, "manual-security-document", nonEmpty(in.MediaType, "application/octet-stream"), payloadHash, in.Raw) if err != nil { @@ -402,6 +671,9 @@ func (l *Ledger) UploadManualSecurityDocument(ctx context.Context, actor domain. if err := l.ensureScopeLocked(actor.TenantID, in.ProductID, "", in.ReleaseID); err != nil { return domain.ManualSecurityDocument{}, err } + if err := l.authorizeResourceLocked(actor, ScopeSecurityWrite, resourceRefs{ProductID: in.ProductID, ReleaseID: in.ReleaseID}); err != nil { + return domain.ManualSecurityDocument{}, err + } doc := domain.ManualSecurityDocument{ ID: newID("msd"), TenantID: actor.TenantID, @@ -425,6 +697,12 @@ func (l *Ledger) UploadManualSecurityDocument(ctx context.Context, actor domain. } func (l *Ledger) UploadSPDXSBOM(ctx context.Context, actor domain.Actor, releaseID, artifactID string, raw []byte) (domain.SBOM, error) { + if err := ctx.Err(); err != nil { + return domain.SBOM{}, err + } + if err := require(actor, ScopeEvidenceWrite); err != nil { + return domain.SBOM{}, err + } if len(raw) == 0 || len(raw) > 20<<20 { return domain.SBOM{}, ErrValidation } @@ -456,6 +734,16 @@ func (l *Ledger) UploadSPDXSBOM(ctx context.Context, actor domain.Actor, release } components = append(components, domain.SBOMComponent{Name: pkg.Name, Version: pkg.VersionInfo, PURL: purl}) } + l.mu.Lock() + if err := l.ensureScopeLocked(actor.TenantID, "", "", strings.TrimSpace(releaseID)); err != nil { + l.mu.Unlock() + return domain.SBOM{}, err + } + if err := l.authorizeResourceLocked(actor, ScopeEvidenceWrite, resourceRefs{ReleaseID: strings.TrimSpace(releaseID)}); err != nil { + l.mu.Unlock() + return domain.SBOM{}, err + } + l.mu.Unlock() payloadHash := hashBytes(raw) payloadRef, err := l.storePayload(ctx, actor.TenantID, "sbom-spdx", "application/spdx+json", payloadHash, raw) if err != nil { @@ -484,7 +772,7 @@ func (l *Ledger) UploadSPDXSBOM(ctx context.Context, actor domain.Actor, release sbom := domain.SBOM{ID: newID("sbom"), TenantID: actor.TenantID, EvidenceID: item.ID, ReleaseID: releaseID, ArtifactID: artifactID, Format: "spdx", SpecVersion: doc.SPDXVersion, ComponentCount: len(components), Components: components, CreatedAt: l.now()} l.sboms[sbom.ID] = sbom _, _ = l.appendChainLocked(actor.TenantID, "sbom.parsed", "sbom", sbom.ID, "api_key", actor.KeyID, payloadHash, "") - if err := l.persistLocked(ctx); err != nil { + if err := l.persistReleaseLedgerLocked(ctx, l.releaseLedgerMutationLocked()); err != nil { return domain.SBOM{}, err } return sbom, nil @@ -504,6 +792,12 @@ func (l *Ledger) CreateSBOMDiff(ctx context.Context, actor domain.Actor, in Crea if !bok || !tok || base.TenantID != actor.TenantID || target.TenantID != actor.TenantID { return domain.SBOMDiff{}, ErrNotFound } + if err := l.authorizeResourceLocked(actor, ScopeEvidenceRead, resourceRefs{ReleaseID: base.ReleaseID, ArtifactID: base.ArtifactID}); err != nil { + return domain.SBOMDiff{}, err + } + if err := l.authorizeResourceLocked(actor, ScopeEvidenceRead, resourceRefs{ReleaseID: target.ReleaseID, ArtifactID: target.ArtifactID}); err != nil { + return domain.SBOMDiff{}, err + } added, removed, unchanged := diffComponents(base.Components, target.Components) diff := domain.SBOMDiff{ ID: newID("sdiff"), @@ -536,26 +830,35 @@ func (l *Ledger) CreateSBOMDiff(ctx context.Context, actor domain.Actor, in Crea } func (l *Ledger) UploadCycloneDXVEX(ctx context.Context, actor domain.Actor, releaseID, artifactID string, raw []byte) (domain.VEXDocument, error) { + if err := ctx.Err(); err != nil { + return domain.VEXDocument{}, err + } + if err := require(actor, ScopeEvidenceWrite); err != nil { + return domain.VEXDocument{}, err + } if len(raw) == 0 || len(raw) > 20<<20 { return domain.VEXDocument{}, ErrValidation } - var doc struct { - BOMFormat string `json:"bomFormat"` - SpecVersion string `json:"specVersion"` - Vulnerabilities []struct { - ID string `json:"id"` - Analysis struct { - State string `json:"state"` - Justification string `json:"justification"` - Detail string `json:"detail"` - Response []string `json:"response"` - } `json:"analysis"` - } `json:"vulnerabilities"` - } - if err := strictDecode(raw, &doc); err != nil || strings.ToLower(doc.BOMFormat) != "cyclonedx" || len(doc.Vulnerabilities) == 0 { + var doc cycloneDXVEXDocument + if err := strictDecode(raw, &doc); err != nil || !strings.EqualFold(strings.TrimSpace(doc.BOMFormat), "cyclonedx") || len(doc.Vulnerabilities) == 0 { return domain.VEXDocument{}, ErrValidation } - statusSummary := map[string]int{} + statusSummary, invalidStatements, validStatements := analyzeCycloneDXVEXStatements(doc) + if len(validStatements) == 0 { + return domain.VEXDocument{}, ErrValidation + } + releaseID = strings.TrimSpace(releaseID) + artifactID = strings.TrimSpace(artifactID) + l.mu.Lock() + if err := l.ensureScopeLocked(actor.TenantID, "", "", releaseID); err != nil { + l.mu.Unlock() + return domain.VEXDocument{}, err + } + if err := l.authorizeResourceLocked(actor, ScopeEvidenceWrite, resourceRefs{ReleaseID: releaseID}); err != nil { + l.mu.Unlock() + return domain.VEXDocument{}, err + } + l.mu.Unlock() payloadHash := hashBytes(raw) payloadRef, err := l.storePayload(ctx, actor.TenantID, "vex-cyclonedx", "application/vnd.cyclonedx+json", payloadHash, raw) if err != nil { @@ -572,31 +875,236 @@ func (l *Ledger) UploadCycloneDXVEX(ctx context.Context, actor domain.Actor, rel l.mu.Lock() defer l.mu.Unlock() vex := domain.VEXDocument{ID: newID("vex"), TenantID: actor.TenantID, EvidenceID: item.ID, ReleaseID: releaseID, ArtifactID: artifactID, Format: "cyclonedx", Author: "cyclonedx", Version: doc.SpecVersion, StatementCount: len(doc.Vulnerabilities), StatusSummary: statusSummary, SchemaVersion: domain.VEXDocumentSchemaVersion, CreatedAt: l.now()} - for _, vuln := range doc.Vulnerabilities { + persistedVEX := vex + chainAction := "vex.parsed" + if l.workerOwnedParsers { + persistedVEX.Author = "" + persistedVEX.StatementCount = 0 + persistedVEX.StatusSummary = nil + chainAction = "vex.accepted" + } + l.vexDocuments[vex.ID] = persistedVEX + createdDecisions := 0 + supersededDecisions := 0 + mappingFailures := []domain.VEXImportIssue{} + warnings := []string{} + createdForFinding := map[string]struct{}{} + duplicateWarningAdded := false + for _, statement := range validStatements { + matches := l.findCycloneDXVEXMatchingFindingsLocked(actor.TenantID, releaseID, statement) + if len(matches) == 0 { + mappingFailures = append(mappingFailures, vexImportIssue(statement.index, "finding_not_found", "No matching vulnerability scan finding was found for this CycloneDX VEX vulnerability.")) + } + if l.workerOwnedParsers { + continue + } + for _, matched := range matches { + if _, seen := createdForFinding[matched.finding.ID]; seen { + if !duplicateWarningAdded { + warnings = append(warnings, "Duplicate CycloneDX VEX vulnerabilities for an already mapped finding were ignored.") + duplicateWarningAdded = true + } + continue + } + createdForFinding[matched.finding.ID] = struct{}{} + decision := l.createDecisionLocked(actor.TenantID, matched.scan, matched.finding, CreateVulnerabilityDecisionInput{ + Status: statement.status, + Justification: nonEmpty(statement.vulnerability.Analysis.Justification, "cyclonedx_vex"), + ImpactStatement: strings.TrimSpace(statement.vulnerability.Analysis.Detail), + ActionStatement: strings.Join(statement.vulnerability.Analysis.Response, ","), + CustomerVisible: strings.TrimSpace(statement.vulnerability.Analysis.Detail) != "", + }, "cyclonedx_vex", actor.KeyID, item.ID, vex.ID) + l.decisions[decision.ID] = decision + if decision.Supersedes != "" { + supersededDecisions++ + } + l.appendDecisionLifecycleAuditLocked(actor.TenantID, decision, matched.finding.ID, "api_key", actor.KeyID, payloadHash) + createdDecisions++ + } + } + if len(invalidStatements) > 0 { + warnings = append(warnings, "One or more CycloneDX VEX vulnerabilities were skipped because required analysis fields were missing or unsupported.") + } + if l.workerOwnedParsers { + warnings = append(warnings, "Worker-owned parser side effects are enabled; CycloneDX VEX decisions are created asynchronously after payload replay.") + } + report := domain.VEXImportReport{ + ID: newID("vexrep"), + TenantID: actor.TenantID, + VEXDocumentID: vex.ID, + EvidenceID: item.ID, + ReleaseID: releaseID, + ArtifactID: artifactID, + ParserVersion: ParserVersionCycloneDXVEXJSON, + Status: ternary(l.workerOwnedParsers, "accepted", "parsed"), + StatementCount: len(doc.Vulnerabilities), + DecisionsCreated: createdDecisions, + DecisionsSuperseded: supersededDecisions, + UnsupportedFields: []string{}, + Warnings: warnings, + InvalidStatements: invalidStatements, + MappingFailures: mappingFailures, + SchemaVersion: domain.VEXImportReportSchemaVersion, + CreatedAt: l.now(), + UpdatedAt: l.now(), + } + l.vexImportReports[report.ID] = report + _, _ = l.appendChainLocked(actor.TenantID, chainAction, "vex_document", vex.ID, "api_key", actor.KeyID, payloadHash, "") + jobPayload := map[string]any{"payload_ref": payloadRef, "payload_hash": payloadHash, "parser_version": ParserVersionCycloneDXVEXJSON, "decisions_created": createdDecisions, "import_report_id": report.ID} + if l.workerOwnedParsers { + jobPayload["worker_create_decisions"] = true + jobPayload["actor_type"] = "api_key" + jobPayload["actor_id"] = actor.KeyID + jobPayload["evidence_id"] = item.ID + } + job := l.newOutboxJob(actor.TenantID, "parse_vex", "vex_document", vex.ID, jobPayload) + if err := l.persistReleaseLedgerWithOutboxLocked(ctx, job); err != nil { + return domain.VEXDocument{}, err + } + return vex, nil +} + +func (l *Ledger) PreviewCycloneDXVEXImport(ctx context.Context, actor domain.Actor, releaseID, artifactID string, raw []byte) (domain.VEXImportPreview, error) { + if err := ctx.Err(); err != nil { + return domain.VEXImportPreview{}, err + } + if err := require(actor, ScopeEvidenceRead); err != nil { + return domain.VEXImportPreview{}, err + } + if len(raw) == 0 || len(raw) > 20<<20 { + return domain.VEXImportPreview{}, ErrValidation + } + var doc cycloneDXVEXDocument + if err := strictDecode(raw, &doc); err != nil || !strings.EqualFold(strings.TrimSpace(doc.BOMFormat), "cyclonedx") || len(doc.Vulnerabilities) == 0 { + return domain.VEXImportPreview{}, ErrValidation + } + statusSummary, invalidStatements, validStatements := analyzeCycloneDXVEXStatements(doc) + if len(validStatements) == 0 { + return domain.VEXImportPreview{}, ErrValidation + } + releaseID = strings.TrimSpace(releaseID) + artifactID = strings.TrimSpace(artifactID) + if releaseID == "" { + return domain.VEXImportPreview{}, ErrValidation + } + l.mu.Lock() + defer l.mu.Unlock() + if err := l.ensureScopeLocked(actor.TenantID, "", "", releaseID); err != nil { + return domain.VEXImportPreview{}, err + } + if artifactID != "" { + artifact, ok := l.artifacts[artifactID] + if !ok || artifact.TenantID != actor.TenantID { + return domain.VEXImportPreview{}, ErrNotFound + } + } + if err := l.authorizeResourceLocked(actor, ScopeEvidenceRead, resourceRefs{ReleaseID: releaseID}); err != nil { + return domain.VEXImportPreview{}, err + } + created, superseded, warnings, mappingFailures := l.previewCycloneDXVEXDecisionEffectsLocked(actor.TenantID, releaseID, validStatements) + if len(invalidStatements) > 0 { + warnings = append(warnings, "One or more CycloneDX VEX vulnerabilities were skipped because required analysis fields were missing or unsupported.") + } + return domain.VEXImportPreview{ + TenantID: actor.TenantID, + ReleaseID: releaseID, + ArtifactID: artifactID, + Format: "cyclonedx", + ParserVersion: ParserVersionCycloneDXVEXJSON, + Advisory: true, + StatementCount: len(doc.Vulnerabilities), + StatusSummary: cloneIntMap(statusSummary), + DecisionsWouldCreate: created, + DecisionsWouldSupersede: superseded, + Warnings: warnings, + InvalidStatements: invalidStatements, + MappingFailures: mappingFailures, + Assumptions: vexImportPreviewAssumptions(), + Limitations: vexImportPreviewLimitations(), + SchemaVersion: domain.VEXImportPreviewSchemaVersion, + GeneratedAt: l.now(), + }, nil +} + +type cycloneDXVEXStatement struct { + index int + vulnerability cycloneDXVEXVulnerability + status string + affectedRefs map[string]struct{} +} + +func analyzeCycloneDXVEXStatements(doc cycloneDXVEXDocument) (map[string]int, []domain.VEXImportIssue, []cycloneDXVEXStatement) { + statusSummary := map[string]int{} + invalid := []domain.VEXImportIssue{} + valid := []cycloneDXVEXStatement{} + for index, vuln := range doc.Vulnerabilities { + vuln.ID = strings.TrimSpace(vuln.ID) status := cyclonedxAnalysisStatus(vuln.Analysis.State) - if status == "" || strings.TrimSpace(vuln.ID) == "" { - return domain.VEXDocument{}, ErrValidation + switch { + case vuln.ID == "": + invalid = append(invalid, vexImportIssue(index+1, "missing_vulnerability", "CycloneDX VEX vulnerability is missing an id.")) + continue + case status == "": + invalid = append(invalid, vexImportIssue(index+1, "unsupported_analysis_state", "CycloneDX VEX vulnerability has an unsupported analysis state.")) + continue } statusSummary[status]++ - for _, scan := range l.scans { - if scan.TenantID != actor.TenantID || scan.ReleaseID != releaseID { + valid = append(valid, cycloneDXVEXStatement{index: index + 1, vulnerability: vuln, status: status, affectedRefs: cycloneDXVEXAffectedRefs(vuln)}) + } + return statusSummary, invalid, valid +} + +func cycloneDXVEXAffectedRefs(vuln cycloneDXVEXVulnerability) map[string]struct{} { + out := map[string]struct{}{} + for _, affect := range vuln.Affects { + if ref := strings.TrimSpace(affect.Ref); ref != "" { + out[ref] = struct{}{} + } + } + return out +} + +func (l *Ledger) findCycloneDXVEXMatchingFindingsLocked(tenantID, releaseID string, statement cycloneDXVEXStatement) []matchedFinding { + out := []matchedFinding{} + for _, scan := range l.scans { + if scan.TenantID != tenantID || scan.ReleaseID != releaseID { + continue + } + for _, finding := range scan.Findings { + if finding.Vulnerability != statement.vulnerability.ID { continue } - for _, finding := range scan.Findings { - if finding.Vulnerability != vuln.ID { + if len(statement.affectedRefs) > 0 && finding.Component != "" { + if _, ok := statement.affectedRefs[finding.Component]; !ok { continue } - decision := domain.VulnerabilityDecision{ID: newID("vd"), TenantID: actor.TenantID, FindingID: finding.ID, ScanID: scan.ID, ReleaseID: releaseID, Vulnerability: finding.Vulnerability, Component: finding.Component, Status: status, Justification: nonEmpty(vuln.Analysis.Justification, "cyclonedx_vex"), ImpactStatement: vuln.Analysis.Detail, ActionStatement: strings.Join(vuln.Analysis.Response, ","), Source: "cyclonedx_vex", EvidenceID: item.ID, VEXDocumentID: vex.ID, SchemaVersion: domain.VulnerabilityDecisionVersion, CreatedAt: l.now()} - l.decisions[decision.ID] = decision } + out = append(out, matchedFinding{scan: scan, finding: finding}) } } - l.vexDocuments[vex.ID] = vex - _, _ = l.appendChainLocked(actor.TenantID, "vex.parsed", "vex_document", vex.ID, "api_key", actor.KeyID, payloadHash, "") - if err := l.persistLocked(ctx); err != nil { - return domain.VEXDocument{}, err + return out +} + +func (l *Ledger) previewCycloneDXVEXDecisionEffectsLocked(tenantID, releaseID string, statements []cycloneDXVEXStatement) (int, int, []string, []domain.VEXImportIssue) { + created, superseded := 0, 0 + mappingFailures := []domain.VEXImportIssue{} + warnings := []string{} + createdForFinding := map[string]struct{}{} + duplicateWarningAdded := false + for _, statement := range statements { + matches := l.findCycloneDXVEXMatchingFindingsLocked(tenantID, releaseID, statement) + if len(matches) == 0 { + mappingFailures = append(mappingFailures, vexImportIssue(statement.index, "finding_not_found", "No matching vulnerability scan finding was found for this CycloneDX VEX vulnerability.")) + } + added, replaced, duplicate := l.previewDecisionEffectsForMatchesLocked(tenantID, matches, createdForFinding) + created += added + superseded += replaced + if duplicate && !duplicateWarningAdded { + warnings = append(warnings, "Duplicate CycloneDX VEX vulnerabilities for an already mapped finding were ignored.") + duplicateWarningAdded = true + } } - return vex, nil + return created, superseded, warnings, mappingFailures } func (l *Ledger) RecordVulnerabilityWorkflow(ctx context.Context, actor domain.Actor, in RecordVulnerabilityWorkflowInput) (domain.VulnerabilityWorkflowRecord, error) { @@ -616,6 +1124,9 @@ func (l *Ledger) RecordVulnerabilityWorkflow(ctx context.Context, actor domain.A if !ok { return domain.VulnerabilityWorkflowRecord{}, ErrNotFound } + if err := l.authorizeResourceLocked(actor, ScopeSecurityWrite, resourceRefs{ReleaseID: scan.ReleaseID}); err != nil { + return domain.VulnerabilityWorkflowRecord{}, err + } record := domain.VulnerabilityWorkflowRecord{ID: newID("vw"), TenantID: actor.TenantID, FindingID: in.FindingID, ReleaseID: scan.ReleaseID, Action: in.Action, Reason: in.Reason, ActorID: actorID(actor), SchemaVersion: "vulnerability-workflow.v1.0.0", CreatedAt: l.now()} l.vulnWorkflow[record.ID] = record _, _ = l.appendChainLocked(actor.TenantID, "vulnerability_workflow."+record.Action, "vulnerability_finding", in.FindingID, actorType(actor), actorID(actor), "", "") @@ -625,7 +1136,8 @@ func (l *Ledger) RecordVulnerabilityWorkflow(ctx context.Context, actor domain.A return record, nil } -func (l *Ledger) VulnerabilityPostureReport(ctx context.Context, actor domain.Actor, releaseID string) (domain.VulnerabilityPostureReport, error) { +func (s packageReportService) VulnerabilityPostureReport(ctx context.Context, actor domain.Actor, releaseID string) (domain.VulnerabilityPostureReport, error) { + l := s.ledger if err := ctx.Err(); err != nil { return domain.VulnerabilityPostureReport{}, err } @@ -634,6 +1146,15 @@ func (l *Ledger) VulnerabilityPostureReport(ctx context.Context, actor domain.Ac } l.mu.Lock() defer l.mu.Unlock() + if strings.TrimSpace(releaseID) != "" { + release, ok := l.releases[strings.TrimSpace(releaseID)] + if !ok || release.TenantID != actor.TenantID { + return domain.VulnerabilityPostureReport{}, ErrNotFound + } + if err := l.authorizeResourceLocked(actor, ScopeSecurityRead, resourceRefs{ProductID: release.ProductID, ReleaseID: release.ID}); err != nil { + return domain.VulnerabilityPostureReport{}, err + } + } summary := map[string]int{} openCritical := 0 for _, scan := range l.scans { @@ -664,16 +1185,28 @@ func (l *Ledger) CreateContractDiff(ctx context.Context, actor domain.Actor, in if !bok || !tok || base.TenantID != actor.TenantID || target.TenantID != actor.TenantID || base.ProductID != target.ProductID { return domain.ContractDiff{}, ErrNotFound } + if err := l.authorizeResourceLocked(actor, ScopeEvidenceRead, resourceRefs{ProductID: base.ProductID, ReleaseID: base.ReleaseID}); err != nil { + return domain.ContractDiff{}, err + } + if err := l.authorizeResourceLocked(actor, ScopeEvidenceRead, resourceRefs{ProductID: target.ProductID, ReleaseID: target.ReleaseID}); err != nil { + return domain.ContractDiff{}, err + } result := "unchanged" breaking, nonBreaking := []string{}, []string{} if base.Hash != target.Hash { result = "changed" - if target.PathCount < base.PathCount { - result = "breaking" - breaking = append(breaking, "target contract has fewer paths than base contract") + breaking, nonBreaking = diffOpenAPIOperations(base, target) + if len(base.Operations) == 0 || len(target.Operations) == 0 { + breaking, nonBreaking = []string{}, []string{} + if target.PathCount < base.PathCount { + breaking = append(breaking, "target contract has fewer paths than base contract") + } + if target.PathCount > base.PathCount { + nonBreaking = append(nonBreaking, "target contract has additional paths") + } } - if target.PathCount > base.PathCount { - nonBreaking = append(nonBreaking, "target contract has additional paths") + if len(breaking) > 0 { + result = "breaking" } } diff := domain.ContractDiff{ID: newID("cdiff"), TenantID: actor.TenantID, BaseContractID: base.ID, TargetContractID: target.ID, ProductID: base.ProductID, ReleaseID: strings.TrimSpace(in.ReleaseID), Result: result, BreakingChanges: breaking, NonBreakingChanges: nonBreaking, SchemaVersion: domain.ContractDiffSchemaVersion, CreatedAt: l.now()} @@ -685,6 +1218,85 @@ func (l *Ledger) CreateContractDiff(ctx context.Context, actor domain.Actor, in return diff, nil } +func diffOpenAPIOperations(base, target domain.OpenAPIContract) ([]string, []string) { + baseOps := indexOpenAPIOperations(base.Operations) + targetOps := indexOpenAPIOperations(target.Operations) + breaking, nonBreaking := []string{}, []string{} + for key, baseOp := range baseOps { + targetOp, ok := targetOps[key] + label := openAPIOperationLabel(baseOp) + if !ok { + breaking = append(breaking, "operation removed: "+label) + continue + } + if !baseOp.RequestBodyRequired && targetOp.RequestBodyRequired { + breaking = append(breaking, "request body became required: "+label) + } + addedRequired := missingStrings(baseOp.RequiredRequestFields, targetOp.RequiredRequestFields) + if len(addedRequired) > 0 { + breaking = append(breaking, "required request fields added for "+label+": "+strings.Join(addedRequired, ",")) + } + removedStatuses := missingStrings(targetOp.ResponseStatuses, baseOp.ResponseStatuses) + if len(removedStatuses) > 0 { + breaking = append(breaking, "response statuses removed for "+label+": "+strings.Join(removedStatuses, ",")) + } + addedStatuses := missingStrings(baseOp.ResponseStatuses, targetOp.ResponseStatuses) + if len(addedStatuses) > 0 { + nonBreaking = append(nonBreaking, "response statuses added for "+label+": "+strings.Join(addedStatuses, ",")) + } + if !baseOp.Deprecated && targetOp.Deprecated { + nonBreaking = append(nonBreaking, "operation deprecated: "+label) + } + } + for key, targetOp := range targetOps { + if _, ok := baseOps[key]; !ok { + nonBreaking = append(nonBreaking, "operation added: "+openAPIOperationLabel(targetOp)) + } + } + sort.Strings(breaking) + sort.Strings(nonBreaking) + return breaking, nonBreaking +} + +func indexOpenAPIOperations(ops []domain.OpenAPIOperation) map[string]domain.OpenAPIOperation { + out := make(map[string]domain.OpenAPIOperation, len(ops)) + for _, op := range ops { + if strings.TrimSpace(op.Path) == "" || strings.TrimSpace(op.Method) == "" { + continue + } + op.Method = strings.ToUpper(strings.TrimSpace(op.Method)) + op.Path = strings.TrimSpace(op.Path) + out[op.Method+" "+op.Path] = op + } + return out +} + +func openAPIOperationLabel(op domain.OpenAPIOperation) string { + return strings.ToUpper(strings.TrimSpace(op.Method)) + " " + strings.TrimSpace(op.Path) +} + +func missingStrings(have, want []string) []string { + present := map[string]struct{}{} + for _, value := range have { + value = strings.TrimSpace(value) + if value != "" { + present[value] = struct{}{} + } + } + missing := []string{} + for _, value := range want { + value = strings.TrimSpace(value) + if value == "" { + continue + } + if _, ok := present[value]; !ok { + missing = append(missing, value) + } + } + sort.Strings(missing) + return missing +} + func (l *Ledger) CreateCustomPolicy(ctx context.Context, actor domain.Actor, in CreateCustomPolicyInput) (domain.CustomPolicy, error) { if err := ctx.Err(); err != nil { return domain.CustomPolicy{}, err @@ -734,6 +1346,9 @@ func (l *Ledger) EvaluateCustomPolicy(ctx context.Context, actor domain.Actor, p if !ok || !rok || policy.TenantID != actor.TenantID || release.TenantID != actor.TenantID { return domain.CustomPolicyEvaluation{}, ErrNotFound } + if err := l.authorizeResourceLocked(actor, ScopePolicyRead, resourceRefs{ProductID: release.ProductID, ReleaseID: release.ID}); err != nil { + return domain.CustomPolicyEvaluation{}, err + } checks := []domain.PolicyCheck{} result := "passed" for _, rule := range policy.Rules { diff --git a/internal/app/risk_workflows_more_test.go b/internal/app/risk_workflows_more_test.go new file mode 100644 index 0000000..0dfe3d6 --- /dev/null +++ b/internal/app/risk_workflows_more_test.go @@ -0,0 +1,275 @@ +package app + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "encoding/base64" + "errors" + "strings" + "testing" + "time" + + "github.com/aatuh/evydence/internal/domain" +) + +func TestRiskWorkflowEvidenceFormatsAndReports(t *testing.T) { + ledger := NewLedger(Config{APIKeyPepper: "test-pepper", Now: fixedNow}) + ctx := context.Background() + actor, release, artifact := setupReleaseRiskFixture(t, ledger) + evidence, err := ledger.CreateEvidence(ctx, actor, CreateEvidenceInput{ProductID: release.ProductID, ReleaseID: release.ID, Type: "security_review", Title: "Review", PayloadHash: sampleDigest("review")}) + if err != nil { + t.Fatalf("evidence: %v", err) + } + incident, err := ledger.CreateIncident(ctx, actor, CreateIncidentInput{ProductID: release.ProductID, ReleaseID: release.ID, Title: "Critical vuln", Severity: "critical"}) + if err != nil { + t.Fatalf("incident: %v", err) + } + if _, err := ledger.RecordIncidentTimelineEvent(ctx, actor, incident.ID, RecordIncidentTimelineInput{EventType: "detected", Summary: "scan detected issue", EvidenceID: evidence.ID}); err != nil { + t.Fatalf("timeline: %v", err) + } + due := fixedNow().Add(24 * time.Hour) + if _, err := ledger.CreateRemediationTask(ctx, actor, CreateRemediationTaskInput{IncidentID: incident.ID, ReleaseID: release.ID, Title: "Patch", Owner: "security", DueAt: &due, EvidenceID: evidence.ID}); err != nil { + t.Fatalf("task: %v", err) + } + if report, err := ledger.IncidentReport(ctx, actor, incident.ID); err != nil || len(report.Timeline) != 1 || len(report.Tasks) != 1 || len(report.LinkedEvidence) == 0 { + t.Fatalf("incident report=%#v err=%v", report, err) + } + + sarif, err := ledger.UploadSecurityScan(ctx, actor, UploadSecurityScanInput{ + ProductID: release.ProductID, ReleaseID: release.ID, ArtifactID: artifact.ID, + Category: "sast", Format: "sarif", Scanner: "codeql", TargetRef: "git:main", + Raw: []byte(`{"version":"2.1.0","runs":[{"results":[{"level":"error"},{"level":""}]}]}`), + }) + if err != nil { + t.Fatalf("sarif scan: %v", err) + } + if sarif.FindingCount != 2 || sarif.Summary["warning"] != 1 { + t.Fatalf("sarif summary = %#v", sarif) + } + secretScan, err := ledger.UploadSecurityScan(ctx, actor, UploadSecurityScanInput{ + ProductID: release.ProductID, ReleaseID: release.ID, + Category: "secret_scan", Scanner: "gitleaks", TargetRef: "git:main", + Raw: []byte(`{"findings":[{"severity":"high"}]}`), + }) + if err != nil { + t.Fatalf("secret scan: %v", err) + } + if !secretScan.Redacted || !secretScan.Quarantined { + t.Fatalf("secret scan should be redacted/quarantined: %#v", secretScan) + } + apiScan, err := ledger.UploadAPISecurityScan(ctx, actor, UploadSecurityScanInput{ + ProductID: release.ProductID, ReleaseID: release.ID, Scanner: "zap", TargetRef: "openapi", + Raw: []byte(`{"findings":[{"severity":"medium"},{"severity":""}]}`), + }) + if err != nil { + t.Fatalf("api security scan: %v", err) + } + if apiScan.Category != "api_security" || apiScan.Summary["unknown"] != 1 { + t.Fatalf("api scan = %#v", apiScan) + } + _, webhookPrivate, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("webhook key: %v", err) + } + incidentReceiver, err := ledger.CreateIncidentWebhookReceiver(ctx, actor, CreateIncidentWebhookReceiverInput{ + IncidentID: incident.ID, + Name: "pager", + Provider: "incident_tool", + PublicKey: base64.RawStdEncoding.EncodeToString(webhookPrivate.Public().(ed25519.PublicKey)), + }) + if err != nil { + t.Fatalf("incident webhook receiver: %v", err) + } + webhookBody := []byte(`{"event_type":"mitigation_applied","summary":"patched service"}`) + webhookSignature := ed25519.Sign(webhookPrivate, incidentWebhookSignedPayload(fixedNow(), "evt-1", webhookBody)) + webhookRecord, webhookTimeline, err := ledger.HandleIncidentWebhook(ctx, HandleIncidentWebhookInput{ + ReceiverID: incidentReceiver.ID, + EventID: "evt-1", + Timestamp: fixedNow(), + Signature: "ed25519=" + base64.RawStdEncoding.EncodeToString(webhookSignature), + Body: webhookBody, + }) + if err != nil { + t.Fatalf("incident webhook: %v", err) + } + if webhookRecord.Result != "accepted" || webhookRecord.TimelineEventID != webhookTimeline.ID || webhookTimeline.EventType != "mitigation_applied" { + t.Fatalf("webhook record=%#v timeline=%#v", webhookRecord, webhookTimeline) + } + duplicateRecord, duplicateTimeline, err := ledger.HandleIncidentWebhook(ctx, HandleIncidentWebhookInput{ + ReceiverID: incidentReceiver.ID, + EventID: "evt-1", + Timestamp: fixedNow(), + Signature: "ed25519=" + base64.RawStdEncoding.EncodeToString(webhookSignature), + Body: webhookBody, + }) + if err != nil { + t.Fatalf("duplicate incident webhook: %v", err) + } + if duplicateRecord.ID != webhookRecord.ID || duplicateTimeline.ID != webhookTimeline.ID { + t.Fatalf("duplicate webhook not idempotent: %#v %#v", duplicateRecord, duplicateTimeline) + } + if _, _, err := ledger.HandleIncidentWebhook(ctx, HandleIncidentWebhookInput{ReceiverID: incidentReceiver.ID, EventID: "evt-2", Timestamp: fixedNow(), Signature: "ed25519=bad", Body: webhookBody}); !errors.Is(err, ErrUnauthorized) { + t.Fatalf("bad webhook signature err=%v, want unauthorized", err) + } + if _, _, err := ledger.HandleIncidentWebhook(ctx, HandleIncidentWebhookInput{ReceiverID: incidentReceiver.ID, EventID: "evt-3", Timestamp: fixedNow().Add(-10 * time.Minute), Signature: "ed25519=" + base64.RawStdEncoding.EncodeToString(webhookSignature), Body: webhookBody}); !errors.Is(err, ErrUnauthorized) { + t.Fatalf("stale webhook err=%v, want unauthorized", err) + } + if _, err := ledger.UploadManualSecurityDocument(ctx, actor, UploadManualSecurityDocumentInput{ProductID: release.ProductID, ReleaseID: release.ID, DocumentType: "threat_model", Title: "Threat Model", Sensitivity: "restricted", Raw: []byte("model"), MediaType: "text/plain"}); err != nil { + t.Fatalf("manual doc: %v", err) + } + + base, err := ledger.UploadSPDXSBOM(ctx, actor, release.ID, artifact.ID, []byte(`{"spdxVersion":"SPDX-2.3","packages":[{"name":"api","versionInfo":"1.0.0","externalRefs":[{"referenceType":"purl","referenceLocator":"pkg:oci/api@1.0.0"}]},{"name":"old","versionInfo":"1.0.0"}]}`)) + if err != nil { + t.Fatalf("base spdx: %v", err) + } + target, err := ledger.UploadSPDXSBOM(ctx, actor, release.ID, artifact.ID, []byte(`{"spdxVersion":"SPDX-2.3","packages":[{"name":"api","versionInfo":"1.0.0","externalRefs":[{"referenceType":"purl","referenceLocator":"pkg:oci/api@1.0.0"}]},{"name":"new","versionInfo":"1.0.0"}]}`)) + if err != nil { + t.Fatalf("target spdx: %v", err) + } + diff, err := ledger.CreateSBOMDiff(ctx, actor, CreateSBOMDiffInput{BaseSBOMID: base.ID, TargetSBOMID: target.ID, ReleaseID: release.ID}) + if err != nil { + t.Fatalf("sbom diff: %v", err) + } + if diff.UnchangedCount != 1 || len(diff.AddedComponents) != 1 || len(diff.RemovedComponents) != 1 || len(diff.DependencyChanges) != 2 { + t.Fatalf("diff = %#v", diff) + } + components, err := ledger.ListSBOMComponents(ctx, actor, ListSBOMComponentsInput{ReleaseID: release.ID, Query: "new"}) + if err != nil { + t.Fatalf("list sbom components: %v", err) + } + if len(components) != 1 || components[0].SBOMID != target.ID || components[0].Component.Name != "new" { + t.Fatalf("component query = %#v", components) + } + purlComponents, err := ledger.ListSBOMComponents(ctx, actor, ListSBOMComponentsInput{SBOMID: base.ID, PURL: "pkg:oci/api@1.0.0", Limit: 1}) + if err != nil { + t.Fatalf("list sbom components by purl: %v", err) + } + if len(purlComponents) != 1 || purlComponents[0].Component.PURL != "pkg:oci/api@1.0.0" { + t.Fatalf("purl component query = %#v", purlComponents) + } + if _, err := ledger.ListSBOMComponents(ctx, actor, ListSBOMComponentsInput{Limit: 501}); !errors.Is(err, ErrValidation) { + t.Fatalf("large component limit err=%v, want validation", err) + } + + scan, err := ledger.UploadVulnerabilityScan(ctx, actor, []byte(`{"scanner":"grype","target_ref":"pkg:oci/api","release_id":"`+release.ID+`","findings":[{"vulnerability":"CVE-2026-0002","component":"api","severity":"critical","state":"open"}]}`)) + if err != nil { + t.Fatalf("vuln scan: %v", err) + } + cdxVEX, err := ledger.UploadCycloneDXVEX(ctx, actor, release.ID, artifact.ID, []byte(`{"bomFormat":"CycloneDX","specVersion":"1.6","vulnerabilities":[{"id":"CVE-2026-0002","analysis":{"state":"resolved","justification":"code_not_present","detail":"fixed","response":["update"]}}]}`)) + if err != nil { + t.Fatalf("cyclonedx vex: %v", err) + } + if cdxVEX.StatusSummary["fixed"] != 1 { + t.Fatalf("cdx vex = %#v", cdxVEX) + } + if _, err := ledger.RecordVulnerabilityWorkflow(ctx, actor, RecordVulnerabilityWorkflowInput{FindingID: scan.Findings[0].ID, Action: "scanner_metadata", Reason: "database version captured"}); err != nil { + t.Fatalf("workflow: %v", err) + } + if posture, err := ledger.VulnerabilityPostureReport(ctx, actor, release.ID); err != nil || posture.OpenCritical != 1 { + t.Fatalf("posture=%#v err=%v", posture, err) + } + + baseContract, err := ledger.UploadOpenAPIContract(ctx, actor, release.ProductID, release.ID, "base", []byte(`{"openapi":"3.1.0","info":{"title":"API","version":"1"},"paths":{"/v1/a":{"get":{"responses":{"200":{"description":"ok"}}}},"/v1/b":{"get":{"responses":{"200":{"description":"ok"}}}}}}`)) + if err != nil { + t.Fatalf("base contract: %v", err) + } + targetContract, err := ledger.UploadOpenAPIContract(ctx, actor, release.ProductID, release.ID, "target", []byte(`{"openapi":"3.1.0","info":{"title":"API","version":"2"},"paths":{"/v1/a":{"get":{"responses":{"200":{"description":"ok"}}}}}}`)) + if err != nil { + t.Fatalf("target contract: %v", err) + } + contractDiff, err := ledger.CreateContractDiff(ctx, actor, CreateContractDiffInput{BaseContractID: baseContract.ID, TargetContractID: targetContract.ID, ReleaseID: release.ID}) + if err != nil { + t.Fatalf("contract diff: %v", err) + } + if contractDiff.Result != "breaking" || len(contractDiff.BreakingChanges) != 1 { + t.Fatalf("contract diff = %#v", contractDiff) + } + baseRichContract, err := ledger.UploadOpenAPIContract(ctx, actor, release.ProductID, release.ID, "rich-base", []byte(`{"openapi":"3.1.0","info":{"title":"API","version":"1"},"paths":{"/v1/items":{"post":{"requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","required":["id"],"properties":{"id":{"type":"string"}}}}}},"responses":{"200":{"description":"ok"},"201":{"description":"created"}}}}}}`)) + if err != nil { + t.Fatalf("rich base contract: %v", err) + } + targetRichContract, err := ledger.UploadOpenAPIContract(ctx, actor, release.ProductID, release.ID, "rich-target", []byte(`{"openapi":"3.1.0","info":{"title":"API","version":"2"},"paths":{"/v1/items":{"post":{"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["id","name"],"properties":{"id":{"type":"string"},"name":{"type":"string"}}}}}},"responses":{"200":{"description":"ok"}}}},"/v1/items/{id}":{"get":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"ok"}}}}}}`)) + if err != nil { + t.Fatalf("rich target contract: %v", err) + } + if len(baseRichContract.Operations) != 1 || len(targetRichContract.Operations) != 2 { + t.Fatalf("operation summaries not captured: base=%#v target=%#v", baseRichContract.Operations, targetRichContract.Operations) + } + richDiff, err := ledger.CreateContractDiff(ctx, actor, CreateContractDiffInput{BaseContractID: baseRichContract.ID, TargetContractID: targetRichContract.ID, ReleaseID: release.ID}) + if err != nil { + t.Fatalf("rich contract diff: %v", err) + } + breakingText := strings.Join(richDiff.BreakingChanges, "\n") + nonBreakingText := strings.Join(richDiff.NonBreakingChanges, "\n") + if richDiff.Result != "breaking" || !strings.Contains(breakingText, "request body became required: POST /v1/items") || !strings.Contains(breakingText, "required request fields added for POST /v1/items: name") || !strings.Contains(breakingText, "response statuses removed for POST /v1/items: 201") { + t.Fatalf("rich breaking diff = %#v", richDiff) + } + if !strings.Contains(nonBreakingText, "operation added: GET /v1/items/{id}") { + t.Fatalf("rich non-breaking diff = %#v", richDiff) + } + + policy, err := ledger.CreateCustomPolicy(ctx, actor, CreateCustomPolicyInput{Name: "release gates", Version: "1", Rules: []domain.PolicyRule{ + {Name: "metadata", Severity: "low"}, + {Name: "sbom exists", EvidenceType: "sbom", Severity: "high", Required: true}, + {Name: "optional pen test", EvidenceType: "pen_test_report", Severity: "medium", Required: false}, + }}) + if err != nil { + t.Fatalf("policy: %v", err) + } + eval, err := ledger.EvaluateCustomPolicy(ctx, actor, policy.ID, release.ID) + if err != nil { + t.Fatalf("policy eval: %v", err) + } + if eval.Result != "passed" || len(eval.Checks) != 3 { + t.Fatalf("eval = %#v", eval) + } +} + +func TestRiskWorkflowValidationHelpers(t *testing.T) { + for _, severity := range []string{"low", "medium", "high", "critical"} { + if !validSeverity(severity) { + t.Fatalf("valid severity rejected: %s", severity) + } + } + for _, category := range []string{"sast", "dast", "secret_scan", "license_scan", "api_security"} { + if !validSecurityScanCategory(category) { + t.Fatalf("valid scan category rejected: %s", category) + } + } + for _, typ := range []string{"threat_model", "security_review", "pen_test_report"} { + if !validManualDocType(typ) { + t.Fatalf("valid doc type rejected: %s", typ) + } + } + for _, sensitivity := range []string{"internal", "confidential", "restricted"} { + if !validSensitivity(sensitivity) { + t.Fatalf("valid sensitivity rejected: %s", sensitivity) + } + } + for _, action := range []string{"scanner_metadata", "sla_set", "scanner_disagreement", "superseded", "reopened"} { + if !validVulnWorkflowAction(action) { + t.Fatalf("valid workflow action rejected: %s", action) + } + } + for _, typ := range []string{"sbom", "vulnerability_scan", "vex", "vulnerability_decision", "artifact", "build", "build_attestation", "openapi_contract", "release_bundle", "exception", "sast", "dast", "secret_scan", "license_scan", "api_security", "deployment", "threat_model", "security_review", "pen_test_report"} { + if !validPolicyEvidenceType(typ) { + t.Fatalf("valid policy evidence type rejected: %s", typ) + } + } + if validSeverity("info") || validSecurityScanCategory("container") || validManualDocType("notes") || validSensitivity("public") || validVulnWorkflowAction("ignore") || validPolicyEvidenceType("unknown") { + t.Fatal("invalid risk workflow helper value accepted") + } + if got := cyclonedxAnalysisStatus("resolved"); got != "fixed" { + t.Fatalf("resolved = %s", got) + } + if got := cyclonedxAnalysisStatus("not_affected"); got != "not_affected" { + t.Fatalf("not_affected = %s", got) + } + if got := cyclonedxAnalysisStatus("exploitable"); got != "affected" { + t.Fatalf("exploitable = %s", got) + } + if got := cyclonedxAnalysisStatus("unknown"); got != "" { + t.Fatalf("unknown = %s", got) + } +} diff --git a/internal/app/risk_workflows_test.go b/internal/app/risk_workflows_test.go index 9ea244d..56eb164 100644 --- a/internal/app/risk_workflows_test.go +++ b/internal/app/risk_workflows_test.go @@ -151,3 +151,144 @@ func TestCycloneDXVEXVulnerabilityWorkflowContractDiffAndPolicyV2(t *testing.T) t.Fatalf("eval = %#v, want missing sbom failure", eval) } } + +func TestCycloneDXVEXImportReportTracksIssuesDuplicatesAndOutbox(t *testing.T) { + outbox := &recordingOutbox{} + ledger := NewLedger(Config{APIKeyPepper: "test-pepper", Now: fixedNow, Outbox: outbox}) + ctx := context.Background() + actor, release, artifact := setupReleaseRiskFixture(t, ledger) + scan, err := ledger.UploadVulnerabilityScan(ctx, actor, []byte(`{ + "scanner":"grype", + "target_ref":"pkg:oci/payments-api", + "release_id":"`+release.ID+`", + "findings":[ + {"vulnerability":"CVE-2026-2001","component":"pkg:apk/openssl@3.1.0","severity":"critical","state":"open"}, + {"vulnerability":"CVE-2026-2002","component":"pkg:apk/curl@8.0.0","severity":"high","state":"open"} + ] + }`)) + if err != nil { + t.Fatalf("scan: %v", err) + } + if _, err := ledger.CreateVulnerabilityDecision(ctx, actor, scan.Findings[0].ID, CreateVulnerabilityDecisionInput{Status: decisionStatusAffected, Justification: "initial triage"}); err != nil { + t.Fatalf("initial decision: %v", err) + } + outbox.jobs = nil + + vex, err := ledger.UploadCycloneDXVEX(ctx, actor, release.ID, artifact.ID, []byte(`{ + "bomFormat":"CycloneDX", + "specVersion":"1.6", + "vulnerabilities":[ + {"id":"CVE-2026-2001","affects":[{"ref":"pkg:apk/openssl@3.1.0"}],"analysis":{"state":"resolved","justification":"fixed_in_release","detail":"fixed in this release","response":["update"]}}, + {"id":"CVE-2026-2001","affects":[{"ref":"pkg:apk/openssl@3.1.0"}],"analysis":{"state":"resolved","justification":"fixed_in_release","detail":"duplicate statement","response":["update"]}}, + {"id":"CVE-2026-2999","affects":[{"ref":"pkg:apk/missing@1.0.0"}],"analysis":{"state":"resolved","justification":"fixed_elsewhere","detail":"not in scan","response":["none"]}}, + {"id":"CVE-2026-2002","analysis":{"state":"unknown","justification":"unknown_state","detail":"bad state"}} + ] + }`)) + if err != nil { + t.Fatalf("cyclonedx vex: %v", err) + } + report, err := ledger.GetVEXImportReport(ctx, actor, vex.ID) + if err != nil { + t.Fatalf("import report: %v", err) + } + if report.ParserVersion != ParserVersionCycloneDXVEXJSON || report.StatementCount != 4 || report.DecisionsCreated != 1 || report.DecisionsSuperseded != 1 { + t.Fatalf("import report counts = %#v", report) + } + if len(report.MappingFailures) != 1 || report.MappingFailures[0].StatementIndex != 3 || report.MappingFailures[0].Code != "finding_not_found" { + t.Fatalf("mapping failures = %#v", report.MappingFailures) + } + if len(report.InvalidStatements) != 1 || report.InvalidStatements[0].StatementIndex != 4 || report.InvalidStatements[0].Code != "unsupported_analysis_state" { + t.Fatalf("invalid statements = %#v", report.InvalidStatements) + } + if !strings.Contains(strings.Join(report.Warnings, "\n"), "Duplicate CycloneDX VEX vulnerabilities") || !strings.Contains(strings.Join(report.Warnings, "\n"), "skipped") { + t.Fatalf("warnings = %#v", report.Warnings) + } + active := true + decisions, err := ledger.ListVulnerabilityDecisions(ctx, actor, ListVulnerabilityDecisionsInput{ReleaseID: release.ID, Vulnerability: "CVE-2026-2001", Active: &active}) + if err != nil { + t.Fatalf("list decisions: %v", err) + } + if len(decisions) != 1 || decisions[0].Status != decisionStatusFixed || decisions[0].Source != "cyclonedx_vex" { + t.Fatalf("active decisions = %#v", decisions) + } + if len(outbox.jobs) != 1 || outbox.jobs[0].Kind != "parse_vex" || outbox.jobs[0].Payload["parser_version"] != ParserVersionCycloneDXVEXJSON || outbox.jobs[0].Payload["import_report_id"] != report.ID { + t.Fatalf("outbox jobs = %#v", outbox.jobs) + } + + if _, err := ledger.UploadCycloneDXVEX(ctx, actor, release.ID, artifact.ID, []byte(`{"bomFormat":"CycloneDX","specVersion":"1.6","unexpected":true,"vulnerabilities":[{"id":"CVE-2026-2001","analysis":{"state":"resolved"}}]}`)); !errors.Is(err, ErrValidation) { + t.Fatalf("unsupported field err=%v, want validation", err) + } + if _, err := ledger.UploadCycloneDXVEX(ctx, actor, release.ID, artifact.ID, []byte(`{"bomFormat":"CycloneDX","vulnerabilities":[`)); !errors.Is(err, ErrValidation) { + t.Fatalf("malformed err=%v, want validation", err) + } +} + +func TestVEXImportPreviewIsAdvisoryAndDoesNotMutateLedger(t *testing.T) { + outbox := &recordingOutbox{} + ledger := NewLedger(Config{APIKeyPepper: "test-pepper", Now: fixedNow, Outbox: outbox}) + ctx := context.Background() + actor, release, artifact := setupReleaseRiskFixture(t, ledger) + scan, err := ledger.UploadVulnerabilityScan(ctx, actor, []byte(`{ + "scanner":"grype", + "target_ref":"pkg:oci/payments-api", + "release_id":"`+release.ID+`", + "findings":[{"vulnerability":"CVE-2026-4001","component":"pkg:apk/openssl@3.1.0","severity":"critical","state":"open"}] + }`)) + if err != nil { + t.Fatalf("scan: %v", err) + } + if _, err := ledger.CreateVulnerabilityDecision(ctx, actor, scan.Findings[0].ID, CreateVulnerabilityDecisionInput{Status: decisionStatusAffected, Justification: "initial triage"}); err != nil { + t.Fatalf("initial decision: %v", err) + } + outbox.jobs = nil + evidenceCount, vexCount, decisionCount := len(ledger.evidence), len(ledger.vexDocuments), len(ledger.decisions) + + preview, err := ledger.PreviewVEXImport(ctx, actor, release.ID, artifact.ID, []byte(`{ + "@context":"https://openvex.dev/ns/v0.2.0", + "@id":"https://example.test/vex/preview", + "author":"security@example.test", + "timestamp":"2026-05-27T12:00:00Z", + "version":1, + "statements":[ + {"vulnerability":{"name":"CVE-2026-4001"},"products":[{"@id":"pkg:apk/openssl@3.1.0"}],"status":"fixed","justification":"fixed","impact_statement":"patched","action_statement":"ship"}, + {"vulnerability":{"name":"CVE-2026-4999"},"products":[{"@id":"pkg:apk/missing@1.0.0"}],"status":"fixed","justification":"fixed","impact_statement":"missing","action_statement":"none"} + ] + }`)) + if err != nil { + t.Fatalf("openvex preview: %v", err) + } + if preview.Format != "openvex" || !preview.Advisory || preview.ParserVersion != ParserVersionOpenVEXJSON || preview.StatementCount != 2 || preview.DecisionsWouldCreate != 1 || preview.DecisionsWouldSupersede != 1 { + t.Fatalf("openvex preview = %#v", preview) + } + if len(preview.MappingFailures) != 1 || preview.MappingFailures[0].StatementIndex != 2 || preview.MappingFailures[0].Code != "finding_not_found" { + t.Fatalf("openvex mapping failures = %#v", preview.MappingFailures) + } + if len(ledger.evidence) != evidenceCount || len(ledger.vexDocuments) != vexCount || len(ledger.decisions) != decisionCount || len(outbox.jobs) != 0 { + t.Fatalf("preview mutated state evidence=%d/%d vex=%d/%d decisions=%d/%d jobs=%d", len(ledger.evidence), evidenceCount, len(ledger.vexDocuments), vexCount, len(ledger.decisions), decisionCount, len(outbox.jobs)) + } + + cyclonePreview, err := ledger.PreviewCycloneDXVEXImport(ctx, actor, release.ID, artifact.ID, []byte(`{ + "bomFormat":"CycloneDX", + "specVersion":"1.6", + "vulnerabilities":[ + {"id":"CVE-2026-4001","affects":[{"ref":"pkg:apk/openssl@3.1.0"}],"analysis":{"state":"resolved","justification":"fixed","detail":"patched","response":["update"]}}, + {"id":"CVE-2026-4999","analysis":{"state":"resolved","justification":"fixed","detail":"not in scan"}}, + {"id":"CVE-2026-4002","analysis":{"state":"unknown","detail":"bad state"}} + ] + }`)) + if err != nil { + t.Fatalf("cyclonedx preview: %v", err) + } + if cyclonePreview.Format != "cyclonedx" || cyclonePreview.ParserVersion != ParserVersionCycloneDXVEXJSON || cyclonePreview.StatementCount != 3 || cyclonePreview.DecisionsWouldCreate != 1 || cyclonePreview.DecisionsWouldSupersede != 1 { + t.Fatalf("cyclonedx preview = %#v", cyclonePreview) + } + if len(cyclonePreview.InvalidStatements) != 1 || cyclonePreview.InvalidStatements[0].StatementIndex != 3 { + t.Fatalf("cyclonedx invalid statements = %#v", cyclonePreview.InvalidStatements) + } + if len(cyclonePreview.MappingFailures) != 1 || cyclonePreview.MappingFailures[0].StatementIndex != 2 { + t.Fatalf("cyclonedx mapping failures = %#v", cyclonePreview.MappingFailures) + } + if _, err := ledger.PreviewCycloneDXVEXImport(ctx, actor, release.ID, artifact.ID, []byte(`{"bomFormat":"CycloneDX","specVersion":"1.6","unexpected":true,"vulnerabilities":[{"id":"CVE-2026-4001","analysis":{"state":"resolved"}}]}`)); !errors.Is(err, ErrValidation) { + t.Fatalf("strict preview err=%v, want validation", err) + } +} diff --git a/internal/app/state.go b/internal/app/state.go index a207ac8..8ace9bc 100644 --- a/internal/app/state.go +++ b/internal/app/state.go @@ -26,86 +26,101 @@ func (l *Ledger) snapshotLocked() PersistedState { signingKeys[id] = key } state := PersistedState{ - Tenants: l.tenants, - Organizations: l.organizations, - Users: l.users, - RoleBindings: l.roleBindings, - SSOProviders: l.ssoProviders, - IdentityLinks: l.identityLinks, - SSOSessions: ssoSessions, - SSOSessionHashes: map[string]string{}, - APIKeys: apiKeys, - APIKeyHashes: map[string]string{}, - Collectors: l.collectors, - CollectorReleases: l.collectorReleases, - Products: l.products, - Projects: l.projects, - Releases: l.releases, - Artifacts: l.artifacts, - BuildRuns: l.buildRuns, - BuildAttestations: l.attestations, - Evidence: l.evidence, - EvidenceLifecycle: l.lifecycle, - ReleaseCandidates: l.candidates, - ContainerImages: l.images, - ArtifactSignatures: l.artifactSigs, - Repositories: l.repositories, - Commits: l.commits, - Branches: l.branches, - PullRequests: l.pullRequests, - Environments: l.environments, - Deployments: l.deployments, - Incidents: l.incidents, - TimelineEvents: l.timeline, - RemediationTasks: l.tasks, - SecurityScans: l.securityScans, - ManualSecurityDocs: l.manualDocs, - SBOMDiffs: l.sbomDiffs, - DependencyChanges: l.depChanges, - VulnerabilityWorkflow: l.vulnWorkflow, - ContractDiffs: l.contractDiffs, - CustomPolicies: l.customPolicies, - CustomPolicyEvaluations: l.customPolicyEvals, - Waivers: l.waivers, - Approvals: l.approvals, - RedactionProfiles: l.redactions, - CustomerPackages: l.customerPackages, - HTMLReports: l.htmlReports, - ReportTemplates: l.reportTemplates, - RenderedReports: l.renderedReports, - EvidenceBundles: l.evidenceBundles, - BundleImports: l.bundleImports, - DSSETrustRoots: l.dsseTrustRoots, - CosignVerifications: l.cosignVerifs, - SigningProviders: l.signingProviders, - MerkleBatches: l.merkleBatches, - TransparencyCheckpoints: l.transparency, - ObjectRetentionPolicies: l.retentionPolicies, - BackupManifests: l.backupManifests, - LegalHolds: l.legalHolds, - RetentionOverrides: l.retentionOverrides, - CustomerPortalAccess: portalAccess, - CustomerPortalHashes: map[string]string{}, - QuestionnaireTemplates: l.questionTemplates, - QuestionnairePackages: l.questionPackages, - CommercialCollectors: l.commercialCollectors, - ControlFrameworks: l.frameworks, - SecurityControls: l.controls, - ControlEvidence: l.controlLinks, - SBOMs: l.sboms, - Scans: l.scans, - VEXDocuments: l.vexDocuments, - Decisions: l.decisions, - Contracts: l.contracts, - Policies: l.policies, - Exceptions: l.exceptions, - Bundles: l.bundles, - SigningKeys: signingKeys, - SigningKeyPrivate: map[string][]byte{}, - Signatures: l.signatures, - Verifications: l.verifications, - Chain: l.chain, - Idempotency: l.idempotency, + Tenants: l.tenants, + Organizations: l.organizations, + Users: l.users, + RoleBindings: l.roleBindings, + SSOProviders: l.ssoProviders, + IdentityLinks: l.identityLinks, + SSOSessions: ssoSessions, + SSOSessionHashes: map[string]string{}, + APIKeys: apiKeys, + APIKeyHashes: map[string]string{}, + Collectors: l.collectors, + CollectorReleases: l.collectorReleases, + Products: l.products, + Projects: l.projects, + Releases: l.releases, + Artifacts: l.artifacts, + BuildRuns: l.buildRuns, + BuildAttestations: l.attestations, + Evidence: l.evidence, + EvidenceLifecycle: l.lifecycle, + ReleaseCandidates: l.candidates, + ContainerImages: l.images, + ArtifactSignatures: l.artifactSigs, + Repositories: l.repositories, + Commits: l.commits, + Branches: l.branches, + PullRequests: l.pullRequests, + Environments: l.environments, + Deployments: l.deployments, + Incidents: l.incidents, + TimelineEvents: l.timeline, + IncidentWebhookReceivers: l.webhookReceivers, + IncidentWebhookEvents: l.webhookEvents, + RemediationTasks: l.tasks, + SecurityScans: l.securityScans, + ManualSecurityDocs: l.manualDocs, + SBOMDiffs: l.sbomDiffs, + DependencyChanges: l.depChanges, + VulnerabilityWorkflow: l.vulnWorkflow, + ContractDiffs: l.contractDiffs, + CustomPolicies: l.customPolicies, + CustomPolicyEvaluations: l.customPolicyEvals, + Waivers: l.waivers, + Approvals: l.approvals, + RedactionProfiles: l.redactions, + CustomerPackages: l.customerPackages, + HTMLReports: l.htmlReports, + ReportTemplates: l.reportTemplates, + RenderedReports: l.renderedReports, + EvidenceBundles: l.evidenceBundles, + BundleImports: l.bundleImports, + DSSETrustRoots: l.dsseTrustRoots, + CosignVerifications: l.cosignVerifs, + SigningProviders: l.signingProviders, + MerkleBatches: l.merkleBatches, + TransparencyCheckpoints: l.transparency, + ObjectRetentionPolicies: l.retentionPolicies, + BackupManifests: l.backupManifests, + LegalHolds: l.legalHolds, + RetentionOverrides: l.retentionOverrides, + CustomerPortalAccess: portalAccess, + CustomerPortalHashes: map[string]string{}, + QuestionnaireTemplates: l.questionTemplates, + QuestionnairePackages: l.questionPackages, + QuestionnaireAnswerLibrary: l.answerLibrary, + CommercialCollectors: l.commercialCollectors, + EvidenceSummaries: l.evidenceSummaries, + QuestionnaireDrafts: l.questionDrafts, + GraphSnapshots: l.graphSnapshots, + SaaSProfiles: l.saasProfiles, + PublicTransparencyLogs: l.publicLogs, + PublicTransparencyItems: l.publicLogEntries, + MarketplaceCollectors: l.marketplaceCollectors, + PDFReports: l.pdfReports, + AnomalyReports: l.anomalyReports, + ProviderVerifications: l.providerVerifications, + SigningOperations: l.signingOperations, + ControlFrameworks: l.frameworks, + SecurityControls: l.controls, + ControlEvidence: l.controlLinks, + SBOMs: l.sboms, + Scans: l.scans, + VEXDocuments: l.vexDocuments, + VEXImportReports: l.vexImportReports, + Decisions: l.decisions, + Contracts: l.contracts, + Policies: l.policies, + Exceptions: l.exceptions, + Bundles: l.bundles, + SigningKeys: signingKeys, + SigningKeyPrivate: map[string][]byte{}, + Signatures: l.signatures, + Verifications: l.verifications, + Chain: l.chain, + Idempotency: l.idempotency, } for id, key := range state.APIKeys { if key.Hash != "" { @@ -201,6 +216,8 @@ func (l *Ledger) applyState(state PersistedState) { l.deployments = state.Deployments l.incidents = state.Incidents l.timeline = state.TimelineEvents + l.webhookReceivers = state.IncidentWebhookReceivers + l.webhookEvents = state.IncidentWebhookEvents l.tasks = state.RemediationTasks l.securityScans = state.SecurityScans l.manualDocs = state.ManualSecurityDocs @@ -231,13 +248,26 @@ func (l *Ledger) applyState(state PersistedState) { l.portalAccess = state.CustomerPortalAccess l.questionTemplates = state.QuestionnaireTemplates l.questionPackages = state.QuestionnairePackages + l.answerLibrary = state.QuestionnaireAnswerLibrary l.commercialCollectors = state.CommercialCollectors + l.evidenceSummaries = state.EvidenceSummaries + l.questionDrafts = state.QuestionnaireDrafts + l.graphSnapshots = state.GraphSnapshots + l.saasProfiles = state.SaaSProfiles + l.publicLogs = state.PublicTransparencyLogs + l.publicLogEntries = state.PublicTransparencyItems + l.marketplaceCollectors = state.MarketplaceCollectors + l.pdfReports = state.PDFReports + l.anomalyReports = state.AnomalyReports + l.providerVerifications = state.ProviderVerifications + l.signingOperations = state.SigningOperations l.frameworks = state.ControlFrameworks l.controls = state.SecurityControls l.controlLinks = state.ControlEvidence l.sboms = state.SBOMs l.scans = state.Scans l.vexDocuments = state.VEXDocuments + l.vexImportReports = state.VEXImportReports l.decisions = state.Decisions l.contracts = state.Contracts l.policies = state.Policies @@ -254,7 +284,162 @@ func (l *Ledger) persistLocked(ctx context.Context) error { if l.store == nil { return nil } - return l.store.SaveState(ctx, l.snapshotLocked()) + state := l.snapshotLocked() + if relational, ok := l.store.(RelationalStateStore); ok { + return relational.SaveRelationalState(ctx, state) + } + return l.store.SaveState(ctx, state) +} + +func (l *Ledger) persistCriticalLocked(ctx context.Context, mutation CriticalMutation) error { + if l.store == nil { + return nil + } + focused, ok := l.store.(CriticalMutationStore) + if !ok { + return l.persistLocked(ctx) + } + return focused.ApplyCriticalMutation(ctx, mutation) +} + +func (l *Ledger) persistReleaseLedgerLocked(ctx context.Context, mutation ReleaseLedgerMutation) error { + if l.store == nil { + return nil + } + focused, ok := l.store.(ReleaseLedgerMutationStore) + if !ok { + return l.persistLocked(ctx) + } + return focused.ApplyReleaseLedgerMutation(ctx, mutation) +} + +func (l *Ledger) criticalMutationLocked() CriticalMutation { + return criticalMutationFromState(l.snapshotLocked()) +} + +func (l *Ledger) releaseLedgerMutationLocked() ReleaseLedgerMutation { + return releaseLedgerMutationFromState(l.snapshotLocked()) +} + +func ReleaseLedgerMutationFromState(state PersistedState) ReleaseLedgerMutation { + return releaseLedgerMutationFromState(state) +} + +func (l *Ledger) persistReleaseLedgerWithOutboxLocked(ctx context.Context, job OutboxJob) error { + mutation := l.releaseLedgerMutationLocked() + mutation.OutboxJobs = append(mutation.OutboxJobs, job) + if _, ok := l.store.(ReleaseLedgerMutationStore); !ok { + if err := l.enqueueJob(ctx, job); err != nil { + return err + } + } + return l.persistReleaseLedgerLocked(ctx, mutation) +} + +func releaseLedgerMutationFromState(state PersistedState) ReleaseLedgerMutation { + mutation := ReleaseLedgerMutation{} + for _, product := range state.Products { + mutation.Products = append(mutation.Products, product) + } + for _, project := range state.Projects { + mutation.Projects = append(mutation.Projects, project) + } + for _, release := range state.Releases { + mutation.Releases = append(mutation.Releases, release) + } + for _, artifact := range state.Artifacts { + mutation.Artifacts = append(mutation.Artifacts, artifact) + } + for _, evidence := range state.Evidence { + mutation.Evidence = append(mutation.Evidence, evidence) + } + for _, event := range state.EvidenceLifecycle { + mutation.EvidenceLifecycle = append(mutation.EvidenceLifecycle, event) + } + for _, sbom := range state.SBOMs { + mutation.SBOMs = append(mutation.SBOMs, sbom) + } + for _, scan := range state.Scans { + mutation.Scans = append(mutation.Scans, scan) + } + for _, contract := range state.Contracts { + mutation.Contracts = append(mutation.Contracts, contract) + } + for _, vex := range state.VEXDocuments { + mutation.VEXDocuments = append(mutation.VEXDocuments, vex) + } + for _, report := range state.VEXImportReports { + mutation.VEXImportReports = append(mutation.VEXImportReports, report) + } + for _, decision := range state.Decisions { + mutation.VulnerabilityDecisions = append(mutation.VulnerabilityDecisions, decision) + } + for _, tenantChain := range state.Chain { + mutation.AuditChainEntries = append(mutation.AuditChainEntries, tenantChain...) + } + return mutation +} + +func criticalMutationFromState(state PersistedState) CriticalMutation { + mutation := CriticalMutation{ + APIKeyHashes: map[string]string{}, + SSOSessionHashes: map[string]string{}, + CustomerPortalHashes: map[string]string{}, + Idempotency: map[string]IdempotencyRecord{}, + SigningKeyPrivate: map[string][]byte{}, + } + for _, tenant := range state.Tenants { + mutation.Tenants = append(mutation.Tenants, tenant) + } + for id, key := range state.APIKeys { + mutation.APIKeys = append(mutation.APIKeys, key) + if hash := state.APIKeyHashes[id]; hash != "" { + mutation.APIKeyHashes[id] = hash + } + } + for _, collector := range state.Collectors { + mutation.Collectors = append(mutation.Collectors, collector) + } + for id, session := range state.SSOSessions { + mutation.SSOSessions = append(mutation.SSOSessions, session) + if hash := state.SSOSessionHashes[id]; hash != "" { + mutation.SSOSessionHashes[id] = hash + } + } + for id, access := range state.CustomerPortalAccess { + mutation.CustomerPortalAccess = append(mutation.CustomerPortalAccess, access) + if hash := state.CustomerPortalHashes[id]; hash != "" { + mutation.CustomerPortalHashes[id] = hash + } + } + for key, record := range state.Idempotency { + mutation.Idempotency[key] = record + } + for id, key := range state.SigningKeys { + mutation.SigningKeys = append(mutation.SigningKeys, key) + if private := state.SigningKeyPrivate[id]; len(private) > 0 { + mutation.SigningKeyPrivate[id] = append([]byte(nil), private...) + } + } + for _, signature := range state.Signatures { + mutation.Signatures = append(mutation.Signatures, signature) + } + for _, bundle := range state.Bundles { + mutation.ReleaseBundles = append(mutation.ReleaseBundles, bundle) + } + for _, verification := range state.Verifications { + mutation.VerificationResults = append(mutation.VerificationResults, verification) + } + for _, verification := range state.ProviderVerifications { + mutation.ProviderVerifications = append(mutation.ProviderVerifications, verification) + } + for _, decision := range state.Decisions { + mutation.VulnerabilityDecisions = append(mutation.VulnerabilityDecisions, decision) + } + for _, entries := range state.Chain { + mutation.AuditChainEntries = append(mutation.AuditChainEntries, entries...) + } + return mutation } func (l *Ledger) storePayload(ctx context.Context, tenantID, kind, mediaType, digest string, raw []byte) (string, error) { @@ -281,10 +466,11 @@ func (l *Ledger) storePayload(ctx context.Context, tenantID, kind, mediaType, di } func (l *Ledger) enqueue(ctx context.Context, tenantID, kind, subjectType, subjectID string, payload map[string]any) error { - if l.outbox == nil { - return nil - } - job := OutboxJob{ + return l.enqueueJob(ctx, l.newOutboxJob(tenantID, kind, subjectType, subjectID, payload)) +} + +func (l *Ledger) newOutboxJob(tenantID, kind, subjectType, subjectID string, payload map[string]any) OutboxJob { + return OutboxJob{ ID: newID("job"), TenantID: tenantID, Kind: kind, @@ -293,6 +479,12 @@ func (l *Ledger) enqueue(ctx context.Context, tenantID, kind, subjectType, subje Payload: cloneMap(payload), CreatedAt: l.now(), } +} + +func (l *Ledger) enqueueJob(ctx context.Context, job OutboxJob) error { + if l.outbox == nil { + return nil + } return l.outbox.Enqueue(ctx, job) } @@ -402,6 +594,12 @@ func normalizeState(state PersistedState) PersistedState { if state.TimelineEvents == nil { state.TimelineEvents = map[string]domain.IncidentTimelineEvent{} } + if state.IncidentWebhookReceivers == nil { + state.IncidentWebhookReceivers = map[string]domain.IncidentWebhookReceiver{} + } + if state.IncidentWebhookEvents == nil { + state.IncidentWebhookEvents = map[string]domain.IncidentWebhookEvent{} + } if state.RemediationTasks == nil { state.RemediationTasks = map[string]domain.RemediationTask{} } @@ -495,9 +693,45 @@ func normalizeState(state PersistedState) PersistedState { if state.QuestionnairePackages == nil { state.QuestionnairePackages = map[string]domain.QuestionnairePackage{} } + if state.QuestionnaireAnswerLibrary == nil { + state.QuestionnaireAnswerLibrary = map[string]domain.QuestionnaireAnswerLibraryEntry{} + } if state.CommercialCollectors == nil { state.CommercialCollectors = map[string]domain.CommercialCollectorDefinition{} } + if state.EvidenceSummaries == nil { + state.EvidenceSummaries = map[string]domain.EvidenceSummary{} + } + if state.QuestionnaireDrafts == nil { + state.QuestionnaireDrafts = map[string]domain.QuestionnaireDraft{} + } + if state.GraphSnapshots == nil { + state.GraphSnapshots = map[string]domain.EvidenceGraphSnapshot{} + } + if state.SaaSProfiles == nil { + state.SaaSProfiles = map[string]domain.SaaSEditionProfile{} + } + if state.PublicTransparencyLogs == nil { + state.PublicTransparencyLogs = map[string]domain.PublicTransparencyLog{} + } + if state.PublicTransparencyItems == nil { + state.PublicTransparencyItems = map[string]domain.PublicTransparencyLogEntry{} + } + if state.MarketplaceCollectors == nil { + state.MarketplaceCollectors = map[string]domain.MarketplaceCollector{} + } + if state.PDFReports == nil { + state.PDFReports = map[string]domain.PDFReportPackage{} + } + if state.AnomalyReports == nil { + state.AnomalyReports = map[string]domain.AnomalyReport{} + } + if state.ProviderVerifications == nil { + state.ProviderVerifications = map[string]domain.ProviderVerification{} + } + if state.SigningOperations == nil { + state.SigningOperations = map[string]domain.SigningOperation{} + } if state.ControlFrameworks == nil { state.ControlFrameworks = map[string]domain.ControlFramework{} } @@ -516,6 +750,9 @@ func normalizeState(state PersistedState) PersistedState { if state.VEXDocuments == nil { state.VEXDocuments = map[string]domain.VEXDocument{} } + if state.VEXImportReports == nil { + state.VEXImportReports = map[string]domain.VEXImportReport{} + } if state.Decisions == nil { state.Decisions = map[string]domain.VulnerabilityDecision{} } diff --git a/internal/app/vex.go b/internal/app/vex.go index 9e6fd4d..baefcf7 100644 --- a/internal/app/vex.go +++ b/internal/app/vex.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "fmt" "io" "sort" "strconv" @@ -20,11 +21,34 @@ const ( decisionStatusUnderInvestigation = "under_investigation" ) +var decisionStatusTransitions = map[string]map[string]struct{}{ + decisionStatusAffected: decisionStatusSet(decisionStatusAffected, decisionStatusNotAffected, decisionStatusFixed, decisionStatusUnderInvestigation), + decisionStatusNotAffected: decisionStatusSet(decisionStatusAffected, decisionStatusNotAffected, decisionStatusFixed, decisionStatusUnderInvestigation), + decisionStatusFixed: decisionStatusSet(decisionStatusAffected, decisionStatusNotAffected, decisionStatusFixed, decisionStatusUnderInvestigation), + decisionStatusUnderInvestigation: decisionStatusSet(decisionStatusAffected, decisionStatusNotAffected, decisionStatusFixed, decisionStatusUnderInvestigation), +} + type CreateVulnerabilityDecisionInput struct { Status string Justification string ImpactStatement string ActionStatement string + CustomerVisible bool + InternalNotes string + EvidenceIDs []string + SupportingRefs []domain.SubjectRef + VEXDocumentID string + ReviewedAt *time.Time + ReviewDueAt *time.Time +} + +type ListVulnerabilityDecisionsInput struct { + ProductID string + ReleaseID string + Vulnerability string + Component string + Status string + Active *bool } type CreateExceptionInput struct { @@ -63,7 +87,11 @@ type openVEXProduct struct { Subcomponents []openVEXProduct `json:"subcomponents,omitempty"` } -func (l *Ledger) UploadVEX(ctx context.Context, actor domain.Actor, releaseID, artifactID string, raw []byte) (domain.VEXDocument, error) { +func (s releaseEvidenceService) UploadVEX(ctx context.Context, actor domain.Actor, releaseID, artifactID string, raw []byte) (domain.VEXDocument, error) { + l := s.ledger + if err := ctx.Err(); err != nil { + return domain.VEXDocument{}, err + } if err := require(actor, ScopeEvidenceWrite); err != nil { return domain.VEXDocument{}, err } @@ -91,6 +119,10 @@ func (l *Ledger) UploadVEX(ctx context.Context, actor domain.Actor, releaseID, a return domain.VEXDocument{}, ErrNotFound } } + if err := l.authorizeResourceLocked(actor, ScopeEvidenceWrite, resourceRefs{ReleaseID: releaseID}); err != nil { + l.mu.Unlock() + return domain.VEXDocument{}, err + } l.mu.Unlock() payloadHash := hashBytes(raw) @@ -139,32 +171,182 @@ func (l *Ledger) UploadVEX(ctx context.Context, actor domain.Actor, releaseID, a SchemaVersion: domain.VEXDocumentSchemaVersion, CreatedAt: l.now(), } - l.vexDocuments[vex.ID] = vex + persistedVEX := vex + chainAction := "vex.parsed" + if l.workerOwnedParsers { + persistedVEX.Author = "" + persistedVEX.StatementCount = 0 + persistedVEX.StatusSummary = nil + chainAction = "vex.accepted" + } + l.vexDocuments[vex.ID] = persistedVEX createdDecisions := 0 - for _, statement := range doc.Statements { - for _, matched := range l.findMatchingFindingsLocked(actor.TenantID, releaseID, statement) { - decision := l.createDecisionLocked(actor.TenantID, matched.scan, matched.finding, CreateVulnerabilityDecisionInput{ - Status: statement.Status, - Justification: statement.Justification, - ImpactStatement: statement.ImpactStatement, - ActionStatement: statement.ActionStatement, - }, "vex", actor.KeyID, item.ID, vex.ID) - l.decisions[decision.ID] = decision - _, _ = l.appendChainLocked(actor.TenantID, "vulnerability_decision.created", "vulnerability_finding", matched.finding.ID, "api_key", actor.KeyID, payloadHash, "") - createdDecisions++ - } - } - _, _ = l.appendChainLocked(actor.TenantID, "vex.parsed", "vex_document", vex.ID, "api_key", actor.KeyID, payloadHash, "") - if err := l.enqueue(ctx, actor.TenantID, "parse_vex", "vex_document", vex.ID, map[string]any{"payload_ref": payloadRef, "payload_hash": payloadHash, "decisions_created": createdDecisions}); err != nil { - return domain.VEXDocument{}, err + supersededDecisions := 0 + mappingFailures := []domain.VEXImportIssue{} + warnings := []string{} + if !l.workerOwnedParsers { + createdForFinding := map[string]struct{}{} + duplicateWarningAdded := false + for index, statement := range doc.Statements { + matches := l.findMatchingFindingsLocked(actor.TenantID, releaseID, statement) + if len(matches) == 0 { + mappingFailures = append(mappingFailures, vexImportIssue(index+1, "finding_not_found", "No matching vulnerability scan finding was found for this VEX statement.")) + } + for _, matched := range matches { + if _, seen := createdForFinding[matched.finding.ID]; seen { + if !duplicateWarningAdded { + warnings = append(warnings, "Duplicate VEX statements for an already mapped finding were ignored.") + duplicateWarningAdded = true + } + continue + } + createdForFinding[matched.finding.ID] = struct{}{} + decision := l.createDecisionLocked(actor.TenantID, matched.scan, matched.finding, CreateVulnerabilityDecisionInput{ + Status: statement.Status, + Justification: statement.Justification, + ImpactStatement: statement.ImpactStatement, + ActionStatement: statement.ActionStatement, + CustomerVisible: strings.TrimSpace(statement.ImpactStatement) != "", + }, "vex", actorID(actor), item.ID, vex.ID) + l.decisions[decision.ID] = decision + if decision.Supersedes != "" { + supersededDecisions++ + } + l.appendDecisionLifecycleAuditLocked(actor.TenantID, decision, matched.finding.ID, actorType(actor), actorID(actor), payloadHash) + createdDecisions++ + } + } + } else { + for index, statement := range doc.Statements { + if len(l.findMatchingFindingsLocked(actor.TenantID, releaseID, statement)) == 0 { + mappingFailures = append(mappingFailures, vexImportIssue(index+1, "finding_not_found", "No matching vulnerability scan finding was found for this VEX statement at upload time.")) + } + } } - if err := l.persistLocked(ctx); err != nil { + if l.workerOwnedParsers { + warnings = append(warnings, "Worker-owned parser side effects are enabled; decisions are created asynchronously after payload replay.") + } + report := domain.VEXImportReport{ + ID: newID("vexrep"), + TenantID: actor.TenantID, + VEXDocumentID: vex.ID, + EvidenceID: item.ID, + ReleaseID: releaseID, + ArtifactID: artifactID, + ParserVersion: ParserVersionOpenVEXJSON, + Status: ternary(l.workerOwnedParsers, "accepted", "parsed"), + StatementCount: len(doc.Statements), + DecisionsCreated: createdDecisions, + DecisionsSuperseded: supersededDecisions, + UnsupportedFields: []string{}, + Warnings: warnings, + MappingFailures: mappingFailures, + SchemaVersion: domain.VEXImportReportSchemaVersion, + CreatedAt: l.now(), + UpdatedAt: l.now(), + } + l.vexImportReports[report.ID] = report + _, _ = l.appendChainLocked(actor.TenantID, chainAction, "vex_document", vex.ID, actorType(actor), actorID(actor), payloadHash, "") + jobPayload := map[string]any{"payload_ref": payloadRef, "payload_hash": payloadHash, "parser_version": ParserVersionOpenVEXJSON, "decisions_created": createdDecisions, "import_report_id": report.ID} + if l.workerOwnedParsers { + jobPayload["worker_create_decisions"] = true + jobPayload["actor_type"] = actorType(actor) + jobPayload["actor_id"] = actorID(actor) + jobPayload["evidence_id"] = item.ID + } + job := l.newOutboxJob(actor.TenantID, "parse_vex", "vex_document", vex.ID, jobPayload) + if err := l.persistReleaseLedgerWithOutboxLocked(ctx, job); err != nil { return domain.VEXDocument{}, err } return vex, nil } -func (l *Ledger) GetVEXDocument(ctx context.Context, actor domain.Actor, id string) (domain.VEXDocument, error) { +func (s releaseEvidenceService) PreviewVEXImport(ctx context.Context, actor domain.Actor, releaseID, artifactID string, raw []byte) (domain.VEXImportPreview, error) { + l := s.ledger + if err := ctx.Err(); err != nil { + return domain.VEXImportPreview{}, err + } + if err := require(actor, ScopeEvidenceRead); err != nil { + return domain.VEXImportPreview{}, err + } + if len(raw) == 0 || len(raw) > 20<<20 { + return domain.VEXImportPreview{}, ErrValidation + } + doc, err := parseOpenVEX(raw) + if err != nil { + return domain.VEXImportPreview{}, err + } + releaseID = strings.TrimSpace(releaseID) + artifactID = strings.TrimSpace(artifactID) + if releaseID == "" { + return domain.VEXImportPreview{}, ErrValidation + } + statusSummary := map[string]int{} + for _, statement := range doc.Statements { + statusSummary[statement.Status]++ + } + l.mu.Lock() + defer l.mu.Unlock() + if err := l.ensureScopeLocked(actor.TenantID, "", "", releaseID); err != nil { + return domain.VEXImportPreview{}, err + } + if artifactID != "" { + artifact, ok := l.artifacts[artifactID] + if !ok || artifact.TenantID != actor.TenantID { + return domain.VEXImportPreview{}, ErrNotFound + } + } + if err := l.authorizeResourceLocked(actor, ScopeEvidenceRead, resourceRefs{ReleaseID: releaseID}); err != nil { + return domain.VEXImportPreview{}, err + } + created, superseded, warnings, mappingFailures := l.previewOpenVEXDecisionEffectsLocked(actor.TenantID, releaseID, doc.Statements) + return domain.VEXImportPreview{ + TenantID: actor.TenantID, + ReleaseID: releaseID, + ArtifactID: artifactID, + Format: "openvex", + ParserVersion: ParserVersionOpenVEXJSON, + Advisory: true, + StatementCount: len(doc.Statements), + StatusSummary: cloneIntMap(statusSummary), + DecisionsWouldCreate: created, + DecisionsWouldSupersede: superseded, + Warnings: warnings, + MappingFailures: mappingFailures, + Assumptions: vexImportPreviewAssumptions(), + Limitations: vexImportPreviewLimitations(), + SchemaVersion: domain.VEXImportPreviewSchemaVersion, + GeneratedAt: l.now(), + }, nil +} + +func (s releaseEvidenceService) GetVEXImportReport(ctx context.Context, actor domain.Actor, vexID string) (domain.VEXImportReport, error) { + l := s.ledger + if err := ctx.Err(); err != nil { + return domain.VEXImportReport{}, err + } + if err := require(actor, ScopeEvidenceRead); err != nil { + return domain.VEXImportReport{}, err + } + l.mu.Lock() + defer l.mu.Unlock() + vex, ok := l.vexDocuments[strings.TrimSpace(vexID)] + if !ok || vex.TenantID != actor.TenantID { + return domain.VEXImportReport{}, ErrNotFound + } + if err := l.authorizeResourceLocked(actor, ScopeEvidenceRead, resourceRefs{ReleaseID: vex.ReleaseID}); err != nil { + return domain.VEXImportReport{}, err + } + for _, report := range l.vexImportReports { + if report.TenantID == actor.TenantID && report.VEXDocumentID == vex.ID { + return report, nil + } + } + return domain.VEXImportReport{}, ErrNotFound +} + +func (s releaseEvidenceService) GetVEXDocument(ctx context.Context, actor domain.Actor, id string) (domain.VEXDocument, error) { + l := s.ledger if err := ctx.Err(); err != nil { return domain.VEXDocument{}, err } @@ -177,10 +359,14 @@ func (l *Ledger) GetVEXDocument(ctx context.Context, actor domain.Actor, id stri if !ok || vex.TenantID != actor.TenantID { return domain.VEXDocument{}, ErrNotFound } + if err := l.authorizeResourceLocked(actor, ScopeEvidenceRead, resourceRefs{ReleaseID: vex.ReleaseID}); err != nil { + return domain.VEXDocument{}, err + } return vex, nil } -func (l *Ledger) CreateVulnerabilityDecision(ctx context.Context, actor domain.Actor, findingID string, in CreateVulnerabilityDecisionInput) (domain.VulnerabilityDecision, error) { +func (s releaseEvidenceService) CreateVulnerabilityDecision(ctx context.Context, actor domain.Actor, findingID string, in CreateVulnerabilityDecisionInput) (domain.VulnerabilityDecision, error) { + l := s.ledger if err := ctx.Err(); err != nil { return domain.VulnerabilityDecision{}, err } @@ -190,22 +376,224 @@ func (l *Ledger) CreateVulnerabilityDecision(ctx context.Context, actor domain.A if !validDecisionStatus(in.Status) || strings.TrimSpace(in.Justification) == "" { return domain.VulnerabilityDecision{}, ErrValidation } + if in.CustomerVisible && strings.TrimSpace(in.ImpactStatement) == "" { + return domain.VulnerabilityDecision{}, ErrValidation + } + if len(strings.TrimSpace(in.InternalNotes)) > 8192 { + return domain.VulnerabilityDecision{}, ErrValidation + } + if err := normalizeDecisionReviewTimes(&in, l.now()); err != nil { + return domain.VulnerabilityDecision{}, err + } l.mu.Lock() defer l.mu.Unlock() scan, finding, ok := l.findFindingLocked(actor.TenantID, strings.TrimSpace(findingID)) if !ok { return domain.VulnerabilityDecision{}, ErrNotFound } - decision := l.createDecisionLocked(actor.TenantID, scan, finding, in, "api", actor.KeyID, "", "") + if err := l.authorizeResourceLocked(actor, ScopeEvidenceWrite, resourceRefs{ReleaseID: scan.ReleaseID}); err != nil { + return domain.VulnerabilityDecision{}, err + } + if latest, ok := l.latestDecisionForFindingLocked(actor.TenantID, finding.ID); ok && !validDecisionTransition(latest.Status, strings.TrimSpace(in.Status)) { + return domain.VulnerabilityDecision{}, ErrValidation + } + in.VEXDocumentID = strings.TrimSpace(in.VEXDocumentID) + if in.VEXDocumentID != "" { + vex, ok := l.vexDocuments[in.VEXDocumentID] + if !ok || vex.TenantID != actor.TenantID || vex.ReleaseID != scan.ReleaseID { + return domain.VulnerabilityDecision{}, ErrNotFound + } + in.VEXDocumentID = vex.ID + } + evidenceIDs, err := l.validateDecisionEvidenceLinksLocked(actor.TenantID, scan.ReleaseID, in.EvidenceIDs) + if err != nil { + return domain.VulnerabilityDecision{}, err + } + in.EvidenceIDs = evidenceIDs + supportingRefs, err := l.validateDecisionSupportingRefsLocked(actor.TenantID, scan.ReleaseID, in.SupportingRefs) + if err != nil { + return domain.VulnerabilityDecision{}, err + } + in.SupportingRefs = supportingRefs + decision := l.createDecisionLocked(actor.TenantID, scan, finding, in, "api", actor.KeyID, "", in.VEXDocumentID) l.decisions[decision.ID] = decision - _, _ = l.appendChainLocked(actor.TenantID, "vulnerability_decision.created", "vulnerability_finding", finding.ID, "api_key", actor.KeyID, "", "") - if err := l.persistLocked(ctx); err != nil { + l.appendDecisionLifecycleAuditLocked(actor.TenantID, decision, finding.ID, "api_key", actor.KeyID, "") + if err := l.persistCriticalLocked(ctx, l.criticalMutationLocked()); err != nil { return domain.VulnerabilityDecision{}, err } return decision, nil } -func (l *Ledger) CreateException(ctx context.Context, actor domain.Actor, in CreateExceptionInput) (domain.Exception, error) { +func (s releaseEvidenceService) ListVulnerabilityDecisions(ctx context.Context, actor domain.Actor, in ListVulnerabilityDecisionsInput) ([]domain.VulnerabilityDecision, error) { + l := s.ledger + if err := ctx.Err(); err != nil { + return nil, err + } + if err := require(actor, ScopeEvidenceRead); err != nil { + return nil, err + } + in.ProductID = strings.TrimSpace(in.ProductID) + in.ReleaseID = strings.TrimSpace(in.ReleaseID) + in.Vulnerability = strings.TrimSpace(in.Vulnerability) + in.Component = strings.TrimSpace(in.Component) + in.Status = strings.TrimSpace(in.Status) + if in.Status != "" && !validDecisionStatus(in.Status) { + return nil, ErrValidation + } + + l.mu.Lock() + defer l.mu.Unlock() + filterProductID := in.ProductID + if filterProductID != "" { + product, ok := l.products[filterProductID] + if !ok || product.TenantID != actor.TenantID { + return nil, ErrNotFound + } + if err := l.authorizeResourceLocked(actor, ScopeEvidenceRead, resourceRefs{ProductID: product.ID}); err != nil { + return nil, err + } + } + if in.ReleaseID != "" { + release, ok := l.releases[in.ReleaseID] + if !ok || release.TenantID != actor.TenantID { + return nil, ErrNotFound + } + if filterProductID != "" && release.ProductID != filterProductID { + return nil, ErrNotFound + } + filterProductID = release.ProductID + if err := l.authorizeResourceLocked(actor, ScopeEvidenceRead, resourceRefs{ProductID: release.ProductID, ReleaseID: release.ID}); err != nil { + return nil, err + } + } + + out := []domain.VulnerabilityDecision{} + for _, decision := range l.decisions { + if decision.TenantID != actor.TenantID { + continue + } + if in.ReleaseID != "" && decision.ReleaseID != in.ReleaseID { + continue + } + releaseProductID := "" + if decision.ReleaseID != "" { + release, ok := l.releases[decision.ReleaseID] + if !ok || release.TenantID != actor.TenantID { + continue + } + releaseProductID = release.ProductID + } + if filterProductID != "" && releaseProductID != filterProductID { + continue + } + if in.Vulnerability != "" && decision.Vulnerability != in.Vulnerability { + continue + } + if in.Component != "" && decision.Component != in.Component { + continue + } + if in.Status != "" && decision.Status != in.Status { + continue + } + if in.Active != nil && (*in.Active) != (decision.SupersededBy == "") { + continue + } + if !l.resourceAllowedLocked(actor, ScopeEvidenceRead, resourceRefs{ProductID: releaseProductID, ReleaseID: decision.ReleaseID}) { + continue + } + out = append(out, decision) + } + sort.Slice(out, func(i, j int) bool { + if out[i].CreatedAt.Equal(out[j].CreatedAt) { + return out[i].ID < out[j].ID + } + return out[i].CreatedAt.Before(out[j].CreatedAt) + }) + return out, nil +} + +func (s releaseEvidenceService) VulnerabilityDecisionSummaryReport(ctx context.Context, actor domain.Actor, releaseID string) (domain.VulnerabilityDecisionSummaryReport, error) { + l := s.ledger + if err := ctx.Err(); err != nil { + return domain.VulnerabilityDecisionSummaryReport{}, err + } + if err := require(actor, ScopeReportRead); err != nil { + return domain.VulnerabilityDecisionSummaryReport{}, err + } + releaseID = strings.TrimSpace(releaseID) + if releaseID == "" { + return domain.VulnerabilityDecisionSummaryReport{}, ErrValidation + } + l.mu.Lock() + defer l.mu.Unlock() + release, ok := l.releases[releaseID] + if !ok || release.TenantID != actor.TenantID { + return domain.VulnerabilityDecisionSummaryReport{}, ErrNotFound + } + if err := l.authorizeResourceLocked(actor, ScopeReportRead, resourceRefs{ProductID: release.ProductID, ReleaseID: release.ID}); err != nil { + return domain.VulnerabilityDecisionSummaryReport{}, err + } + decisions := []domain.VulnerabilityDecisionCustomerSummary{} + for _, decision := range l.decisions { + if decision.TenantID != actor.TenantID || decision.ReleaseID != release.ID || decision.SupersededBy != "" || !decision.CustomerVisible { + continue + } + decisions = append(decisions, customerDecisionSummary(decision)) + } + sort.Slice(decisions, func(i, j int) bool { + if decisions[i].CreatedAt.Equal(decisions[j].CreatedAt) { + return decisions[i].ID < decisions[j].ID + } + return decisions[i].CreatedAt.Before(decisions[j].CreatedAt) + }) + return domain.VulnerabilityDecisionSummaryReport{ + ReportType: "vulnerability_decision_summary", + TemplateVersion: "vulnerability-decision-summary.v1.0.0", + ProductID: release.ProductID, + ReleaseID: release.ID, + Decisions: decisions, + Assumptions: []string{ + "Only active vulnerability decisions marked customer_visible are included.", + "Evidence identifiers point to records in this Evydence instance; raw evidence payload bytes are not included.", + "reviewed_at records when the decision was reviewed; missing review_due_at means no scheduled follow-up review was recorded.", + }, + Limitations: []string{ + "This summary supports compliance-readiness review; it is not certification, legal advice, complete SBOM proof, or authoritative vulnerability coverage.", + "Decision accuracy depends on tenant-supplied evidence, scanner inputs, and review quality.", + "Review dates are tenant-supplied metadata and do not prove that the underlying vulnerability analysis is still correct.", + }, + GeneratedAt: l.now(), + }, nil +} + +func customerDecisionSummary(decision domain.VulnerabilityDecision) domain.VulnerabilityDecisionCustomerSummary { + return domain.VulnerabilityDecisionCustomerSummary{ + ID: decision.ID, + FindingID: decision.FindingID, + ScanID: decision.ScanID, + ReleaseID: decision.ReleaseID, + Vulnerability: decision.Vulnerability, + Component: decision.Component, + SBOMID: decision.SBOMID, + SBOMComponentPURL: decision.SBOMComponentPURL, + SBOMComponentName: decision.SBOMComponentName, + Status: decision.Status, + Justification: decision.Justification, + ImpactStatement: decision.ImpactStatement, + ActionStatement: decision.ActionStatement, + Source: decision.Source, + EvidenceID: decision.EvidenceID, + EvidenceIDs: append([]string(nil), decision.EvidenceIDs...), + SupportingRefs: cloneSubjectRefs(decision.SupportingRefs), + VEXDocumentID: decision.VEXDocumentID, + ReviewedAt: cloneTimePtr(decision.ReviewedAt), + ReviewDueAt: cloneTimePtr(decision.ReviewDueAt), + CreatedAt: decision.CreatedAt, + } +} + +func (s releaseEvidenceService) CreateException(ctx context.Context, actor domain.Actor, in CreateExceptionInput) (domain.Exception, error) { + l := s.ledger if err := ctx.Err(); err != nil { return domain.Exception{}, err } @@ -225,6 +613,9 @@ func (l *Ledger) CreateException(ctx context.Context, actor domain.Actor, in Cre if !ok || release.TenantID != actor.TenantID { return domain.Exception{}, ErrNotFound } + if err := l.authorizeResourceLocked(actor, ScopeReleaseWrite, resourceRefs{ProductID: release.ProductID, ReleaseID: release.ID}); err != nil { + return domain.Exception{}, err + } if in.FindingID != "" { scan, _, ok := l.findFindingLocked(actor.TenantID, in.FindingID) if !ok || scan.ReleaseID != in.ReleaseID { @@ -257,7 +648,8 @@ func (l *Ledger) CreateException(ctx context.Context, actor domain.Actor, in Cre return exception, nil } -func (l *Ledger) ListExceptions(ctx context.Context, actor domain.Actor, releaseID string) ([]domain.Exception, error) { +func (s releaseEvidenceService) ListExceptions(ctx context.Context, actor domain.Actor, releaseID string) ([]domain.Exception, error) { + l := s.ledger if err := ctx.Err(); err != nil { return nil, err } @@ -274,13 +666,17 @@ func (l *Ledger) ListExceptions(ctx context.Context, actor domain.Actor, release if releaseID != "" && exception.ReleaseID != releaseID { continue } + if !l.resourceAllowedLocked(actor, ScopeVerifyRead, resourceRefs{ReleaseID: exception.ReleaseID}) { + continue + } out = append(out, exception) } sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt.Before(out[j].CreatedAt) }) return out, nil } -func (l *Ledger) ApproveException(ctx context.Context, actor domain.Actor, id string) (domain.Exception, error) { +func (s releaseEvidenceService) ApproveException(ctx context.Context, actor domain.Actor, id string) (domain.Exception, error) { + l := s.ledger if err := ctx.Err(); err != nil { return domain.Exception{}, err } @@ -296,6 +692,9 @@ func (l *Ledger) ApproveException(ctx context.Context, actor domain.Actor, id st if !exception.ExpiresAt.After(l.now()) { return domain.Exception{}, ErrConflict } + if err := l.authorizeResourceLocked(actor, ScopeReleaseWrite, resourceRefs{ReleaseID: exception.ReleaseID}); err != nil { + return domain.Exception{}, err + } if exception.Approved { return exception, nil } @@ -311,7 +710,8 @@ func (l *Ledger) ApproveException(ctx context.Context, actor domain.Actor, id st return exception, nil } -func (l *Ledger) ReleaseReadinessReport(ctx context.Context, actor domain.Actor, releaseID string) (domain.ReleaseReadinessReport, error) { +func (s releaseEvidenceService) ReleaseReadinessReport(ctx context.Context, actor domain.Actor, releaseID string) (domain.ReleaseReadinessReport, error) { + l := s.ledger eval, err := l.EvaluateRelease(ctx, actor, releaseID) if err != nil { return domain.ReleaseReadinessReport{}, err @@ -324,54 +724,376 @@ func (l *Ledger) ReleaseReadinessReport(ctx context.Context, actor domain.Actor, for _, check := range eval.Checks { gaps = append(gaps, check.Missing...) } - sort.Strings(gaps) + gaps = sortedUniqueNonEmptyStrings(gaps) + failedPolicies := failedPolicyNames(eval.Checks) + knownLimitations := releaseReadinessKnownLimitations() + nonClaims := releaseReadinessNonClaims() + sections := l.releaseReadinessSectionsLocked(actor.TenantID, eval.ReleaseID, eval, blocking, accepted, gaps, failedPolicies, knownLimitations) return domain.ReleaseReadinessReport{ ReportType: "release_readiness", TemplateVersion: domain.ReleaseReadinessTemplateVersion, ReleaseID: eval.ReleaseID, Result: eval.Result, + PolicySet: eval.PolicySet, + Summary: releaseReadinessSummary(eval, len(blocking), len(gaps), len(failedPolicies)), Checks: eval.Checks, + Sections: sections, BlockingFindings: blocking, AcceptedExceptions: accepted, Gaps: gaps, - Assumptions: []string{"This report supports compliance readiness and is not a legal compliance conclusion."}, - Limitations: []string{"Readiness is based only on evidence, decisions, exceptions, and bundles recorded in this Evydence instance."}, + MissingEvidence: gaps, + FailedPolicies: failedPolicies, + KnownLimitations: knownLimitations, + NonClaims: nonClaims, + Assumptions: []string{"This report supports compliance readiness and technical evidence review; it is not a legal compliance conclusion."}, + Limitations: knownLimitations, GeneratedAt: l.now(), }, nil } +func releaseReadinessSummary(eval domain.PolicyEvaluation, blockingCount, missingCount, failedCount int) domain.ReadinessSummary { + if eval.Result == "passed" { + return domain.ReadinessSummary{ + Headline: "Recorded release evidence satisfies the current built-in readiness checks.", + Result: eval.Result, + HumanSummary: "The report found no blocking policy failures in the recorded release evidence. Review the limitations and non-claims before sharing externally.", + PolicySet: eval.PolicySet, + } + } + return domain.ReadinessSummary{ + Headline: "Recorded release evidence has readiness blockers or missing inputs.", + Result: eval.Result, + HumanSummary: fmt.Sprintf("The report found %d failed policy checks, %d missing evidence inputs, and %d blocking findings in the recorded release evidence.", failedCount, missingCount, blockingCount), + PolicySet: eval.PolicySet, + } +} + +func releaseReadinessKnownLimitations() []string { + return []string{ + "Readiness is based only on evidence, decisions, exceptions, bundles, and build records stored in this Evydence instance.", + "SBOM presence does not prove component inventory completeness.", + "Scanner output is submitted evidence and is not treated as authoritative vulnerability truth.", + "Build provenance and attestations are evaluated from recorded metadata and configured trust material only.", + "Customer package shareability depends on the generated package redaction profile, expiry, and operator review.", + } +} + +func releaseReadinessNonClaims() []string { + return []string{ + "This report supports technical evidence review and compliance readiness only.", + "It is not legal compliance proof, certification, complete SBOM proof, an authoritative vulnerability result, regulator acceptance, or a secure-release guarantee.", + } +} + +func failedPolicyNames(checks []domain.PolicyCheck) []string { + out := []string{} + for _, check := range checks { + if check.Result == "failed" { + out = append(out, check.Name) + } + } + sort.Strings(out) + return out +} + +func (l *Ledger) releaseReadinessSectionsLocked(tenantID, releaseID string, eval domain.PolicyEvaluation, blocking []domain.BlockingFinding, accepted []domain.Exception, missingEvidence, failedPolicies, knownLimitations []string) []domain.ReadinessSection { + checks := readinessChecksByName(eval.Checks) + decisionCount := l.activeDecisionCountForReleaseLocked(tenantID, releaseID) + hasPackage := l.hasActiveCustomerPackageLocked(tenantID, releaseID) + sections := []domain.ReadinessSection{ + readinessSection("release_evidence", "Release Evidence", []domain.ReadinessQuestion{ + readinessQuestionForCheck(checks["release_has_artifact"], "release_has_artifact", "Is a release artifact linked?", "Artifact evidence is linked to the release.", "Release artifact evidence is missing."), + readinessQuestionForCheck(checks["release_requires_artifact_digest"], "artifact_digest", "Are artifact digests present?", "Artifact digest evidence is linked to the release.", "Release artifact digest evidence is missing."), + readinessQuestionForCheck(checks["release_requires_sbom"], "sbom", "Is there an SBOM for this release?", "SBOM evidence is recorded for this release.", "SBOM evidence is missing for this release."), + readinessQuestionForCheck(checks["release_requires_vulnerability_scan"], "vulnerability_scan", "Is there a vulnerability scan?", "Vulnerability scan evidence is recorded for this release.", "Vulnerability scan evidence is missing for this release."), + }), + readinessSection("risk_decisions", "Vulnerability Decisions", []domain.ReadinessQuestion{ + readinessQuestionForCheck(checks["critical_exploitable_blocks_release"], "critical_findings_triaged", "Are open critical findings triaged?", "No unhandled open critical findings are recorded.", "One or more open critical findings require a valid decision, remediation, or approved unexpired exception."), + readinessQuestionForCheck(checks["high_findings_require_triage"], "high_findings_triaged", "Are open high findings triaged?", "No unhandled open high findings are recorded.", "One or more open high findings require a valid decision, remediation, or approved unexpired exception."), + readinessDecisionQuestion(decisionCount, len(blocking)), + readinessQuestionForCheck(checks["customer_visible_decisions_require_statements"], "customer_visible_decision_statements", "Do customer-visible decisions have review-safe statements?", "Customer-visible decisions have required impact statements.", "One or more customer-visible decisions is missing an impact statement."), + readinessQuestionForCheck(checks["not_affected_decisions_require_justification"], "not_affected_justifications", "Do not_affected decisions include justifications?", "not_affected decisions include recorded justifications.", "One or more not_affected decisions is missing a justification."), + readinessExceptionQuestion(accepted), + readinessQuestionForCheck(checks["exceptions_require_owner_reason_expiry_and_approval"], "exception_completeness", "Are exceptions complete?", "Release exceptions have required owner, reason, expiry, and approval metadata.", "One or more exceptions is incomplete."), + }), + readinessSection("provenance", "Build Provenance And Bundle", []domain.ReadinessQuestion{ + readinessQuestionForCheck(checks["release_requires_passed_build"], "passed_build", "Is passed build provenance attached?", "A passed build is linked to a release artifact digest.", "No passed build with output digest linked to the release was found."), + readinessQuestionForCheck(checks["release_requires_build_attestation"], "build_attestation", "Is there a build attestation for a release artifact?", "A build attestation subject matches a release artifact digest.", "No build attestation subject matches a release artifact digest."), + readinessQuestionForCheck(checks["release_requires_signed_bundle"], "signed_bundle", "Is there a signed release bundle?", "A signed release bundle exists for this release.", "A signed release bundle is missing for this release."), + }), + readinessSection("customer_review", "Customer Package Review", []domain.ReadinessQuestion{ + readinessCustomerPackageQuestion(hasPackage), + readinessQuestionForCheck(checks["package_redaction_profile_valid"], "package_redaction_profile_valid", "Is the customer package redaction profile valid?", "Recorded package redaction profiles are explicit and exclude sensitive fields, or no package exists yet.", "One or more packages has an expired or incomplete redaction profile."), + }), + readinessSection("gaps_and_limitations", "Gaps And Limitations", []domain.ReadinessQuestion{ + readinessGapQuestion(missingEvidence, failedPolicies), + {ID: "known_limitations", Question: "What limitations apply?", Answer: "Read the known_limitations and non_claims fields before using this report.", Status: "limited", KnownLimitations: append([]string(nil), knownLimitations...)}, + }), + } + return sections +} + +func readinessChecksByName(checks []domain.PolicyCheck) map[string]domain.PolicyCheck { + out := map[string]domain.PolicyCheck{} + for _, check := range checks { + out[check.Name] = check + } + return out +} + +func readinessSection(id, title string, questions []domain.ReadinessQuestion) domain.ReadinessSection { + status := "passed" + for _, question := range questions { + switch question.Status { + case "failed_policy": + status = "failed" + case "missing_evidence": + if status != "failed" { + status = "missing" + } + case "limited": + if status == "passed" { + status = "limited" + } + } + } + return domain.ReadinessSection{ID: id, Title: title, Status: status, Summary: readinessSectionSummary(status), Questions: questions} +} + +func readinessSectionSummary(status string) string { + switch status { + case "failed": + return "One or more reviewer questions failed a policy check." + case "missing": + return "One or more reviewer questions is missing evidence." + case "limited": + return "This section has recorded limitations or non-blocking missing context." + default: + return "Recorded evidence satisfies this section." + } +} + +func readinessQuestionForCheck(check domain.PolicyCheck, id, question, passedAnswer, failedAnswer string) domain.ReadinessQuestion { + if check.Name == "" { + return domain.ReadinessQuestion{ID: id, Question: question, Answer: "No policy check was recorded for this question.", Status: "limited", KnownLimitations: []string{"Policy coverage for this question is not configured."}} + } + if check.Result == "passed" { + return domain.ReadinessQuestion{ID: id, Question: question, Answer: passedAnswer, Status: "passed", Checks: []string{check.Name}} + } + status := "failed_policy" + if len(check.Missing) > 0 { + status = "missing_evidence" + } + return domain.ReadinessQuestion{ID: id, Question: question, Answer: failedAnswer, Status: status, Checks: []string{check.Name}, MissingEvidence: append([]string(nil), check.Missing...), FailedPolicies: []string{check.Name}} +} + +func readinessDecisionQuestion(decisionCount, blockingCount int) domain.ReadinessQuestion { + if blockingCount > 0 { + return domain.ReadinessQuestion{ID: "vex_decisions_for_blockers", Question: "Are VEX or vulnerability decisions present for blocking findings?", Answer: fmt.Sprintf("%d blocking finding(s) still need a valid decision, remediation, or approved exception.", blockingCount), Status: "failed_policy", MissingEvidence: []string{"vulnerability_decision"}, FailedPolicies: []string{"critical_exploitable_blocks_release"}} + } + if decisionCount > 0 { + return domain.ReadinessQuestion{ID: "vex_decisions_for_blockers", Question: "Are VEX or vulnerability decisions present for blocking findings?", Answer: fmt.Sprintf("%d active vulnerability decision(s) are recorded for this release.", decisionCount), Status: "passed", Evidence: []string{"vulnerability_decision"}} + } + return domain.ReadinessQuestion{ID: "vex_decisions_for_blockers", Question: "Are VEX or vulnerability decisions present for blocking findings?", Answer: "No active vulnerability decisions are recorded; no blocking findings currently require one.", Status: "limited", KnownLimitations: []string{"Decision evidence is only expected when findings require triage or customer-visible explanation."}} +} + +func readinessExceptionQuestion(accepted []domain.Exception) domain.ReadinessQuestion { + if len(accepted) > 0 { + return domain.ReadinessQuestion{ID: "approved_exceptions", Question: "Are exceptions approved and unexpired?", Answer: fmt.Sprintf("%d approved unexpired exception(s) are accepted for this release.", len(accepted)), Status: "passed", Evidence: []string{"exception"}} + } + return domain.ReadinessQuestion{ID: "approved_exceptions", Question: "Are exceptions approved and unexpired?", Answer: "No approved unexpired release exceptions are used by this report.", Status: "limited", KnownLimitations: []string{"No exception is needed when policy checks pass without an accepted exception."}} +} + +func readinessCustomerPackageQuestion(hasPackage bool) domain.ReadinessQuestion { + if hasPackage { + return domain.ReadinessQuestion{ID: "customer_package_safe_to_share", Question: "Is a customer package available for review?", Answer: "A non-expired customer package is recorded for this release; review its redaction profile before sharing.", Status: "passed", Evidence: []string{"customer_package"}} + } + return domain.ReadinessQuestion{ID: "customer_package_safe_to_share", Question: "Is a customer package available for review?", Answer: "No customer package was evaluated by this release readiness report.", Status: "limited", KnownLimitations: []string{"Package shareability is evaluated when a scoped customer package is generated."}} +} + +func readinessGapQuestion(missingEvidence, failedPolicies []string) domain.ReadinessQuestion { + if len(missingEvidence) == 0 && len(failedPolicies) == 0 { + return domain.ReadinessQuestion{ID: "remaining_gaps", Question: "What gaps remain?", Answer: "No blocking readiness gaps are recorded by the current policy checks.", Status: "passed"} + } + status := "failed_policy" + if len(failedPolicies) == 0 { + status = "missing_evidence" + } + return domain.ReadinessQuestion{ID: "remaining_gaps", Question: "What gaps remain?", Answer: "The report lists missing evidence and failed policy checks that should be reviewed.", Status: status, MissingEvidence: append([]string(nil), missingEvidence...), FailedPolicies: append([]string(nil), failedPolicies...)} +} + +func (l *Ledger) activeDecisionCountForReleaseLocked(tenantID, releaseID string) int { + count := 0 + for _, decision := range l.decisions { + if decision.TenantID == tenantID && decision.ReleaseID == releaseID && decision.SupersededBy == "" { + count++ + } + } + return count +} + +func (l *Ledger) hasActiveCustomerPackageLocked(tenantID, releaseID string) bool { + for _, pkg := range l.customerPackages { + if pkg.TenantID == tenantID && pkg.ReleaseID == releaseID && pkg.State == "generated" && pkg.ExpiresAt.After(l.now()) { + return true + } + } + return false +} + +func (l *Ledger) checkCustomerVisibleDecisionsHaveStatementsLocked(tenantID, releaseID string) domain.PolicyCheck { + missing := []string{} + for _, decision := range l.decisions { + if decision.TenantID != tenantID || decision.ReleaseID != releaseID || decision.SupersededBy != "" || !decision.CustomerVisible { + continue + } + if strings.TrimSpace(decision.ImpactStatement) == "" { + missing = append(missing, decision.ID) + } + } + if len(missing) > 0 { + sort.Strings(missing) + return domain.PolicyCheck{Name: "customer_visible_decisions_require_statements", Result: "failed", Severity: "high", Missing: missing, Explanation: "customer-visible decisions require an impact statement suitable for package summaries", Remediation: "Create a superseding customer-visible decision with a clear impact statement and no internal-only notes in customer-facing fields."} + } + return domain.PolicyCheck{Name: "customer_visible_decisions_require_statements", Result: "passed", Severity: "high", Explanation: "customer-visible decisions have required impact statements"} +} + +func (l *Ledger) checkNotAffectedDecisionsHaveJustificationLocked(tenantID, releaseID string) domain.PolicyCheck { + missing := []string{} + for _, decision := range l.decisions { + if decision.TenantID != tenantID || decision.ReleaseID != releaseID || decision.SupersededBy != "" || decision.Status != decisionStatusNotAffected { + continue + } + if strings.TrimSpace(decision.Justification) == "" { + missing = append(missing, decision.ID) + } + } + if len(missing) > 0 { + sort.Strings(missing) + return domain.PolicyCheck{Name: "not_affected_decisions_require_justification", Result: "failed", Severity: "high", Missing: missing, Explanation: "not_affected decisions require a recorded justification", Remediation: "Create a superseding not_affected decision with a specific technical justification."} + } + return domain.PolicyCheck{Name: "not_affected_decisions_require_justification", Result: "passed", Severity: "high", Explanation: "not_affected decisions have recorded justifications"} +} + +func (l *Ledger) checkExceptionsCompleteLocked(tenantID, releaseID string) domain.PolicyCheck { + missing := []string{} + for _, exception := range l.exceptions { + if exception.TenantID != tenantID || exception.ReleaseID != releaseID { + continue + } + if strings.TrimSpace(exception.Owner) == "" || strings.TrimSpace(exception.Reason) == "" || exception.ExpiresAt.IsZero() || (exception.Approved && (strings.TrimSpace(exception.ApprovedBy) == "" || exception.ApprovedAt == nil)) { + missing = append(missing, exception.ID) + } + } + if len(missing) > 0 { + sort.Strings(missing) + return domain.PolicyCheck{Name: "exceptions_require_owner_reason_expiry_and_approval", Result: "failed", Severity: "high", Missing: missing, Explanation: "exceptions must include owner, reason, expiry, and approval metadata when approved", Remediation: "Create a complete exception with owner, reason, future expiry, and an audited approval transition."} + } + return domain.PolicyCheck{Name: "exceptions_require_owner_reason_expiry_and_approval", Result: "passed", Severity: "high", Explanation: "release exceptions have required completeness fields"} +} + +func (l *Ledger) checkPackageRedactionProfilesValidLocked(tenantID, releaseID string) domain.PolicyCheck { + missing := []string{} + sawPackage := false + for _, pkg := range l.customerPackages { + if pkg.TenantID != tenantID || pkg.ReleaseID != releaseID { + continue + } + sawPackage = true + profile, ok := l.redactions[pkg.RedactionProfileID] + if !ok || profile.TenantID != tenantID || len(profile.AllowedTypes) == 0 || !redactionProfileExcludesPackageSensitiveFields(profile) || !pkg.ExpiresAt.After(l.now()) { + missing = append(missing, pkg.ID) + } + } + if len(missing) > 0 { + sort.Strings(missing) + return domain.PolicyCheck{Name: "package_redaction_profile_valid", Result: "failed", Severity: "high", Missing: missing, Explanation: "one or more customer packages has an expired package or redaction profile that does not explicitly exclude sensitive fields", Remediation: "Create a new package with the customer_safe or security_review preset, or configure explicit allowed types and sensitive-field exclusions."} + } + if !sawPackage { + return domain.PolicyCheck{Name: "package_redaction_profile_valid", Result: "passed", Severity: "medium", Explanation: "no customer package is recorded for this release; redaction profile validation applies when a package exists"} + } + return domain.PolicyCheck{Name: "package_redaction_profile_valid", Result: "passed", Severity: "high", Explanation: "customer package redaction profiles are explicit and exclude sensitive fields"} +} + +func redactionProfileExcludesPackageSensitiveFields(profile domain.RedactionProfile) bool { + required := map[string]struct{}{"payload_ref": {}, "object_key": {}, "private_key": {}, "token": {}, "secret": {}, "internal_notes": {}} + for _, field := range profile.ExcludedFields { + delete(required, strings.ToLower(strings.TrimSpace(field))) + } + return len(required) == 0 +} + func parseOpenVEX(raw []byte) (openVEXDocument, error) { var doc openVEXDocument dec := json.NewDecoder(bytes.NewReader(raw)) dec.DisallowUnknownFields() if err := dec.Decode(&doc); err != nil { - return openVEXDocument{}, ErrValidation + return openVEXDocument{}, vexValidationError("openvex JSON is malformed or contains unsupported fields") } if err := dec.Decode(&struct{}{}); err != io.EOF { - return openVEXDocument{}, ErrValidation + return openVEXDocument{}, vexValidationError("openvex JSON must contain a single document") + } + doc.Author = strings.TrimSpace(doc.Author) + doc.Timestamp = strings.TrimSpace(doc.Timestamp) + if doc.Author == "" { + return openVEXDocument{}, vexValidationError("openvex author is required") } - if strings.TrimSpace(doc.Author) == "" || strings.TrimSpace(doc.Timestamp) == "" || len(doc.Statements) == 0 { - return openVEXDocument{}, ErrValidation + if doc.Timestamp == "" { + return openVEXDocument{}, vexValidationError("openvex timestamp is required") + } + if len(doc.Statements) == 0 { + return openVEXDocument{}, vexValidationError("openvex must include at least one statement") } if _, err := time.Parse(time.RFC3339, doc.Timestamp); err != nil { - return openVEXDocument{}, ErrValidation + return openVEXDocument{}, vexValidationError("openvex timestamp must be RFC3339") } - for _, statement := range doc.Statements { - if strings.TrimSpace(statement.Vulnerability.Name) == "" || !validDecisionStatus(statement.Status) || strings.TrimSpace(statement.Justification) == "" { - return openVEXDocument{}, ErrValidation + for index := range doc.Statements { + statement := &doc.Statements[index] + statement.Vulnerability.Name = strings.TrimSpace(statement.Vulnerability.Name) + statement.Status = strings.TrimSpace(statement.Status) + statement.Justification = strings.TrimSpace(statement.Justification) + statement.ImpactStatement = strings.TrimSpace(statement.ImpactStatement) + statement.ActionStatement = strings.TrimSpace(statement.ActionStatement) + if statement.Vulnerability.Name == "" { + return openVEXDocument{}, vexStatementValidationError(index+1, "is missing a vulnerability name") + } + if !validDecisionStatus(statement.Status) { + return openVEXDocument{}, vexStatementValidationError(index+1, "has an unsupported status") + } + if statement.Justification == "" { + return openVEXDocument{}, vexStatementValidationError(index+1, "is missing a justification") } if len(statement.Products) == 0 { - return openVEXDocument{}, ErrValidation + return openVEXDocument{}, vexStatementValidationError(index+1, "is missing products") } - for _, product := range statement.Products { - if strings.TrimSpace(product.ID) == "" { - return openVEXDocument{}, ErrValidation - } + if err := validateOpenVEXProducts(index+1, statement.Products); err != nil { + return openVEXDocument{}, err } } return doc, nil } +func validateOpenVEXProducts(statementIndex int, products []openVEXProduct) error { + for index := range products { + products[index].ID = strings.TrimSpace(products[index].ID) + if products[index].ID == "" { + return vexValidationError(fmt.Sprintf("openvex statement %d product %d is missing an @id", statementIndex, index+1)) + } + if err := validateOpenVEXProducts(statementIndex, products[index].Subcomponents); err != nil { + return err + } + } + return nil +} + +func vexStatementValidationError(index int, detail string) error { + return vexValidationError(fmt.Sprintf("openvex statement %d %s", index, detail)) +} + +func vexValidationError(detail string) error { + return fmt.Errorf("%s: %w", detail, ErrValidation) +} + func validDecisionStatus(status string) bool { switch strings.TrimSpace(status) { case decisionStatusAffected, decisionStatusNotAffected, decisionStatusFixed, decisionStatusUnderInvestigation: @@ -381,6 +1103,27 @@ func validDecisionStatus(status string) bool { } } +func validDecisionTransition(from, to string) bool { + to = strings.TrimSpace(to) + if strings.TrimSpace(from) == "" { + return validDecisionStatus(to) + } + allowed, ok := decisionStatusTransitions[strings.TrimSpace(from)] + if !ok { + return false + } + _, ok = allowed[to] + return ok +} + +func decisionStatusSet(statuses ...string) map[string]struct{} { + set := map[string]struct{}{} + for _, status := range statuses { + set[status] = struct{}{} + } + return set +} + func versionString(version any) string { switch v := version.(type) { case string: @@ -419,6 +1162,45 @@ func (l *Ledger) findMatchingFindingsLocked(tenantID, releaseID string, statemen return out } +func (l *Ledger) previewOpenVEXDecisionEffectsLocked(tenantID, releaseID string, statements []openVEXStatement) (int, int, []string, []domain.VEXImportIssue) { + created, superseded := 0, 0 + mappingFailures := []domain.VEXImportIssue{} + warnings := []string{} + createdForFinding := map[string]struct{}{} + duplicateWarningAdded := false + for index, statement := range statements { + matches := l.findMatchingFindingsLocked(tenantID, releaseID, statement) + if len(matches) == 0 { + mappingFailures = append(mappingFailures, vexImportIssue(index+1, "finding_not_found", "No matching vulnerability scan finding was found for this VEX statement.")) + } + added, replaced, duplicate := l.previewDecisionEffectsForMatchesLocked(tenantID, matches, createdForFinding) + created += added + superseded += replaced + if duplicate && !duplicateWarningAdded { + warnings = append(warnings, "Duplicate VEX statements for an already mapped finding were ignored.") + duplicateWarningAdded = true + } + } + return created, superseded, warnings, mappingFailures +} + +func (l *Ledger) previewDecisionEffectsForMatchesLocked(tenantID string, matches []matchedFinding, createdForFinding map[string]struct{}) (int, int, bool) { + created, superseded := 0, 0 + duplicate := false + for _, matched := range matches { + if _, seen := createdForFinding[matched.finding.ID]; seen { + duplicate = true + continue + } + createdForFinding[matched.finding.ID] = struct{}{} + created++ + if _, ok := l.latestDecisionForFindingLocked(tenantID, matched.finding.ID); ok { + superseded++ + } + } + return created, superseded, duplicate +} + func openVEXProductIDs(products []openVEXProduct) map[string]struct{} { out := map[string]struct{}{} var walk func([]openVEXProduct) @@ -436,6 +1218,8 @@ func openVEXProductIDs(products []openVEXProduct) map[string]struct{} { func (l *Ledger) createDecisionLocked(tenantID string, scan domain.VulnerabilityScan, finding domain.VulnerabilityFinding, in CreateVulnerabilityDecisionInput, source, actorID, evidenceID, vexID string) domain.VulnerabilityDecision { decisionID := newID("vd") + createdAt := l.now() + sbomID, sbomComponentPURL, sbomComponentName := l.decisionSBOMContextLocked(tenantID, scan.ReleaseID, finding.Component) var supersedes string for id, existing := range l.decisions { if existing.TenantID == tenantID && existing.FindingID == finding.ID && existing.SupersededBy == "" { @@ -445,28 +1229,299 @@ func (l *Ledger) createDecisionLocked(tenantID string, scan domain.Vulnerability } } decision := domain.VulnerabilityDecision{ - ID: decisionID, - TenantID: tenantID, - FindingID: finding.ID, - ScanID: scan.ID, - ReleaseID: scan.ReleaseID, - Vulnerability: finding.Vulnerability, - Component: finding.Component, - Status: strings.TrimSpace(in.Status), - Justification: strings.TrimSpace(in.Justification), - ImpactStatement: strings.TrimSpace(in.ImpactStatement), - ActionStatement: strings.TrimSpace(in.ActionStatement), - Source: source, - EvidenceID: evidenceID, - VEXDocumentID: vexID, - Supersedes: supersedes, - ApprovedBy: actorID, - SchemaVersion: domain.VulnerabilityDecisionVersion, - CreatedAt: l.now(), + ID: decisionID, + TenantID: tenantID, + FindingID: finding.ID, + ScanID: scan.ID, + ReleaseID: scan.ReleaseID, + Vulnerability: finding.Vulnerability, + Component: finding.Component, + SBOMID: sbomID, + SBOMComponentPURL: sbomComponentPURL, + SBOMComponentName: sbomComponentName, + Status: strings.TrimSpace(in.Status), + Justification: strings.TrimSpace(in.Justification), + ImpactStatement: strings.TrimSpace(in.ImpactStatement), + ActionStatement: strings.TrimSpace(in.ActionStatement), + CustomerVisible: in.CustomerVisible, + InternalNotes: strings.TrimSpace(in.InternalNotes), + Source: source, + EvidenceID: evidenceID, + EvidenceIDs: decisionEvidenceIDs(evidenceID, in.EvidenceIDs), + SupportingRefs: cloneSubjectRefs(in.SupportingRefs), + VEXDocumentID: vexID, + Supersedes: supersedes, + ApprovedBy: actorID, + ReviewedAt: decisionReviewedAt(in.ReviewedAt, createdAt), + ReviewDueAt: cloneTimePtr(in.ReviewDueAt), + SchemaVersion: domain.VulnerabilityDecisionVersion, + CreatedAt: createdAt, } return decision } +func (l *Ledger) decisionSBOMContextLocked(tenantID, releaseID, findingComponent string) (string, string, string) { + findingComponent = strings.TrimSpace(findingComponent) + if releaseID == "" || findingComponent == "" { + return "", "", "" + } + sboms := make([]domain.SBOM, 0, len(l.sboms)) + for _, sbom := range l.sboms { + if sbom.TenantID == tenantID && sbom.ReleaseID == releaseID { + sboms = append(sboms, sbom) + } + } + sort.Slice(sboms, func(i, j int) bool { + if sboms[i].CreatedAt.Equal(sboms[j].CreatedAt) { + return sboms[i].ID < sboms[j].ID + } + return sboms[i].CreatedAt.Before(sboms[j].CreatedAt) + }) + for _, sbom := range sboms { + for _, component := range sbom.Components { + if strings.TrimSpace(component.PURL) != "" && strings.TrimSpace(component.PURL) == findingComponent { + return sbom.ID, strings.TrimSpace(component.PURL), strings.TrimSpace(component.Name) + } + } + } + for _, sbom := range sboms { + for _, component := range sbom.Components { + name := strings.TrimSpace(component.Name) + version := strings.TrimSpace(component.Version) + if name == findingComponent || (version != "" && name+"@"+version == findingComponent) { + return sbom.ID, strings.TrimSpace(component.PURL), name + } + } + } + return "", "", "" +} + +func normalizeDecisionReviewTimes(in *CreateVulnerabilityDecisionInput, now time.Time) error { + now = now.UTC() + var reviewedAt time.Time + if in.ReviewedAt != nil { + if in.ReviewedAt.IsZero() || in.ReviewedAt.After(now.Add(time.Minute)) { + return ErrValidation + } + reviewedAt = in.ReviewedAt.UTC() + in.ReviewedAt = &reviewedAt + } else { + reviewedAt = now + } + if in.ReviewDueAt != nil { + if in.ReviewDueAt.IsZero() { + return ErrValidation + } + dueAt := in.ReviewDueAt.UTC() + if !dueAt.After(reviewedAt) { + return ErrValidation + } + in.ReviewDueAt = &dueAt + } + return nil +} + +func decisionReviewedAt(reviewedAt *time.Time, fallback time.Time) *time.Time { + if reviewedAt != nil { + return cloneTimePtr(reviewedAt) + } + value := fallback.UTC() + return &value +} + +func cloneTimePtr(value *time.Time) *time.Time { + if value == nil { + return nil + } + clone := value.UTC() + return &clone +} + +func (l *Ledger) validateDecisionEvidenceLinksLocked(tenantID, releaseID string, evidenceIDs []string) ([]string, error) { + ids := sortedUniqueNonEmptyStrings(evidenceIDs) + if len(evidenceIDs) != len(ids) { + // Empty values are malformed; duplicate non-empty values are normalized below. + nonEmpty := 0 + for _, id := range evidenceIDs { + if strings.TrimSpace(id) != "" { + nonEmpty++ + } + } + if nonEmpty != len(evidenceIDs) { + return nil, ErrValidation + } + } + for _, id := range ids { + item, ok := l.evidence[id] + if !ok || item.TenantID != tenantID { + return nil, ErrNotFound + } + if item.ReleaseID != "" && releaseID != "" && item.ReleaseID != releaseID { + return nil, ErrNotFound + } + } + return ids, nil +} + +func (l *Ledger) validateDecisionSupportingRefsLocked(tenantID, releaseID string, refs []domain.SubjectRef) ([]domain.SubjectRef, error) { + if len(refs) == 0 { + return nil, nil + } + if releaseID == "" { + return nil, ErrValidation + } + release, ok := l.releases[releaseID] + if !ok || release.TenantID != tenantID { + return nil, ErrNotFound + } + if len(refs) > 20 { + return nil, ErrValidation + } + seen := map[string]struct{}{} + out := make([]domain.SubjectRef, 0, len(refs)) + for _, ref := range refs { + typ := strings.ToLower(strings.TrimSpace(ref.Type)) + id := strings.TrimSpace(ref.ID) + if typ == "" || id == "" || strings.TrimSpace(ref.Digest) != "" { + return nil, ErrValidation + } + normalized := domain.SubjectRef{Type: typ, ID: id} + key := normalized.Type + "\x00" + normalized.ID + if _, ok := seen[key]; ok { + return nil, ErrValidation + } + seen[key] = struct{}{} + if !l.decisionSupportingRefInScopeLocked(tenantID, release.ProductID, releaseID, normalized) { + return nil, ErrNotFound + } + out = append(out, normalized) + } + sort.Slice(out, func(i, j int) bool { + if out[i].Type == out[j].Type { + return out[i].ID < out[j].ID + } + return out[i].Type < out[j].Type + }) + return out, nil +} + +func (l *Ledger) decisionSupportingRefInScopeLocked(tenantID, productID, releaseID string, ref domain.SubjectRef) bool { + switch ref.Type { + case "approval": + approval, ok := l.approvals[ref.ID] + return ok && approval.TenantID == tenantID && l.approvalSupportsReleaseLocked(tenantID, productID, releaseID, approval) + case "exception": + exception, ok := l.exceptions[ref.ID] + return ok && exception.TenantID == tenantID && exception.ReleaseID == releaseID + case "waiver": + waiver, ok := l.waivers[ref.ID] + return ok && waiver.TenantID == tenantID && waiverBelongsToPackage(waiver, productID, releaseID) + case "release_bundle": + bundle, ok := l.bundles[ref.ID] + return ok && bundle.TenantID == tenantID && bundle.ReleaseID == releaseID + case "incident": + incident, ok := l.incidents[ref.ID] + return ok && incident.TenantID == tenantID && incident.ReleaseID == releaseID + case "remediation_task": + task, ok := l.tasks[ref.ID] + if !ok || task.TenantID != tenantID { + return false + } + if task.ReleaseID == releaseID { + return true + } + if task.IncidentID != "" { + incident, ok := l.incidents[task.IncidentID] + return ok && incident.TenantID == tenantID && incident.ReleaseID == releaseID + } + return false + default: + return false + } +} + +func (l *Ledger) approvalSupportsReleaseLocked(tenantID, productID, releaseID string, approval domain.ApprovalRecord) bool { + switch approval.SubjectType { + case "release": + return approval.SubjectID == releaseID + case "waiver": + waiver, ok := l.waivers[approval.SubjectID] + return ok && waiver.TenantID == tenantID && waiverBelongsToPackage(waiver, productID, releaseID) + case "customer_package": + pkg, ok := l.customerPackages[approval.SubjectID] + return ok && pkg.TenantID == tenantID && pkg.ProductID == productID && pkg.ReleaseID == releaseID + case "contract_diff": + diff, ok := l.contractDiffs[approval.SubjectID] + return ok && diff.TenantID == tenantID && diff.ProductID == productID && diff.ReleaseID == releaseID + case "security_review": + doc, ok := l.manualDocs[approval.SubjectID] + return ok && doc.TenantID == tenantID && doc.ProductID == productID && doc.ReleaseID == releaseID && doc.DocumentType == "security_review" + default: + return false + } +} + +func cloneSubjectRefs(refs []domain.SubjectRef) []domain.SubjectRef { + if len(refs) == 0 { + return nil + } + out := make([]domain.SubjectRef, 0, len(refs)) + for _, ref := range refs { + out = append(out, domain.SubjectRef{Type: strings.TrimSpace(ref.Type), ID: strings.TrimSpace(ref.ID), Digest: strings.TrimSpace(ref.Digest)}) + } + return out +} + +func decisionEvidenceIDs(primary string, extra []string) []string { + ids := append([]string(nil), extra...) + if strings.TrimSpace(primary) != "" { + ids = append(ids, strings.TrimSpace(primary)) + } + return sortedUniqueNonEmptyStrings(ids) +} + +func vexImportIssue(statementIndex int, code, detail string) domain.VEXImportIssue { + return domain.VEXImportIssue{StatementIndex: statementIndex, Code: code, Detail: detail} +} + +func vexImportPreviewAssumptions() []string { + return []string{ + "Preview results are computed from currently stored scan findings and active decisions for the requested release.", + "Preview does not store raw VEX payloads, create evidence, create decisions, or enqueue parser jobs.", + } +} + +func vexImportPreviewLimitations() []string { + return []string{ + "Preview is advisory and may change if scans, decisions, exceptions, or releases change before upload.", + "Preview does not prove legal compliance, complete vulnerability coverage, complete SBOM coverage, or release security.", + } +} + +func sortedUniqueNonEmptyStrings(in []string) []string { + set := map[string]struct{}{} + for _, value := range in { + value = strings.TrimSpace(value) + if value == "" { + continue + } + set[value] = struct{}{} + } + out := make([]string, 0, len(set)) + for value := range set { + out = append(out, value) + } + sort.Strings(out) + return out +} + +func (l *Ledger) appendDecisionLifecycleAuditLocked(tenantID string, decision domain.VulnerabilityDecision, findingID, actorTypeValue, actorIDValue, payloadHash string) { + if decision.Supersedes != "" { + _, _ = l.appendChainLocked(tenantID, "vulnerability_decision.superseded", "vulnerability_decision", decision.Supersedes, actorTypeValue, actorIDValue, payloadHash, "") + } + _, _ = l.appendChainLocked(tenantID, "vulnerability_decision.created", "vulnerability_finding", findingID, actorTypeValue, actorIDValue, payloadHash, "") +} + func (l *Ledger) findFindingLocked(tenantID, findingID string) (domain.VulnerabilityScan, domain.VulnerabilityFinding, bool) { for _, scan := range l.scans { if scan.TenantID != tenantID { @@ -512,13 +1567,21 @@ func (l *Ledger) findingHandledLocked(tenantID string, scan domain.Vulnerability } func (l *Ledger) unhandledCriticalFindingsLocked(tenantID, releaseID string) []domain.BlockingFinding { + return l.unhandledFindingsBySeverityLocked(tenantID, releaseID, "critical") +} + +func (l *Ledger) unhandledFindingsBySeverityLocked(tenantID, releaseID string, severities ...string) []domain.BlockingFinding { + allowed := map[string]struct{}{} + for _, severity := range severities { + allowed[strings.ToLower(strings.TrimSpace(severity))] = struct{}{} + } blocking := []domain.BlockingFinding{} for _, scan := range l.scans { if scan.TenantID != tenantID || scan.ReleaseID != releaseID { continue } for _, finding := range scan.Findings { - if strings.ToLower(finding.Severity) != "critical" || strings.ToLower(nonEmpty(finding.State, "open")) != "open" { + if _, ok := allowed[strings.ToLower(finding.Severity)]; !ok || strings.ToLower(nonEmpty(finding.State, "open")) != "open" { continue } if l.findingHandledLocked(tenantID, scan, finding) { @@ -561,7 +1624,7 @@ func (l *Ledger) checkReleaseHasArtifactDigestLocked(tenantID, releaseID string) } } } - return domain.PolicyCheck{Name: "release_requires_artifact_digest", Result: "failed", Severity: "high", Missing: []string{"artifact_digest"}, Explanation: "release artifact digest evidence is missing"} + return domain.PolicyCheck{Name: "release_requires_artifact_digest", Result: "failed", Severity: "high", Missing: []string{"artifact_digest"}, Explanation: "release artifact digest evidence is missing", Remediation: "Register an artifact digest and link it to release evidence."} } func (l *Ledger) checkReleaseHasSignedBundleLocked(tenantID, releaseID string) domain.PolicyCheck { @@ -573,5 +1636,5 @@ func (l *Ledger) checkReleaseHasSignedBundleLocked(tenantID, releaseID string) d return domain.PolicyCheck{Name: "release_requires_signed_bundle", Result: "passed", Severity: "high", Explanation: "signed release bundle exists"} } } - return domain.PolicyCheck{Name: "release_requires_signed_bundle", Result: "failed", Severity: "high", Missing: []string{"signed_release_bundle"}, Explanation: "signed release bundle is missing"} + return domain.PolicyCheck{Name: "release_requires_signed_bundle", Result: "failed", Severity: "high", Missing: []string{"signed_release_bundle"}, Explanation: "signed release bundle is missing", Remediation: "Generate a release bundle after required evidence is recorded."} } diff --git a/internal/app/vex_mapping_test.go b/internal/app/vex_mapping_test.go new file mode 100644 index 0000000..6795a7f --- /dev/null +++ b/internal/app/vex_mapping_test.go @@ -0,0 +1,301 @@ +package app + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "testing" + + "github.com/aatuh/evydence/internal/domain" +) + +func TestOpenVEXStatusMappingFixtures(t *testing.T) { + tests := []struct { + name string + status string + justification string + }{ + {name: "not affected", status: decisionStatusNotAffected, justification: "component_not_present"}, + {name: "affected", status: decisionStatusAffected, justification: "vulnerable_code_present"}, + {name: "fixed", status: decisionStatusFixed, justification: "fixed_in_release"}, + {name: "under investigation", status: decisionStatusUnderInvestigation, justification: "triage_in_progress"}, + } + + for index, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ledger := NewLedger(Config{APIKeyPepper: "test-pepper", Now: fixedNow}) + ctx := context.Background() + actor, release, artifact := setupReleaseRiskFixture(t, ledger) + vulnerability := fmt.Sprintf("CVE-2026-%04d", index+100) + component := fmt.Sprintf("pkg:apk/component-%d@1.0.0", index) + uploadVEXMappingScan(t, ctx, ledger, actor, release.ID, vulnerability, component) + + vex, err := ledger.UploadVEX(ctx, actor, release.ID, artifact.ID, openVEXFixture(t, []map[string]any{ + openVEXStatementFixture(vulnerability, []map[string]any{{"@id": component}}, tt.status, tt.justification), + })) + if err != nil { + t.Fatalf("upload vex: %v", err) + } + + active := true + decisions, err := ledger.ListVulnerabilityDecisions(ctx, actor, ListVulnerabilityDecisionsInput{ + ReleaseID: release.ID, + Vulnerability: vulnerability, + Active: &active, + }) + if err != nil { + t.Fatalf("list decisions: %v", err) + } + if len(decisions) != 1 { + t.Fatalf("decisions = %#v, want one", decisions) + } + decision := decisions[0] + if decision.Status != tt.status || decision.Source != "vex" || decision.VEXDocumentID != vex.ID || decision.Justification != tt.justification || !decision.CustomerVisible { + t.Fatalf("decision = %#v", decision) + } + report, err := ledger.GetVEXImportReport(ctx, actor, vex.ID) + if err != nil { + t.Fatalf("import report: %v", err) + } + if report.StatementCount != 1 || report.DecisionsCreated != 1 || len(report.MappingFailures) != 0 { + t.Fatalf("import report = %#v", report) + } + }) + } +} + +func TestOpenVEXMappingMultipleProductsAndReleaseScope(t *testing.T) { + ledger := NewLedger(Config{APIKeyPepper: "test-pepper", Now: fixedNow}) + ctx := context.Background() + actor, releaseA, artifact := setupReleaseRiskFixture(t, ledger) + releaseB, err := ledger.CreateRelease(ctx, actor, releaseA.ProductID, "2.0.0") + if err != nil { + t.Fatalf("release B: %v", err) + } + vulnerability := "CVE-2026-0200" + componentA := "pkg:oci/payments-api@sha256:a" + componentB := "pkg:oci/payments-api@sha256:b" + uploadVEXMappingScan(t, ctx, ledger, actor, releaseA.ID, vulnerability, componentA) + uploadVEXMappingScan(t, ctx, ledger, actor, releaseB.ID, vulnerability, componentB) + + vex, err := ledger.UploadVEX(ctx, actor, releaseA.ID, artifact.ID, openVEXFixture(t, []map[string]any{ + openVEXStatementFixture(vulnerability, []map[string]any{ + {"@id": "pkg:oci/aggregate", "subcomponents": []map[string]any{{"@id": componentA}}}, + {"@id": componentB}, + }, decisionStatusFixed, "fixed_in_release"), + })) + if err != nil { + t.Fatalf("upload vex: %v", err) + } + + active := true + releaseADecisions, err := ledger.ListVulnerabilityDecisions(ctx, actor, ListVulnerabilityDecisionsInput{ReleaseID: releaseA.ID, Vulnerability: vulnerability, Active: &active}) + if err != nil { + t.Fatalf("list release A decisions: %v", err) + } + releaseBDecisions, err := ledger.ListVulnerabilityDecisions(ctx, actor, ListVulnerabilityDecisionsInput{ReleaseID: releaseB.ID, Vulnerability: vulnerability, Active: &active}) + if err != nil { + t.Fatalf("list release B decisions: %v", err) + } + if len(releaseADecisions) != 1 || releaseADecisions[0].Component != componentA { + t.Fatalf("release A decisions = %#v", releaseADecisions) + } + if len(releaseBDecisions) != 0 { + t.Fatalf("VEX uploaded for release A created release B decisions: %#v", releaseBDecisions) + } + report, err := ledger.GetVEXImportReport(ctx, actor, vex.ID) + if err != nil { + t.Fatalf("import report: %v", err) + } + if report.DecisionsCreated != 1 || len(report.MappingFailures) != 0 { + t.Fatalf("import report = %#v", report) + } +} + +func TestOpenVEXDuplicateStatementIsIdempotentWithinImport(t *testing.T) { + ledger := NewLedger(Config{APIKeyPepper: "test-pepper", Now: fixedNow}) + ctx := context.Background() + actor, release, artifact := setupReleaseRiskFixture(t, ledger) + vulnerability := "CVE-2026-0300" + component := "pkg:apk/openssl@3.1.0" + uploadVEXMappingScan(t, ctx, ledger, actor, release.ID, vulnerability, component) + statement := openVEXStatementFixture(vulnerability, []map[string]any{{"@id": component}}, decisionStatusFixed, "fixed_in_release") + + vex, err := ledger.UploadVEX(ctx, actor, release.ID, artifact.ID, openVEXFixture(t, []map[string]any{statement, statement})) + if err != nil { + t.Fatalf("upload vex: %v", err) + } + active := true + decisions, err := ledger.ListVulnerabilityDecisions(ctx, actor, ListVulnerabilityDecisionsInput{ReleaseID: release.ID, Vulnerability: vulnerability, Active: &active}) + if err != nil { + t.Fatalf("list active decisions: %v", err) + } + if len(decisions) != 1 { + t.Fatalf("active decisions = %#v, want one", decisions) + } + inactive := false + superseded, err := ledger.ListVulnerabilityDecisions(ctx, actor, ListVulnerabilityDecisionsInput{ReleaseID: release.ID, Vulnerability: vulnerability, Active: &inactive}) + if err != nil { + t.Fatalf("list superseded decisions: %v", err) + } + if len(superseded) != 0 { + t.Fatalf("duplicate statement should not create superseded duplicate versions: %#v", superseded) + } + report, err := ledger.GetVEXImportReport(ctx, actor, vex.ID) + if err != nil { + t.Fatalf("import report: %v", err) + } + if report.DecisionsCreated != 1 || report.DecisionsSuperseded != 0 || !stringSliceContains(report.Warnings, "Duplicate VEX statements") { + t.Fatalf("import report = %#v", report) + } +} + +func TestOpenVEXMappingSupersedesExistingDecisionFixture(t *testing.T) { + ledger := NewLedger(Config{APIKeyPepper: "test-pepper", Now: fixedNow}) + ctx := context.Background() + actor, release, artifact := setupReleaseRiskFixture(t, ledger) + vulnerability := "CVE-2026-0400" + component := "pkg:apk/openssl@3.1.0" + scan := uploadVEXMappingScan(t, ctx, ledger, actor, release.ID, vulnerability, component) + if _, err := ledger.CreateVulnerabilityDecision(ctx, actor, scan.Findings[0].ID, CreateVulnerabilityDecisionInput{ + Status: decisionStatusUnderInvestigation, + Justification: "manual_triage", + ImpactStatement: "Manual triage is in progress.", + }); err != nil { + t.Fatalf("manual decision: %v", err) + } + + vex, err := ledger.UploadVEX(ctx, actor, release.ID, artifact.ID, openVEXFixture(t, []map[string]any{ + openVEXStatementFixture(vulnerability, []map[string]any{{"@id": component}}, decisionStatusFixed, "fixed_in_release"), + })) + if err != nil { + t.Fatalf("upload vex: %v", err) + } + active := true + activeDecisions, err := ledger.ListVulnerabilityDecisions(ctx, actor, ListVulnerabilityDecisionsInput{ReleaseID: release.ID, Vulnerability: vulnerability, Active: &active}) + if err != nil { + t.Fatalf("list active decisions: %v", err) + } + inactive := false + superseded, err := ledger.ListVulnerabilityDecisions(ctx, actor, ListVulnerabilityDecisionsInput{ReleaseID: release.ID, Vulnerability: vulnerability, Active: &inactive}) + if err != nil { + t.Fatalf("list superseded decisions: %v", err) + } + if len(activeDecisions) != 1 || activeDecisions[0].Status != decisionStatusFixed || len(superseded) != 1 || superseded[0].Status != decisionStatusUnderInvestigation { + t.Fatalf("active=%#v superseded=%#v", activeDecisions, superseded) + } + report, err := ledger.GetVEXImportReport(ctx, actor, vex.ID) + if err != nil { + t.Fatalf("import report: %v", err) + } + if report.DecisionsCreated != 1 || report.DecisionsSuperseded != 1 { + t.Fatalf("import report = %#v", report) + } +} + +func TestOpenVEXValidationFixturesReturnUsefulSafeErrors(t *testing.T) { + ledger := NewLedger(Config{APIKeyPepper: "test-pepper", Now: fixedNow}) + ctx := context.Background() + actor, release, artifact := setupReleaseRiskFixture(t, ledger) + tests := []struct { + name string + payload []byte + wantDetail string + notContain string + }{ + { + name: "unknown status", + payload: openVEXFixture(t, []map[string]any{ + openVEXStatementFixture("CVE-2026-0500", []map[string]any{{"@id": "pkg:apk/openssl@3.1.0"}}, "secret-token-status", "triage"), + }), + wantDetail: "openvex statement 1 has an unsupported status", + notContain: "secret-token-status", + }, + { + name: "component identity missing", + payload: openVEXFixture(t, []map[string]any{ + openVEXStatementFixture("CVE-2026-0501", []map[string]any{{"@id": ""}}, decisionStatusFixed, "fixed"), + }), + wantDetail: "openvex statement 1 product 1 is missing an @id", + }, + { + name: "malformed document", + payload: []byte(`{"author":"security@example.test"`), + wantDetail: "openvex JSON is malformed or contains unsupported fields", + }, + { + name: "missing vulnerability", + payload: openVEXFixture(t, []map[string]any{ + openVEXStatementFixture("", []map[string]any{{"@id": "pkg:apk/openssl@3.1.0"}}, decisionStatusFixed, "fixed"), + }), + wantDetail: "openvex statement 1 is missing a vulnerability name", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := ledger.UploadVEX(ctx, actor, release.ID, artifact.ID, tt.payload) + if !errors.Is(err, ErrValidation) { + t.Fatalf("err = %v, want validation", err) + } + if !strings.Contains(err.Error(), tt.wantDetail) { + t.Fatalf("err = %q, want detail %q", err, tt.wantDetail) + } + if tt.notContain != "" && strings.Contains(err.Error(), tt.notContain) { + t.Fatalf("error leaked raw invalid value %q: %v", tt.notContain, err) + } + }) + } +} + +func uploadVEXMappingScan(t *testing.T, ctx context.Context, ledger *Ledger, actor domain.Actor, releaseID, vulnerability, component string) domain.VulnerabilityScan { + t.Helper() + scan, err := ledger.UploadVulnerabilityScan(ctx, actor, []byte(`{ + "scanner":"grype", + "target_ref":"`+component+`", + "release_id":"`+releaseID+`", + "findings":[{"vulnerability":"`+vulnerability+`","component":"`+component+`","severity":"critical","state":"open"}] + }`)) + if err != nil { + t.Fatalf("upload scan: %v", err) + } + return scan +} + +func openVEXFixture(t *testing.T, statements []map[string]any) []byte { + t.Helper() + body, err := json.Marshal(map[string]any{ + "@context": "https://openvex.dev/ns/v0.2.0", + "@id": "https://example.test/vex/fixture", + "author": "security@example.test", + "timestamp": "2026-05-27T12:00:00Z", + "version": 1, + "statements": statements, + }) + if err != nil { + t.Fatalf("marshal VEX fixture: %v", err) + } + return body +} + +func openVEXStatementFixture(vulnerability string, products []map[string]any, status, justification string) map[string]any { + return map[string]any{ + "vulnerability": map[string]any{"name": vulnerability}, + "products": products, + "status": status, + "justification": justification, + "impact_statement": "Decision detail suitable for customer review.", + "action_statement": "Review the linked evidence before relying on this decision.", + } +} + +func stringSliceContains(values []string, needle string) bool { + for _, value := range values { + if strings.Contains(value, needle) { + return true + } + } + return false +} diff --git a/internal/app/workflows.go b/internal/app/workflows.go new file mode 100644 index 0000000..1d12a42 --- /dev/null +++ b/internal/app/workflows.go @@ -0,0 +1,305 @@ +package app + +import ( + "context" + "strings" + + "github.com/aatuh/evydence/internal/domain" +) + +func (l *Ledger) ReleaseEvidenceFlowPlan(ctx context.Context, actor domain.Actor, releaseID string) (domain.ReleaseEvidenceFlow, error) { + return l.releaseEvidenceService().ReleaseEvidenceFlowPlan(ctx, actor, releaseID) +} + +func (l *Ledger) ReleaseSecuritySummary(ctx context.Context, actor domain.Actor, releaseID string) (domain.ReleaseSecuritySummary, error) { + return l.releaseEvidenceService().ReleaseSecuritySummary(ctx, actor, releaseID) +} + +func (s releaseEvidenceService) ReleaseEvidenceFlowPlan(ctx context.Context, actor domain.Actor, releaseID string) (domain.ReleaseEvidenceFlow, error) { + l := s.ledger + if err := ctx.Err(); err != nil { + return domain.ReleaseEvidenceFlow{}, err + } + if err := require(actor, ScopeReleaseRead); err != nil { + return domain.ReleaseEvidenceFlow{}, err + } + releaseID = strings.TrimSpace(releaseID) + if releaseID == "" { + return domain.ReleaseEvidenceFlow{}, ErrValidation + } + l.mu.Lock() + defer l.mu.Unlock() + release, ok := l.releases[releaseID] + if !ok || release.TenantID != actor.TenantID { + return domain.ReleaseEvidenceFlow{}, ErrNotFound + } + if err := l.authorizeResourceLocked(actor, ScopeReleaseRead, resourceRefs{ProductID: release.ProductID, ReleaseID: release.ID}); err != nil { + return domain.ReleaseEvidenceFlow{}, err + } + counts := releaseEvidenceFlowCountsLocked(l, actor.TenantID, release.ID) + steps := []domain.ReleaseEvidenceFlowStep{ + releaseEvidenceFlowStep("artifact_digest", "Register artifact digest", counts["artifact_refs"] > 0, true, "POST", "/v1/artifacts", []string{ScopeEvidenceWrite}, "Register the release artifact digest or upload build output metadata that references the artifact."), + releaseEvidenceFlowStep("build_provenance", "Record build provenance", counts["passed_builds"] > 0, true, "POST", "/v1/builds", []string{ScopeBuildWrite}, "Record CI build metadata, commit identity, and output digests for the release."), + releaseEvidenceFlowStep("sbom", "Upload SBOM", counts["sboms"] > 0, true, "POST", "/v1/sboms", []string{ScopeEvidenceWrite}, "Upload CycloneDX or SPDX SBOM evidence linked to the release and artifact where available."), + releaseEvidenceFlowStep("vulnerability_scan", "Upload vulnerability scan", counts["vulnerability_scans"] > 0, true, "POST", "/v1/vulnerability-scans", []string{ScopeEvidenceWrite}, "Upload generic vulnerability scan evidence for review and decision workflows."), + releaseEvidenceFlowStep("vex_or_decisions", "Record VEX or decisions", counts["vex_documents"]+counts["vulnerability_decisions"] > 0, false, "POST", "/v1/vex", []string{ScopeEvidenceWrite}, "Upload OpenVEX/CycloneDX VEX or create manual vulnerability decisions for relevant findings."), + releaseEvidenceFlowStep("release_bundle", "Create release bundle", counts["release_bundles"] > 0, true, "POST", "/v1/release-bundles", []string{ScopeBundleWrite}, "Create an immutable release bundle after the required evidence is present."), + releaseEvidenceFlowStep("readiness", "Read release readiness", true, true, "GET", "/v1/reports/release-readiness?release_id="+release.ID, []string{ScopeVerifyRead}, "Review deterministic policy checks, gaps, assumptions, exceptions, and limitations."), + releaseEvidenceFlowStep("customer_package", "Create customer package", counts["customer_packages"] > 0, false, "POST", "/v1/customer-packages", []string{ScopePackageWrite}, "Create a scoped customer-safe package only after redaction profile review."), + } + status := "ready_for_review" + for _, step := range steps { + if step.Required && step.Status != "present" { + status = "needs_evidence" + break + } + } + return domain.ReleaseEvidenceFlow{ + ReleaseID: release.ID, + ProductID: release.ProductID, + Status: status, + Counts: counts, + Steps: steps, + Assumptions: []string{ + "Workflow steps describe Evydence API evidence collection and do not replace CI provider, scanner, or operator review.", + "Scanner and SBOM uploads are recorded as evidence with limitations, not as complete or authoritative coverage.", + }, + Limitations: []string{ + "This workflow plan does not make legal compliance conclusions, grant certification, or guarantee release security.", + "Artifact-to-release association is inferred from release-linked SBOMs, build outputs, attestations, and uploaded evidence references.", + }, + SchemaVersion: domain.ReleaseEvidenceFlowVersion, + GeneratedAt: l.now(), + }, nil +} + +func releaseEvidenceFlowCountsLocked(l *Ledger, tenantID, releaseID string) map[string]int { + counts := map[string]int{ + "artifact_refs": 0, + "passed_builds": 0, + "build_attestations": 0, + "sboms": 0, + "vulnerability_scans": 0, + "vex_documents": 0, + "vulnerability_decisions": 0, + "release_bundles": 0, + "customer_packages": 0, + } + artifactRefs := map[string]struct{}{} + for _, sbom := range l.sboms { + if sbom.TenantID == tenantID && sbom.ReleaseID == releaseID { + counts["sboms"]++ + if sbom.ArtifactID != "" { + artifactRefs[sbom.ArtifactID] = struct{}{} + } + } + } + for _, scan := range l.scans { + if scan.TenantID == tenantID && scan.ReleaseID == releaseID { + counts["vulnerability_scans"]++ + } + } + for _, vex := range l.vexDocuments { + if vex.TenantID == tenantID && vex.ReleaseID == releaseID { + counts["vex_documents"]++ + if vex.ArtifactID != "" { + artifactRefs[vex.ArtifactID] = struct{}{} + } + } + } + for _, decision := range l.decisions { + if decision.TenantID == tenantID && decision.ReleaseID == releaseID && decision.SupersededBy == "" { + counts["vulnerability_decisions"]++ + } + } + for _, build := range l.buildRuns { + if build.TenantID == tenantID && build.ReleaseID == releaseID { + if build.Status == "passed" { + counts["passed_builds"]++ + } + for _, output := range build.Outputs { + if output.ArtifactID != "" { + artifactRefs[output.ArtifactID] = struct{}{} + } + } + } + } + for _, attestation := range l.attestations { + build, ok := l.buildRuns[attestation.BuildID] + if ok && attestation.TenantID == tenantID && build.ReleaseID == releaseID { + counts["build_attestations"]++ + } + } + for _, bundle := range l.bundles { + if bundle.TenantID == tenantID && bundle.ReleaseID == releaseID { + counts["release_bundles"]++ + } + } + for _, pkg := range l.customerPackages { + if pkg.TenantID == tenantID && pkg.ReleaseID == releaseID { + counts["customer_packages"]++ + } + } + counts["artifact_refs"] = len(artifactRefs) + return counts +} + +func releaseEvidenceFlowStep(id, title string, present, required bool, method, path string, scopes []string, description string) domain.ReleaseEvidenceFlowStep { + status := "missing" + if present { + status = "present" + } else if !required { + status = "optional" + } + return domain.ReleaseEvidenceFlowStep{ + ID: id, + Title: title, + Status: status, + Required: required, + Method: method, + Path: path, + RequiredScopes: scopes, + IdempotencyRequired: method == "POST", + Description: description, + NextReference: path, + } +} + +func (s releaseEvidenceService) ReleaseSecuritySummary(ctx context.Context, actor domain.Actor, releaseID string) (domain.ReleaseSecuritySummary, error) { + l := s.ledger + if err := ctx.Err(); err != nil { + return domain.ReleaseSecuritySummary{}, err + } + if err := require(actor, ScopeReportRead); err != nil { + return domain.ReleaseSecuritySummary{}, err + } + releaseID = strings.TrimSpace(releaseID) + if releaseID == "" { + return domain.ReleaseSecuritySummary{}, ErrValidation + } + l.mu.Lock() + defer l.mu.Unlock() + release, ok := l.releases[releaseID] + if !ok || release.TenantID != actor.TenantID { + return domain.ReleaseSecuritySummary{}, ErrNotFound + } + product, ok := l.products[release.ProductID] + if !ok || product.TenantID != actor.TenantID { + return domain.ReleaseSecuritySummary{}, ErrNotFound + } + if err := l.authorizeResourceLocked(actor, ScopeReportRead, resourceRefs{ProductID: release.ProductID, ReleaseID: release.ID}); err != nil { + return domain.ReleaseSecuritySummary{}, err + } + counts := releaseEvidenceFlowCountsLocked(l, actor.TenantID, release.ID) + openBySeverity, decisionsByStatus := releaseSecurityFindingDecisionCountsLocked(l, actor.TenantID, release.ID) + missing := []domain.ReleaseSecurityMissingDecision{} + for _, finding := range l.unhandledFindingsBySeverityLocked(actor.TenantID, release.ID, "critical", "high") { + missing = append(missing, domain.ReleaseSecurityMissingDecision{ + FindingID: finding.FindingID, + ScanID: finding.ScanID, + Vulnerability: finding.Vulnerability, + Component: finding.Component, + Severity: finding.Severity, + State: nonEmpty(finding.State, "open"), + }) + } + checks := l.releasePolicyChecksLocked(actor.TenantID, release.ID) + readiness := releasePolicyResult(checks) + packageStatus := "not_generated" + if counts["customer_packages"] > 0 { + packageStatus = "generated" + } + return domain.ReleaseSecuritySummary{ + Product: domain.ReleaseSecurityProductSummary{ + ID: product.ID, Name: product.Name, Slug: product.Slug, + }, + Release: domain.ReleaseSecurityReleaseSummary{ + ID: release.ID, Version: release.Version, State: release.State, + }, + ArtifactCount: counts["artifact_refs"], + SBOMStatus: presentMissingStatus(counts["sboms"] > 0), + VulnerabilityScanStatus: presentMissingStatus(counts["vulnerability_scans"] > 0), + OpenFindingsBySeverity: openBySeverity, + DecisionsByStatus: decisionsByStatus, + MissingRequiredDecisions: missing, + ApprovalSummary: releaseSecurityApprovalSummaryLocked(l, actor.TenantID, release.ID), + ExceptionSummary: releaseSecurityExceptionSummaryLocked(l, actor.TenantID, release.ID), + ReadinessStatus: readiness, + PackageStatus: packageStatus, + Counts: counts, + Assumptions: []string{ + "Summary values are derived only from evidence, decisions, exceptions, approvals, bundles, packages, and build records in this Evydence tenant.", + "Open finding counts reflect uploaded scanner evidence and recorded decisions or exceptions; scanner results are not treated as complete or authoritative coverage.", + }, + Limitations: []string{ + "This summary supports technical review and compliance readiness, not legal compliance conclusions, certification, or release security guarantees.", + "Raw SBOM, scanner, VEX, build, and package payload bytes are intentionally excluded from the summary.", + }, + SchemaVersion: domain.ReleaseSecuritySummaryVersion, + GeneratedAt: l.now(), + }, nil +} + +func releaseSecurityFindingDecisionCountsLocked(l *Ledger, tenantID, releaseID string) (map[string]int, map[string]int) { + openBySeverity := map[string]int{} + for _, scan := range l.scans { + if scan.TenantID != tenantID || scan.ReleaseID != releaseID { + continue + } + for _, finding := range scan.Findings { + if strings.ToLower(nonEmpty(finding.State, "open")) != "open" { + continue + } + openBySeverity[strings.ToLower(nonEmpty(finding.Severity, "unknown"))]++ + } + } + decisionsByStatus := map[string]int{} + for _, decision := range l.decisions { + if decision.TenantID == tenantID && decision.ReleaseID == releaseID && decision.SupersededBy == "" { + decisionsByStatus[decision.Status]++ + } + } + return openBySeverity, decisionsByStatus +} + +func releaseSecurityApprovalSummaryLocked(l *Ledger, tenantID, releaseID string) domain.ReleaseSecurityApprovalSummary { + summary := domain.ReleaseSecurityApprovalSummary{} + for _, approval := range l.approvals { + if approval.TenantID != tenantID || approval.SubjectType != "release" || approval.SubjectID != releaseID { + continue + } + summary.Total++ + if approval.Decision == "approved" { + summary.Approved++ + } + } + return summary +} + +func releaseSecurityExceptionSummaryLocked(l *Ledger, tenantID, releaseID string) domain.ReleaseSecurityExceptionSummary { + summary := domain.ReleaseSecurityExceptionSummary{} + now := l.now() + for _, exception := range l.exceptions { + if exception.TenantID != tenantID || exception.ReleaseID != releaseID { + continue + } + summary.Total++ + if !exception.ExpiresAt.After(now) { + summary.Expired++ + continue + } + if exception.Approved { + summary.ApprovedUnexpired++ + } else { + summary.Unapproved++ + } + } + return summary +} + +func presentMissingStatus(present bool) string { + if present { + return "present" + } + return "missing" +} diff --git a/internal/domain/domain.go b/internal/domain/domain.go index b1d9193..213608f 100644 --- a/internal/domain/domain.go +++ b/internal/domain/domain.go @@ -3,77 +3,97 @@ package domain import "time" const ( - EvidenceItemSchemaVersion = "evidence-item.v1.0.0" - AuditChainEntrySchemaVersion = "audit-chain-entry.v1.0.0" - ReleaseBundleSchemaVersion = "release-bundle.v1.0.0" - CanonicalizationProfileVersion = "canonicalization-profile.v1.0.0" - PolicySetVersion = "policy-set.v1.0.0" - VEXDocumentSchemaVersion = "vex-document.v1.0.0" - VulnerabilityDecisionVersion = "vulnerability-decision.v1.0.0" - ReleaseReadinessTemplateVersion = "release-readiness.v1.0.0" - CollectorSchemaVersion = "collector.v1.0.0" - BuildRunSchemaVersion = "build-run.v1.0.0" - BuildAttestationSchemaVersion = "build-attestation.v1.0.0" - ControlFrameworkSchemaVersion = "control-framework.v1.0.0" - SecurityControlSchemaVersion = "security-control.v1.0.0" - ControlEvidenceSchemaVersion = "control-evidence.v1.0.0" - ControlCoverageTemplateVersion = "control-coverage.v1.0.0" - CRAReadinessTemplateVersion = "cra-readiness.v1.0.0" - EvidenceLifecycleSchemaVersion = "evidence-lifecycle-event.v1.0.0" - ReleaseCandidateSchemaVersion = "release-candidate.v1.0.0" - ContainerImageSchemaVersion = "container-image.v1.0.0" - ArtifactSignatureSchemaVersion = "artifact-signature.v1.0.0" - SourceRepositorySchemaVersion = "source-repository.v1.0.0" - SourceCommitSchemaVersion = "source-commit.v1.0.0" - SourceBranchSchemaVersion = "source-branch.v1.0.0" - PullRequestSchemaVersion = "pull-request.v1.0.0" - DeploymentEnvironmentVersion = "deployment-environment.v1.0.0" - DeploymentEventSchemaVersion = "deployment-event.v1.0.0" - IncidentSchemaVersion = "incident.v1.0.0" - IncidentTimelineSchemaVersion = "incident-timeline-event.v1.0.0" - RemediationTaskSchemaVersion = "remediation-task.v1.0.0" - SecurityScanSchemaVersion = "security-scan.v1.0.0" - ManualSecurityDocSchemaVersion = "manual-security-document.v1.0.0" - SBOMDiffSchemaVersion = "sbom-diff.v1.0.0" - DependencyChangeSchemaVersion = "dependency-change.v1.0.0" - ContractDiffSchemaVersion = "contract-diff.v1.0.0" - CustomPolicySchemaVersion = "custom-policy.v1.0.0" - CustomPolicyEvalSchemaVersion = "custom-policy-evaluation.v1.0.0" - WaiverSchemaVersion = "waiver.v1.0.0" - ApprovalRecordSchemaVersion = "approval-record.v1.0.0" - RedactionProfileSchemaVersion = "redaction-profile.v1.0.0" - CustomerPackageSchemaVersion = "customer-security-package.v1.0.0" - ReportTemplateSchemaVersion = "report-template.v1.0.0" - EvidenceBundleSchemaVersion = "evidence-bundle.v1.0.0" - EvidenceBundleImportVersion = "evidence-bundle-import.v1.0.0" - DSSETrustRootSchemaVersion = "dsse-trust-root.v1.0.0" - CosignVerificationSchemaVersion = "cosign-verification.v1.0.0" - SigningProviderSchemaVersion = "signing-provider.v1.0.0" - MerkleBatchSchemaVersion = "merkle-batch.v1.0.0" - TransparencyCheckpointVersion = "transparency-checkpoint.v1.0.0" - ObjectRetentionPolicyVersion = "object-retention-policy.v1.0.0" - BackupManifestSchemaVersion = "backup-manifest.v1.0.0" - CollectorReleaseSchemaVersion = "collector-release.v1.0.0" - OrganizationSchemaVersion = "organization.v1.0.0" - HumanUserSchemaVersion = "human-user.v1.0.0" - RoleBindingSchemaVersion = "role-binding.v1.0.0" - SSOProviderSchemaVersion = "sso-provider.v1.0.0" - SSOSessionSchemaVersion = "sso-session.v1.0.0" - LegalHoldSchemaVersion = "legal-hold.v1.0.0" - RetentionOverrideSchemaVersion = "retention-override.v1.0.0" - CustomerPortalAccessVersion = "customer-portal-access.v1.0.0" - QuestionnaireTemplateVersion = "questionnaire-template.v1.0.0" - QuestionnairePackageVersion = "questionnaire-package.v1.0.0" - CommercialCollectorVersion = "commercial-collector.v1.0.0" + EvidenceItemSchemaVersion = "evidence-item.v1.0.0" + AuditChainEntrySchemaVersion = "audit-chain-entry.v1.0.0" + ReleaseBundleSchemaVersion = "release-bundle.v1.0.0" + ReleaseEvidenceFlowVersion = "release-evidence-flow.v1.0.0" + ReleaseSecuritySummaryVersion = "release-security-summary.v1.0.0" + CanonicalizationProfileVersion = "canonicalization-profile.v1.0.0" + PolicySetVersion = "policy-set.v1.0.0" + VEXDocumentSchemaVersion = "vex-document.v1.0.0" + VEXImportReportSchemaVersion = "vex-import-report.v1.0.0" + VEXImportPreviewSchemaVersion = "vex-import-preview.v1.0.0" + VulnerabilityDecisionVersion = "vulnerability-decision.v1.0.0" + ReleaseReadinessTemplateVersion = "release-readiness.v1.0.0" + CollectorSchemaVersion = "collector.v1.0.0" + BuildRunSchemaVersion = "build-run.v1.0.0" + BuildAttestationSchemaVersion = "build-attestation.v1.0.0" + ControlFrameworkSchemaVersion = "control-framework.v1.0.0" + SecurityControlSchemaVersion = "security-control.v1.0.0" + ControlEvidenceSchemaVersion = "control-evidence.v1.0.0" + ControlCoverageTemplateVersion = "control-coverage.v1.0.0" + CRAReadinessTemplateVersion = "cra-readiness.v1.0.0" + EvidenceLifecycleSchemaVersion = "evidence-lifecycle-event.v1.0.0" + ReleaseCandidateSchemaVersion = "release-candidate.v1.0.0" + ContainerImageSchemaVersion = "container-image.v1.0.0" + ArtifactSignatureSchemaVersion = "artifact-signature.v1.0.0" + SourceRepositorySchemaVersion = "source-repository.v1.0.0" + SourceCommitSchemaVersion = "source-commit.v1.0.0" + SourceBranchSchemaVersion = "source-branch.v1.0.0" + PullRequestSchemaVersion = "pull-request.v1.0.0" + DeploymentEnvironmentVersion = "deployment-environment.v1.0.0" + DeploymentEventSchemaVersion = "deployment-event.v1.0.0" + IncidentSchemaVersion = "incident.v1.0.0" + IncidentTimelineSchemaVersion = "incident-timeline-event.v1.0.0" + IncidentWebhookReceiverVersion = "incident-webhook-receiver.v1.0.0" + IncidentWebhookEventVersion = "incident-webhook-event.v1.0.0" + RemediationTaskSchemaVersion = "remediation-task.v1.0.0" + SecurityScanSchemaVersion = "security-scan.v1.0.0" + ManualSecurityDocSchemaVersion = "manual-security-document.v1.0.0" + SBOMDiffSchemaVersion = "sbom-diff.v1.0.0" + DependencyChangeSchemaVersion = "dependency-change.v1.0.0" + ContractDiffSchemaVersion = "contract-diff.v1.0.0" + CustomPolicySchemaVersion = "custom-policy.v1.0.0" + CustomPolicyEvalSchemaVersion = "custom-policy-evaluation.v1.0.0" + WaiverSchemaVersion = "waiver.v1.0.0" + ApprovalRecordSchemaVersion = "approval-record.v1.0.0" + RedactionProfileSchemaVersion = "redaction-profile.v1.0.0" + CustomerPackageSchemaVersion = "customer-security-package.v2.0.0" + ReportTemplateSchemaVersion = "report-template.v1.0.0" + EvidenceBundleSchemaVersion = "evidence-bundle.v1.0.0" + EvidenceBundleImportVersion = "evidence-bundle-import.v1.0.0" + DSSETrustRootSchemaVersion = "dsse-trust-root.v1.0.0" + CosignVerificationSchemaVersion = "cosign-verification.v1.0.0" + SigningProviderSchemaVersion = "signing-provider.v1.0.0" + MerkleBatchSchemaVersion = "merkle-batch.v1.0.0" + TransparencyCheckpointVersion = "transparency-checkpoint.v1.0.0" + ObjectRetentionPolicyVersion = "object-retention-policy.v1.0.0" + BackupManifestSchemaVersion = "backup-manifest.v1.0.0" + CollectorReleaseSchemaVersion = "collector-release.v1.0.0" + OrganizationSchemaVersion = "organization.v1.0.0" + HumanUserSchemaVersion = "human-user.v1.0.0" + RoleBindingSchemaVersion = "role-binding.v1.0.0" + SSOProviderSchemaVersion = "sso-provider.v1.0.0" + SSOSessionSchemaVersion = "sso-session.v1.0.0" + LegalHoldSchemaVersion = "legal-hold.v1.0.0" + RetentionOverrideSchemaVersion = "retention-override.v1.0.0" + CustomerPortalAccessVersion = "customer-portal-access.v1.0.0" + QuestionnaireTemplateVersion = "questionnaire-template.v1.0.0" + QuestionnairePackageVersion = "questionnaire-package.v1.0.0" + QuestionnaireAnswerLibraryVersion = "questionnaire-answer-library.v1.0.0" + CommercialCollectorVersion = "commercial-collector.v1.0.0" + EvidenceSummaryVersion = "evidence-summary.v1.0.0" + QuestionnaireDraftVersion = "questionnaire-draft.v1.0.0" + EvidenceGraphSnapshotVersion = "evidence-graph-snapshot.v1.0.0" + SaaSEditionProfileVersion = "saas-edition-profile.v1.0.0" + PublicTransparencyLogVersion = "public-transparency-log.v1.0.0" + PublicTransparencyEntryVersion = "public-transparency-entry.v1.0.0" + MarketplaceCollectorVersion = "marketplace-collector.v1.0.0" + PDFReportPackageVersion = "pdf-report-package.v1.0.0" + AnomalyReportVersion = "anomaly-report.v1.0.0" + ProviderVerificationVersion = "provider-verification.v1.0.0" + SigningOperationVersion = "signing-operation.v1.0.0" ) type Actor struct { - TenantID string - KeyID string - UserID string - Name string - Scopes []string - CollectorID string + TenantID string + KeyID string + UserID string + SessionID string + Name string + Scopes []string + CollectorID string + ResourceGrants []ResourceGrant } func (a Actor) HasScope(scope string) bool { @@ -85,6 +105,13 @@ func (a Actor) HasScope(scope string) bool { return false } +type ResourceGrant struct { + Role string + ResourceType string + ResourceID string + Scopes []string +} + type Tenant struct { ID string `json:"id"` Name string `json:"name"` @@ -126,17 +153,20 @@ type RoleBinding struct { } type SSOProvider struct { - ID string `json:"id"` - TenantID string `json:"tenant_id"` - Name string `json:"name"` - Type string `json:"type"` - Issuer string `json:"issuer"` - ClientID string `json:"client_id"` - GroupsClaim string `json:"groups_claim,omitempty"` - RoleMapping map[string]string `json:"role_mapping,omitempty"` - Status string `json:"status"` - SchemaVersion string `json:"schema_version"` - CreatedAt time.Time `json:"created_at"` + ID string `json:"id"` + TenantID string `json:"tenant_id"` + Name string `json:"name"` + Type string `json:"type"` + Issuer string `json:"issuer"` + ClientID string `json:"client_id"` + GroupsClaim string `json:"groups_claim,omitempty"` + RoleMapping map[string]string `json:"role_mapping,omitempty"` + JWKS map[string]any `json:"jwks,omitempty"` + SAMLSigningCertificates []string `json:"saml_signing_certificates,omitempty"` + TrustMaterialUpdatedAt *time.Time `json:"trust_material_updated_at,omitempty"` + Status string `json:"status"` + SchemaVersion string `json:"schema_version"` + CreatedAt time.Time `json:"created_at"` } type UserIdentityLink struct { @@ -157,6 +187,7 @@ type SSOSession struct { UserID string `json:"user_id"` ProviderID string `json:"provider_id"` Prefix string `json:"prefix"` + Groups []string `json:"groups,omitempty"` ExpiresAt time.Time `json:"expires_at"` RevokedAt *time.Time `json:"revoked_at,omitempty"` SchemaVersion string `json:"schema_version"` @@ -249,6 +280,84 @@ type Release struct { ApprovedAt *time.Time `json:"approved_at,omitempty"` } +type ReleaseEvidenceFlow struct { + ReleaseID string `json:"release_id"` + ProductID string `json:"product_id"` + Status string `json:"status"` + Counts map[string]int `json:"counts"` + Steps []ReleaseEvidenceFlowStep `json:"steps"` + Assumptions []string `json:"assumptions"` + Limitations []string `json:"limitations"` + SchemaVersion string `json:"schema_version"` + GeneratedAt time.Time `json:"generated_at"` +} + +type ReleaseEvidenceFlowStep struct { + ID string `json:"id"` + Title string `json:"title"` + Status string `json:"status"` + Required bool `json:"required"` + Method string `json:"method"` + Path string `json:"path"` + RequiredScopes []string `json:"required_scopes"` + IdempotencyRequired bool `json:"idempotency_required"` + Description string `json:"description"` + NextReference string `json:"next_reference,omitempty"` +} + +type ReleaseSecuritySummary struct { + Product ReleaseSecurityProductSummary `json:"product"` + Release ReleaseSecurityReleaseSummary `json:"release"` + ArtifactCount int `json:"artifact_count"` + SBOMStatus string `json:"sbom_status"` + VulnerabilityScanStatus string `json:"vulnerability_scan_status"` + OpenFindingsBySeverity map[string]int `json:"open_findings_by_severity"` + DecisionsByStatus map[string]int `json:"decisions_by_status"` + MissingRequiredDecisions []ReleaseSecurityMissingDecision `json:"missing_required_decisions,omitempty"` + ApprovalSummary ReleaseSecurityApprovalSummary `json:"approval_summary"` + ExceptionSummary ReleaseSecurityExceptionSummary `json:"exception_summary"` + ReadinessStatus string `json:"readiness_status"` + PackageStatus string `json:"package_status"` + Counts map[string]int `json:"counts"` + Assumptions []string `json:"assumptions"` + Limitations []string `json:"limitations"` + SchemaVersion string `json:"schema_version"` + GeneratedAt time.Time `json:"generated_at"` +} + +type ReleaseSecurityProductSummary struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` +} + +type ReleaseSecurityReleaseSummary struct { + ID string `json:"id"` + Version string `json:"version"` + State string `json:"state"` +} + +type ReleaseSecurityMissingDecision struct { + FindingID string `json:"finding_id"` + ScanID string `json:"scan_id"` + Vulnerability string `json:"vulnerability"` + Component string `json:"component,omitempty"` + Severity string `json:"severity"` + State string `json:"state"` +} + +type ReleaseSecurityApprovalSummary struct { + Total int `json:"total"` + Approved int `json:"approved"` +} + +type ReleaseSecurityExceptionSummary struct { + Total int `json:"total"` + ApprovedUnexpired int `json:"approved_unexpired"` + Unapproved int `json:"unapproved"` + Expired int `json:"expired"` +} + type Artifact struct { ID string `json:"id"` TenantID string `json:"tenant_id"` @@ -567,6 +676,35 @@ type CRAReadinessReport struct { GeneratedAt time.Time `json:"generated_at"` } +type CRAVulnerabilityHandlingReport struct { + ReportType string `json:"report_type"` + TemplateVersion string `json:"template_version"` + ProductID string `json:"product_id"` + ReleaseID string `json:"release_id"` + Summary map[string]int `json:"summary"` + Decisions []VulnerabilityDecisionCustomerSummary `json:"decisions,omitempty"` + AcceptedExceptions []Exception `json:"accepted_exceptions,omitempty"` + EvidenceIDs []string `json:"evidence_ids,omitempty"` + Assumptions []string `json:"assumptions"` + Limitations []string `json:"limitations"` + GeneratedAt time.Time `json:"generated_at"` +} + +type SecurityUpdateEvidenceReport struct { + ReportType string `json:"report_type"` + TemplateVersion string `json:"template_version"` + ProductID string `json:"product_id"` + ReleaseID string `json:"release_id"` + Summary map[string]int `json:"summary"` + FixedDecisions []VulnerabilityDecisionCustomerSummary `json:"fixed_decisions,omitempty"` + Incidents []Incident `json:"incidents,omitempty"` + RemediationTasks []RemediationTask `json:"remediation_tasks,omitempty"` + EvidenceIDs []string `json:"evidence_ids,omitempty"` + Assumptions []string `json:"assumptions"` + Limitations []string `json:"limitations"` + GeneratedAt time.Time `json:"generated_at"` +} + type AuditChainEntry struct { ID string `json:"id"` TenantID string `json:"tenant_id"` @@ -667,16 +805,32 @@ type TransparencyCheckpoint struct { } type ObjectRetentionPolicy struct { - ID string `json:"id"` - TenantID string `json:"tenant_id"` - Name string `json:"name"` - ObjectPrefix string `json:"object_prefix"` - Mode string `json:"mode"` - RetentionDays int `json:"retention_days"` - Status string `json:"status"` - VerifiedAt *time.Time `json:"verified_at,omitempty"` - SchemaVersion string `json:"schema_version"` - CreatedAt time.Time `json:"created_at"` + ID string `json:"id"` + TenantID string `json:"tenant_id"` + Name string `json:"name"` + ObjectPrefix string `json:"object_prefix"` + ObjectKey string `json:"object_key,omitempty"` + RequireLegalHold bool `json:"require_legal_hold,omitempty"` + Mode string `json:"mode"` + RetentionDays int `json:"retention_days"` + Status string `json:"status"` + VerifiedAt *time.Time `json:"verified_at,omitempty"` + VerificationHash string `json:"verification_hash,omitempty"` + VerificationChecks []VerifyCheck `json:"verification_checks,omitempty"` + VerificationLimitations []string `json:"verification_limitations,omitempty"` + SchemaVersion string `json:"schema_version"` + CreatedAt time.Time `json:"created_at"` +} + +type SigningCustodyReviewReport struct { + ReportType string `json:"report_type"` + TenantID string `json:"tenant_id"` + SigningProviders []SigningProvider `json:"signing_providers,omitempty"` + ObjectRetentionPolicies []ObjectRetentionPolicy `json:"object_retention_policies,omitempty"` + Checks []VerifyCheck `json:"checks"` + Assumptions []string `json:"assumptions"` + Limitations []string `json:"limitations"` + GeneratedAt time.Time `json:"generated_at"` } type BackupManifest struct { @@ -733,17 +887,26 @@ type RetentionReport struct { } type CustomerPortalAccess struct { - ID string `json:"id"` - TenantID string `json:"tenant_id"` - PackageID string `json:"package_id"` - CustomerName string `json:"customer_name"` - Prefix string `json:"prefix"` - ExpiresAt time.Time `json:"expires_at"` - RevokedAt *time.Time `json:"revoked_at,omitempty"` - AccessCount int `json:"access_count"` - SchemaVersion string `json:"schema_version"` - CreatedAt time.Time `json:"created_at"` - Hash string `json:"-"` + ID string `json:"id"` + TenantID string `json:"tenant_id"` + PackageID string `json:"package_id"` + CustomerName string `json:"customer_name"` + ReviewerName string `json:"reviewer_name,omitempty"` + ReviewerEmail string `json:"reviewer_email,omitempty"` + RequireNDA bool `json:"require_nda"` + NDAAcceptedAt *time.Time `json:"nda_accepted_at,omitempty"` + NDAAcceptedBy string `json:"nda_accepted_by,omitempty"` + Watermark string `json:"watermark,omitempty"` + Prefix string `json:"prefix"` + ExpiresAt time.Time `json:"expires_at"` + RevokedAt *time.Time `json:"revoked_at,omitempty"` + AccessCount int `json:"access_count"` + FailedAccessCount int `json:"failed_access_count"` + LastAccessedAt *time.Time `json:"last_accessed_at,omitempty"` + LastFailedAt *time.Time `json:"last_failed_at,omitempty"` + SchemaVersion string `json:"schema_version"` + CreatedAt time.Time `json:"created_at"` + Hash string `json:"-"` } type QuestionnaireTemplate struct { @@ -784,6 +947,21 @@ type QuestionnaireResponse struct { Limitations []string `json:"limitations,omitempty"` } +type QuestionnaireAnswerLibraryEntry struct { + ID string `json:"id"` + TenantID string `json:"tenant_id"` + QuestionID string `json:"question_id,omitempty"` + EvidenceType string `json:"evidence_type,omitempty"` + ControlID string `json:"control_id,omitempty"` + ProductID string `json:"product_id,omitempty"` + ReleaseID string `json:"release_id,omitempty"` + Answer string `json:"answer"` + EvidenceIDs []string `json:"evidence_ids,omitempty"` + Limitations []string `json:"limitations,omitempty"` + SchemaVersion string `json:"schema_version"` + CreatedAt time.Time `json:"created_at"` +} + type CommercialCollectorDefinition struct { ID string `json:"id"` TenantID string `json:"tenant_id"` @@ -797,6 +975,200 @@ type CommercialCollectorDefinition struct { CreatedAt time.Time `json:"created_at"` } +type EvidenceCitation struct { + EvidenceID string `json:"evidence_id"` + Type string `json:"type"` + Title string `json:"title"` + CanonicalHash string `json:"canonical_hash"` +} + +type EvidenceSummary struct { + ID string `json:"id"` + TenantID string `json:"tenant_id"` + SubjectType string `json:"subject_type"` + SubjectID string `json:"subject_id"` + EvidenceIDs []string `json:"evidence_ids"` + Summary string `json:"summary"` + Citations []EvidenceCitation `json:"citations"` + Assumptions []string `json:"assumptions"` + Limitations []string `json:"limitations"` + SchemaVersion string `json:"schema_version"` + CreatedAt time.Time `json:"created_at"` +} + +type QuestionnaireDraft struct { + ID string `json:"id"` + TenantID string `json:"tenant_id"` + TemplateID string `json:"template_id"` + ProductID string `json:"product_id,omitempty"` + ReleaseID string `json:"release_id,omitempty"` + Responses []QuestionnaireResponse `json:"responses"` + ManifestHash string `json:"manifest_hash"` + Limitations []string `json:"limitations"` + SchemaVersion string `json:"schema_version"` + CreatedAt time.Time `json:"created_at"` +} + +type GraphNode struct { + ID string `json:"id"` + Type string `json:"type"` + Label string `json:"label"` +} + +type GraphEdge struct { + From string `json:"from"` + To string `json:"to"` + Relationship string `json:"relationship"` +} + +type EvidenceGraphSnapshot struct { + ID string `json:"id"` + TenantID string `json:"tenant_id"` + ProductID string `json:"product_id,omitempty"` + ReleaseID string `json:"release_id,omitempty"` + Nodes []GraphNode `json:"nodes"` + Edges []GraphEdge `json:"edges"` + GraphHash string `json:"graph_hash"` + Limitations []string `json:"limitations"` + SchemaVersion string `json:"schema_version"` + CreatedAt time.Time `json:"created_at"` +} + +type SaaSEditionProfile struct { + ID string `json:"id"` + TenantID string `json:"tenant_id"` + Name string `json:"name"` + Region string `json:"region"` + AdminTenantID string `json:"admin_tenant_id"` + IsolationModel string `json:"isolation_model"` + Status string `json:"status"` + ConfigHash string `json:"config_hash"` + Limitations []string `json:"limitations"` + SchemaVersion string `json:"schema_version"` + CreatedAt time.Time `json:"created_at"` +} + +type PublicTransparencyLog struct { + ID string `json:"id"` + TenantID string `json:"tenant_id"` + Name string `json:"name"` + Endpoint string `json:"endpoint"` + PublicKey string `json:"public_key"` + State string `json:"state"` + SchemaVersion string `json:"schema_version"` + CreatedAt time.Time `json:"created_at"` +} + +type PublicTransparencyLogEntry struct { + ID string `json:"id"` + TenantID string `json:"tenant_id"` + LogID string `json:"log_id"` + CheckpointID string `json:"checkpoint_id"` + MerkleBatchID string `json:"merkle_batch_id"` + ExternalID string `json:"external_id"` + EntryHash string `json:"entry_hash"` + InclusionRootHash string `json:"inclusion_root_hash,omitempty"` + InclusionProofHash string `json:"inclusion_proof_hash,omitempty"` + InclusionVerifiedAt *time.Time `json:"inclusion_verified_at,omitempty"` + VerificationChecks []VerifyCheck `json:"verification_checks,omitempty"` + VerificationLimitations []string `json:"verification_limitations,omitempty"` + State string `json:"state"` + SchemaVersion string `json:"schema_version"` + CreatedAt time.Time `json:"created_at"` +} + +type MarketplaceCollector struct { + ID string `json:"id"` + TenantID string `json:"tenant_id"` + Name string `json:"name"` + Provider string `json:"provider"` + Version string `json:"version"` + Publisher string `json:"publisher"` + ManifestHash string `json:"manifest_hash"` + SignatureID string `json:"signature_id,omitempty"` + SBOMID string `json:"sbom_id,omitempty"` + ScanID string `json:"scan_id,omitempty"` + State string `json:"state"` + Limitations []string `json:"limitations,omitempty"` + SchemaVersion string `json:"schema_version"` + CreatedAt time.Time `json:"created_at"` +} + +type MarketplaceCollectorHealthReport struct { + ReportType string `json:"report_type"` + CollectorID string `json:"collector_id"` + Name string `json:"name"` + Provider string `json:"provider"` + Version string `json:"version"` + SupplyChainStatus string `json:"supply_chain_status"` + Checks []VerifyCheck `json:"checks"` + Collector MarketplaceCollector `json:"collector"` + Assumptions []string `json:"assumptions"` + Limitations []string `json:"limitations"` + GeneratedAt time.Time `json:"generated_at"` +} + +type PDFReportPackage struct { + ID string `json:"id"` + TenantID string `json:"tenant_id"` + ReportType string `json:"report_type"` + ProductID string `json:"product_id,omitempty"` + ReleaseID string `json:"release_id,omitempty"` + Title string `json:"title"` + PayloadRef string `json:"payload_ref,omitempty"` + PayloadHash string `json:"payload_hash"` + PayloadSize int64 `json:"payload_size"` + Limitations []string `json:"limitations"` + SchemaVersion string `json:"schema_version"` + CreatedAt time.Time `json:"created_at"` +} + +type AnomalySignal struct { + Name string `json:"name"` + Severity string `json:"severity"` + Detail string `json:"detail"` +} + +type AnomalyReport struct { + ID string `json:"id"` + TenantID string `json:"tenant_id"` + SubjectType string `json:"subject_type"` + SubjectID string `json:"subject_id"` + Result string `json:"result"` + Signals []AnomalySignal `json:"signals,omitempty"` + Assumptions []string `json:"assumptions"` + Limitations []string `json:"limitations"` + SchemaVersion string `json:"schema_version"` + CreatedAt time.Time `json:"created_at"` +} + +type ProviderVerification struct { + ID string `json:"id"` + TenantID string `json:"tenant_id"` + ProviderType string `json:"provider_type"` + ProviderID string `json:"provider_id"` + Subject string `json:"subject"` + Result string `json:"result"` + Checks []VerifyCheck `json:"checks"` + Limitations []string `json:"limitations"` + SchemaVersion string `json:"schema_version"` + CreatedAt time.Time `json:"created_at"` +} + +type SigningOperation struct { + ID string `json:"id"` + TenantID string `json:"tenant_id"` + ProviderID string `json:"provider_id"` + SubjectType string `json:"subject_type"` + SubjectID string `json:"subject_id"` + PayloadHash string `json:"payload_hash"` + SignatureRef string `json:"signature_ref,omitempty"` + Result string `json:"result"` + Checks []VerifyCheck `json:"checks"` + SchemaVersion string `json:"schema_version"` + CreatedAt time.Time `json:"created_at"` +} + type SBOM struct { ID string `json:"id"` TenantID string `json:"tenant_id"` @@ -816,6 +1188,15 @@ type SBOMComponent struct { PURL string `json:"purl,omitempty"` } +type SBOMComponentRecord struct { + SBOMID string `json:"sbom_id"` + ReleaseID string `json:"release_id,omitempty"` + ArtifactID string `json:"artifact_id,omitempty"` + Format string `json:"format"` + SpecVersion string `json:"spec_version"` + Component SBOMComponent `json:"component"` +} + type VulnerabilityScan struct { ID string `json:"id"` TenantID string `json:"tenant_id"` @@ -851,38 +1232,142 @@ type VEXDocument struct { CreatedAt time.Time `json:"created_at"` } +type VEXImportIssue struct { + StatementIndex int `json:"statement_index,omitempty"` + Code string `json:"code"` + Detail string `json:"detail"` +} + +type VEXImportReport struct { + ID string `json:"id"` + TenantID string `json:"tenant_id"` + VEXDocumentID string `json:"vex_document_id"` + EvidenceID string `json:"evidence_id"` + ReleaseID string `json:"release_id,omitempty"` + ArtifactID string `json:"artifact_id,omitempty"` + ParserVersion string `json:"parser_version"` + Status string `json:"status"` + StatementCount int `json:"statement_count"` + DecisionsCreated int `json:"decisions_created"` + DecisionsSuperseded int `json:"decisions_superseded"` + UnsupportedFields []string `json:"unsupported_fields,omitempty"` + Warnings []string `json:"warnings,omitempty"` + InvalidStatements []VEXImportIssue `json:"invalid_statements,omitempty"` + MappingFailures []VEXImportIssue `json:"mapping_failures,omitempty"` + FailureCode string `json:"failure_code,omitempty"` + FailureDetail string `json:"failure_detail,omitempty"` + SchemaVersion string `json:"schema_version"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type VEXImportPreview struct { + TenantID string `json:"tenant_id"` + ReleaseID string `json:"release_id"` + ArtifactID string `json:"artifact_id,omitempty"` + Format string `json:"format"` + ParserVersion string `json:"parser_version"` + Advisory bool `json:"advisory"` + StatementCount int `json:"statement_count"` + StatusSummary map[string]int `json:"status_summary"` + DecisionsWouldCreate int `json:"decisions_would_create"` + DecisionsWouldSupersede int `json:"decisions_would_supersede"` + Warnings []string `json:"warnings,omitempty"` + InvalidStatements []VEXImportIssue `json:"invalid_statements,omitempty"` + MappingFailures []VEXImportIssue `json:"mapping_failures,omitempty"` + Assumptions []string `json:"assumptions"` + Limitations []string `json:"limitations"` + SchemaVersion string `json:"schema_version"` + GeneratedAt time.Time `json:"generated_at"` +} + type VulnerabilityDecision struct { - ID string `json:"id"` - TenantID string `json:"tenant_id"` - FindingID string `json:"finding_id"` - ScanID string `json:"scan_id"` - ReleaseID string `json:"release_id,omitempty"` - Vulnerability string `json:"vulnerability"` - Component string `json:"component,omitempty"` - Status string `json:"status"` - Justification string `json:"justification"` - ImpactStatement string `json:"impact_statement,omitempty"` - ActionStatement string `json:"action_statement,omitempty"` - Source string `json:"source"` - EvidenceID string `json:"evidence_id,omitempty"` - VEXDocumentID string `json:"vex_document_id,omitempty"` - Supersedes string `json:"supersedes,omitempty"` - SupersededBy string `json:"superseded_by,omitempty"` - ApprovedBy string `json:"approved_by,omitempty"` - SchemaVersion string `json:"schema_version"` - CreatedAt time.Time `json:"created_at"` + ID string `json:"id"` + TenantID string `json:"tenant_id"` + FindingID string `json:"finding_id"` + ScanID string `json:"scan_id"` + ReleaseID string `json:"release_id,omitempty"` + Vulnerability string `json:"vulnerability"` + Component string `json:"component,omitempty"` + SBOMID string `json:"sbom_id,omitempty"` + SBOMComponentPURL string `json:"sbom_component_purl,omitempty"` + SBOMComponentName string `json:"sbom_component_name,omitempty"` + Status string `json:"status"` + Justification string `json:"justification"` + ImpactStatement string `json:"impact_statement,omitempty"` + ActionStatement string `json:"action_statement,omitempty"` + CustomerVisible bool `json:"customer_visible"` + InternalNotes string `json:"internal_notes,omitempty"` + Source string `json:"source"` + EvidenceID string `json:"evidence_id,omitempty"` + EvidenceIDs []string `json:"evidence_ids,omitempty"` + SupportingRefs []SubjectRef `json:"supporting_refs,omitempty"` + VEXDocumentID string `json:"vex_document_id,omitempty"` + Supersedes string `json:"supersedes,omitempty"` + SupersededBy string `json:"superseded_by,omitempty"` + ApprovedBy string `json:"approved_by,omitempty"` + ReviewedAt *time.Time `json:"reviewed_at,omitempty"` + ReviewDueAt *time.Time `json:"review_due_at,omitempty"` + SchemaVersion string `json:"schema_version"` + CreatedAt time.Time `json:"created_at"` +} + +type VulnerabilityDecisionCustomerSummary struct { + ID string `json:"id"` + FindingID string `json:"finding_id"` + ScanID string `json:"scan_id"` + ReleaseID string `json:"release_id"` + Vulnerability string `json:"vulnerability"` + Component string `json:"component,omitempty"` + SBOMID string `json:"sbom_id,omitempty"` + SBOMComponentPURL string `json:"sbom_component_purl,omitempty"` + SBOMComponentName string `json:"sbom_component_name,omitempty"` + Status string `json:"status"` + Justification string `json:"justification,omitempty"` + ImpactStatement string `json:"impact_statement"` + ActionStatement string `json:"action_statement,omitempty"` + Source string `json:"source"` + EvidenceID string `json:"evidence_id,omitempty"` + EvidenceIDs []string `json:"evidence_ids,omitempty"` + SupportingRefs []SubjectRef `json:"supporting_refs,omitempty"` + VEXDocumentID string `json:"vex_document_id,omitempty"` + ReviewedAt *time.Time `json:"reviewed_at,omitempty"` + ReviewDueAt *time.Time `json:"review_due_at,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +type VulnerabilityDecisionSummaryReport struct { + ReportType string `json:"report_type"` + TemplateVersion string `json:"template_version"` + ProductID string `json:"product_id"` + ReleaseID string `json:"release_id"` + Decisions []VulnerabilityDecisionCustomerSummary `json:"decisions"` + Assumptions []string `json:"assumptions"` + Limitations []string `json:"limitations"` + GeneratedAt time.Time `json:"generated_at"` } type OpenAPIContract struct { - ID string `json:"id"` - TenantID string `json:"tenant_id"` - ProductID string `json:"product_id"` - ReleaseID string `json:"release_id,omitempty"` - Version string `json:"version"` - Hash string `json:"hash"` - PathCount int `json:"path_count"` - EvidenceID string `json:"evidence_id"` - CreatedAt time.Time `json:"created_at"` + ID string `json:"id"` + TenantID string `json:"tenant_id"` + ProductID string `json:"product_id"` + ReleaseID string `json:"release_id,omitempty"` + Version string `json:"version"` + Hash string `json:"hash"` + PathCount int `json:"path_count"` + Operations []OpenAPIOperation `json:"operations,omitempty"` + EvidenceID string `json:"evidence_id"` + CreatedAt time.Time `json:"created_at"` +} + +type OpenAPIOperation struct { + Path string `json:"path"` + Method string `json:"method"` + OperationID string `json:"operation_id,omitempty"` + Deprecated bool `json:"deprecated,omitempty"` + RequestBodyRequired bool `json:"request_body_required,omitempty"` + RequiredRequestFields []string `json:"required_request_fields,omitempty"` + ResponseStatuses []string `json:"response_statuses,omitempty"` } type PolicyEvaluation struct { @@ -901,6 +1386,7 @@ type PolicyCheck struct { Severity string `json:"severity"` Missing []string `json:"missing,omitempty"` Explanation string `json:"explanation"` + Remediation string `json:"remediation,omitempty"` } type Exception struct { @@ -948,18 +1434,52 @@ type VerifyCheck struct { } type ReleaseReadinessReport struct { - ReportType string `json:"report_type"` - TemplateVersion string `json:"template_version"` - ReleaseID string `json:"release_id"` - Result string `json:"result"` - Checks []PolicyCheck `json:"checks"` - BlockingFindings []BlockingFinding `json:"blocking_findings,omitempty"` - AcceptedExceptions []Exception `json:"accepted_exceptions,omitempty"` - Gaps []string `json:"gaps,omitempty"` - Assumptions []string `json:"assumptions"` - Limitations []string `json:"limitations"` - Metadata map[string]any `json:"metadata,omitempty"` - GeneratedAt time.Time `json:"generated_at"` + ReportType string `json:"report_type"` + TemplateVersion string `json:"template_version"` + ReleaseID string `json:"release_id"` + Result string `json:"result"` + PolicySet string `json:"policy_set,omitempty"` + Summary ReadinessSummary `json:"summary,omitempty"` + Checks []PolicyCheck `json:"checks"` + Sections []ReadinessSection `json:"sections,omitempty"` + BlockingFindings []BlockingFinding `json:"blocking_findings,omitempty"` + AcceptedExceptions []Exception `json:"accepted_exceptions,omitempty"` + Gaps []string `json:"gaps,omitempty"` + MissingEvidence []string `json:"missing_evidence,omitempty"` + FailedPolicies []string `json:"failed_policies,omitempty"` + KnownLimitations []string `json:"known_limitations,omitempty"` + NonClaims []string `json:"non_claims,omitempty"` + Assumptions []string `json:"assumptions"` + Limitations []string `json:"limitations"` + Metadata map[string]any `json:"metadata,omitempty"` + GeneratedAt time.Time `json:"generated_at"` +} + +type ReadinessSummary struct { + Headline string `json:"headline"` + Result string `json:"result"` + HumanSummary string `json:"human_summary"` + PolicySet string `json:"policy_set,omitempty"` +} + +type ReadinessSection struct { + ID string `json:"id"` + Title string `json:"title"` + Status string `json:"status"` + Summary string `json:"summary"` + Questions []ReadinessQuestion `json:"questions"` +} + +type ReadinessQuestion struct { + ID string `json:"id"` + Question string `json:"question"` + Answer string `json:"answer"` + Status string `json:"status"` + Evidence []string `json:"evidence,omitempty"` + Checks []string `json:"checks,omitempty"` + MissingEvidence []string `json:"missing_evidence,omitempty"` + FailedPolicies []string `json:"failed_policies,omitempty"` + KnownLimitations []string `json:"known_limitations,omitempty"` } type BlockingFinding struct { @@ -1023,6 +1543,33 @@ type IncidentTimelineEvent struct { CreatedAt time.Time `json:"created_at"` } +type IncidentWebhookReceiver struct { + ID string `json:"id"` + TenantID string `json:"tenant_id"` + IncidentID string `json:"incident_id"` + Name string `json:"name"` + Provider string `json:"provider"` + PublicKey string `json:"public_key"` + Status string `json:"status"` + SchemaVersion string `json:"schema_version"` + CreatedAt time.Time `json:"created_at"` +} + +type IncidentWebhookEvent struct { + ID string `json:"id"` + TenantID string `json:"tenant_id"` + ReceiverID string `json:"receiver_id"` + IncidentID string `json:"incident_id"` + Provider string `json:"provider"` + EventID string `json:"event_id"` + PayloadHash string `json:"payload_hash"` + SignatureHash string `json:"signature_hash"` + TimelineEventID string `json:"timeline_event_id,omitempty"` + Result string `json:"result"` + SchemaVersion string `json:"schema_version"` + CreatedAt time.Time `json:"created_at"` +} + type RemediationTask struct { ID string `json:"id"` TenantID string `json:"tenant_id"` @@ -1222,19 +1769,20 @@ type RedactionProfile struct { } type CustomerSecurityPackage struct { - ID string `json:"id"` - TenantID string `json:"tenant_id"` - ProductID string `json:"product_id"` - ReleaseID string `json:"release_id,omitempty"` - RedactionProfileID string `json:"redaction_profile_id"` - Title string `json:"title"` - State string `json:"state"` - Manifest map[string]any `json:"manifest"` - ManifestHash string `json:"manifest_hash"` - ExpiresAt time.Time `json:"expires_at"` - AccessCount int `json:"access_count"` - SchemaVersion string `json:"schema_version"` - CreatedAt time.Time `json:"created_at"` + ID string `json:"id"` + TenantID string `json:"tenant_id"` + ProductID string `json:"product_id"` + ReleaseID string `json:"release_id,omitempty"` + RedactionProfileID string `json:"redaction_profile_id"` + Title string `json:"title"` + State string `json:"state"` + Manifest map[string]any `json:"manifest"` + ManifestHash string `json:"manifest_hash"` + DistributionWatermark string `json:"distribution_watermark,omitempty"` + ExpiresAt time.Time `json:"expires_at"` + AccessCount int `json:"access_count"` + SchemaVersion string `json:"schema_version"` + CreatedAt time.Time `json:"created_at"` } type SecurityReviewPackageReport struct { diff --git a/internal/domain/domain_test.go b/internal/domain/domain_test.go new file mode 100644 index 0000000..35848ba --- /dev/null +++ b/internal/domain/domain_test.go @@ -0,0 +1,15 @@ +package domain + +import "testing" + +func TestActorHasScopeSupportsExactAndWildcardScopes(t *testing.T) { + if !((Actor{Scopes: []string{"evidence:write"}}).HasScope("evidence:write")) { + t.Fatal("exact scope should be accepted") + } + if !((Actor{Scopes: []string{"*"}}).HasScope("controls:admin")) { + t.Fatal("wildcard scope should be accepted") + } + if (Actor{Scopes: []string{"evidence:read"}}).HasScope("evidence:write") { + t.Fatal("ungranted scope should be rejected") + } +} diff --git a/migrations/20260528000800_customer_portal_access_counters.down.sql b/migrations/20260528000800_customer_portal_access_counters.down.sql new file mode 100644 index 0000000..8e0f93d --- /dev/null +++ b/migrations/20260528000800_customer_portal_access_counters.down.sql @@ -0,0 +1,4 @@ +ALTER TABLE customer_portal_access + DROP COLUMN IF EXISTS last_failed_at, + DROP COLUMN IF EXISTS last_accessed_at, + DROP COLUMN IF EXISTS failed_access_count; diff --git a/migrations/20260528000800_customer_portal_access_counters.up.sql b/migrations/20260528000800_customer_portal_access_counters.up.sql new file mode 100644 index 0000000..fa2f020 --- /dev/null +++ b/migrations/20260528000800_customer_portal_access_counters.up.sql @@ -0,0 +1,4 @@ +ALTER TABLE customer_portal_access + ADD COLUMN IF NOT EXISTS failed_access_count integer NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS last_accessed_at timestamptz, + ADD COLUMN IF NOT EXISTS last_failed_at timestamptz; diff --git a/migrations/20260528000900_future_extensions_and_partial_closures.down.sql b/migrations/20260528000900_future_extensions_and_partial_closures.down.sql new file mode 100644 index 0000000..fdebaf1 --- /dev/null +++ b/migrations/20260528000900_future_extensions_and_partial_closures.down.sql @@ -0,0 +1,14 @@ +DROP TABLE IF EXISTS signing_operations; +DROP TABLE IF EXISTS provider_verifications; +DROP TABLE IF EXISTS anomaly_reports; +DROP TABLE IF EXISTS pdf_report_packages; +DROP TABLE IF EXISTS marketplace_collectors; +DROP TABLE IF EXISTS public_transparency_log_entries; +DROP TABLE IF EXISTS public_transparency_logs; +DROP TABLE IF EXISTS saas_edition_profiles; +DROP TABLE IF EXISTS evidence_graph_snapshots; +DROP TABLE IF EXISTS questionnaire_drafts; +DROP TABLE IF EXISTS evidence_summaries; + +ALTER TABLE object_retention_policies + DROP COLUMN IF EXISTS verification_hash; diff --git a/migrations/20260528000900_future_extensions_and_partial_closures.up.sql b/migrations/20260528000900_future_extensions_and_partial_closures.up.sql new file mode 100644 index 0000000..d2c3143 --- /dev/null +++ b/migrations/20260528000900_future_extensions_and_partial_closures.up.sql @@ -0,0 +1,174 @@ +ALTER TABLE object_retention_policies + ADD COLUMN IF NOT EXISTS verification_hash text; + +CREATE TABLE IF NOT EXISTS evidence_summaries ( + id text PRIMARY KEY, + tenant_id text NOT NULL, + subject_type text NOT NULL, + subject_id text NOT NULL, + evidence_ids text[] NOT NULL DEFAULT '{}', + summary text NOT NULL, + citations jsonb NOT NULL, + assumptions text[] NOT NULL DEFAULT '{}', + limitations text[] NOT NULL DEFAULT '{}', + schema_version text NOT NULL, + created_at timestamptz NOT NULL +); + +CREATE TABLE IF NOT EXISTS questionnaire_drafts ( + id text PRIMARY KEY, + tenant_id text NOT NULL, + template_id text NOT NULL, + product_id text, + release_id text, + responses jsonb NOT NULL, + manifest_hash text NOT NULL, + limitations text[] NOT NULL DEFAULT '{}', + schema_version text NOT NULL, + created_at timestamptz NOT NULL +); + +CREATE TABLE IF NOT EXISTS evidence_graph_snapshots ( + id text PRIMARY KEY, + tenant_id text NOT NULL, + product_id text, + release_id text, + nodes jsonb NOT NULL, + edges jsonb NOT NULL, + graph_hash text NOT NULL, + limitations text[] NOT NULL DEFAULT '{}', + schema_version text NOT NULL, + created_at timestamptz NOT NULL +); + +CREATE TABLE IF NOT EXISTS saas_edition_profiles ( + id text PRIMARY KEY, + tenant_id text NOT NULL, + name text NOT NULL, + region text NOT NULL, + admin_tenant_id text NOT NULL, + isolation_model text NOT NULL, + status text NOT NULL, + config_hash text NOT NULL, + limitations text[] NOT NULL DEFAULT '{}', + schema_version text NOT NULL, + created_at timestamptz NOT NULL +); + +CREATE TABLE IF NOT EXISTS public_transparency_logs ( + id text PRIMARY KEY, + tenant_id text NOT NULL, + name text NOT NULL, + endpoint text NOT NULL, + public_key text NOT NULL, + state text NOT NULL, + schema_version text NOT NULL, + created_at timestamptz NOT NULL +); + +CREATE TABLE IF NOT EXISTS public_transparency_log_entries ( + id text PRIMARY KEY, + tenant_id text NOT NULL, + log_id text NOT NULL, + checkpoint_id text NOT NULL, + merkle_batch_id text NOT NULL, + external_id text NOT NULL, + entry_hash text NOT NULL, + state text NOT NULL, + schema_version text NOT NULL, + created_at timestamptz NOT NULL +); + +CREATE TABLE IF NOT EXISTS marketplace_collectors ( + id text PRIMARY KEY, + tenant_id text NOT NULL, + name text NOT NULL, + provider text NOT NULL, + version text NOT NULL, + publisher text NOT NULL, + manifest_hash text NOT NULL, + signature_id text, + sbom_id text, + scan_id text, + state text NOT NULL, + limitations text[] NOT NULL DEFAULT '{}', + schema_version text NOT NULL, + created_at timestamptz NOT NULL, + UNIQUE (tenant_id, provider, name, version) +); + +CREATE TABLE IF NOT EXISTS pdf_report_packages ( + id text PRIMARY KEY, + tenant_id text NOT NULL, + report_type text NOT NULL, + product_id text, + release_id text, + title text NOT NULL, + payload_ref text, + payload_hash text NOT NULL, + payload_size bigint NOT NULL, + limitations text[] NOT NULL DEFAULT '{}', + schema_version text NOT NULL, + created_at timestamptz NOT NULL +); + +CREATE TABLE IF NOT EXISTS anomaly_reports ( + id text PRIMARY KEY, + tenant_id text NOT NULL, + subject_type text NOT NULL, + subject_id text NOT NULL, + result text NOT NULL, + signals jsonb NOT NULL, + assumptions text[] NOT NULL DEFAULT '{}', + limitations text[] NOT NULL DEFAULT '{}', + schema_version text NOT NULL, + created_at timestamptz NOT NULL +); + +CREATE TABLE IF NOT EXISTS provider_verifications ( + id text PRIMARY KEY, + tenant_id text NOT NULL, + provider_type text NOT NULL, + provider_id text NOT NULL, + subject text NOT NULL, + result text NOT NULL, + checks jsonb NOT NULL, + limitations text[] NOT NULL DEFAULT '{}', + schema_version text NOT NULL, + created_at timestamptz NOT NULL +); + +CREATE TABLE IF NOT EXISTS signing_operations ( + id text PRIMARY KEY, + tenant_id text NOT NULL, + provider_id text NOT NULL, + subject_type text NOT NULL, + subject_id text NOT NULL, + payload_hash text NOT NULL, + signature_ref text, + result text NOT NULL, + checks jsonb NOT NULL, + schema_version text NOT NULL, + created_at timestamptz NOT NULL +); + +CREATE INDEX IF NOT EXISTS evidence_summaries_tenant_subject_idx + ON evidence_summaries (tenant_id, subject_type, subject_id, created_at); +CREATE INDEX IF NOT EXISTS questionnaire_drafts_tenant_release_idx + ON questionnaire_drafts (tenant_id, release_id, created_at); +CREATE INDEX IF NOT EXISTS evidence_graph_snapshots_tenant_release_idx + ON evidence_graph_snapshots (tenant_id, release_id, created_at); +CREATE INDEX IF NOT EXISTS saas_edition_profiles_tenant_status_idx + ON saas_edition_profiles (tenant_id, status, created_at); +CREATE INDEX IF NOT EXISTS public_transparency_log_entries_tenant_log_idx + ON public_transparency_log_entries (tenant_id, log_id, created_at); +CREATE INDEX IF NOT EXISTS marketplace_collectors_tenant_provider_idx + ON marketplace_collectors (tenant_id, provider, created_at); +CREATE INDEX IF NOT EXISTS pdf_report_packages_tenant_release_idx + ON pdf_report_packages (tenant_id, release_id, created_at); +CREATE INDEX IF NOT EXISTS anomaly_reports_tenant_subject_idx + ON anomaly_reports (tenant_id, subject_type, subject_id, created_at); +CREATE INDEX IF NOT EXISTS provider_verifications_tenant_provider_idx + ON provider_verifications (tenant_id, provider_type, provider_id, created_at); +CREATE INDEX IF NOT EXISTS signing_operations_tenant_subject_idx + ON signing_operations (tenant_id, subject_type, subject_id, created_at); diff --git a/migrations/20260528001000_signed_incident_webhooks.down.sql b/migrations/20260528001000_signed_incident_webhooks.down.sql new file mode 100644 index 0000000..f916d97 --- /dev/null +++ b/migrations/20260528001000_signed_incident_webhooks.down.sql @@ -0,0 +1,4 @@ +DROP INDEX IF EXISTS incident_webhook_events_tenant_incident_idx; +DROP INDEX IF EXISTS incident_webhook_receivers_tenant_incident_idx; +DROP TABLE IF EXISTS incident_webhook_events; +DROP TABLE IF EXISTS incident_webhook_receivers; diff --git a/migrations/20260528001000_signed_incident_webhooks.up.sql b/migrations/20260528001000_signed_incident_webhooks.up.sql new file mode 100644 index 0000000..30cbb60 --- /dev/null +++ b/migrations/20260528001000_signed_incident_webhooks.up.sql @@ -0,0 +1,33 @@ +CREATE TABLE IF NOT EXISTS incident_webhook_receivers ( + id text PRIMARY KEY, + tenant_id text NOT NULL, + incident_id text NOT NULL, + name text NOT NULL, + provider text NOT NULL, + public_key text NOT NULL, + status text NOT NULL, + schema_version text NOT NULL, + created_at timestamptz NOT NULL +); + +CREATE TABLE IF NOT EXISTS incident_webhook_events ( + id text PRIMARY KEY, + tenant_id text NOT NULL, + receiver_id text NOT NULL, + incident_id text NOT NULL, + provider text NOT NULL, + event_id text NOT NULL, + payload_hash text NOT NULL, + signature_hash text NOT NULL, + timeline_event_id text, + result text NOT NULL, + schema_version text NOT NULL, + created_at timestamptz NOT NULL, + UNIQUE (tenant_id, receiver_id, event_id) +); + +CREATE INDEX IF NOT EXISTS incident_webhook_receivers_tenant_incident_idx + ON incident_webhook_receivers (tenant_id, incident_id, created_at); + +CREATE INDEX IF NOT EXISTS incident_webhook_events_tenant_incident_idx + ON incident_webhook_events (tenant_id, incident_id, created_at); diff --git a/migrations/20260528001100_static_oidc_jwks.down.sql b/migrations/20260528001100_static_oidc_jwks.down.sql new file mode 100644 index 0000000..1c647c2 --- /dev/null +++ b/migrations/20260528001100_static_oidc_jwks.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE sso_providers + DROP COLUMN IF EXISTS jwks; diff --git a/migrations/20260528001100_static_oidc_jwks.up.sql b/migrations/20260528001100_static_oidc_jwks.up.sql new file mode 100644 index 0000000..b95e497 --- /dev/null +++ b/migrations/20260528001100_static_oidc_jwks.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE sso_providers + ADD COLUMN IF NOT EXISTS jwks jsonb NOT NULL DEFAULT '{}'::jsonb; diff --git a/migrations/20260528001200_saml_provider_certificates.down.sql b/migrations/20260528001200_saml_provider_certificates.down.sql new file mode 100644 index 0000000..5824a8a --- /dev/null +++ b/migrations/20260528001200_saml_provider_certificates.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE sso_providers + DROP COLUMN IF EXISTS saml_signing_certificates; diff --git a/migrations/20260528001200_saml_provider_certificates.up.sql b/migrations/20260528001200_saml_provider_certificates.up.sql new file mode 100644 index 0000000..cad4fa5 --- /dev/null +++ b/migrations/20260528001200_saml_provider_certificates.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE sso_providers + ADD COLUMN IF NOT EXISTS saml_signing_certificates jsonb NOT NULL DEFAULT '[]'::jsonb; diff --git a/migrations/20260528001300_sso_trust_material_timestamp.down.sql b/migrations/20260528001300_sso_trust_material_timestamp.down.sql new file mode 100644 index 0000000..7d744de --- /dev/null +++ b/migrations/20260528001300_sso_trust_material_timestamp.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE sso_providers + DROP COLUMN IF EXISTS trust_material_updated_at; diff --git a/migrations/20260528001300_sso_trust_material_timestamp.up.sql b/migrations/20260528001300_sso_trust_material_timestamp.up.sql new file mode 100644 index 0000000..2d2bb8e --- /dev/null +++ b/migrations/20260528001300_sso_trust_material_timestamp.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE sso_providers + ADD COLUMN IF NOT EXISTS trust_material_updated_at timestamptz; diff --git a/migrations/20260528001400_release_core_relational_columns.down.sql b/migrations/20260528001400_release_core_relational_columns.down.sql new file mode 100644 index 0000000..37b36e5 --- /dev/null +++ b/migrations/20260528001400_release_core_relational_columns.down.sql @@ -0,0 +1,13 @@ +DROP INDEX IF EXISTS evidence_items_tenant_build_idx; +DROP INDEX IF EXISTS vulnerability_scans_tenant_release_idx; + +ALTER TABLE openapi_contracts + DROP COLUMN IF EXISTS operations; + +ALTER TABLE vulnerability_scans + DROP COLUMN IF EXISTS release_id; + +ALTER TABLE evidence_items + DROP COLUMN IF EXISTS evidence_version, + DROP COLUMN IF EXISTS deployment_id, + DROP COLUMN IF EXISTS build_id; diff --git a/migrations/20260528001400_release_core_relational_columns.up.sql b/migrations/20260528001400_release_core_relational_columns.up.sql new file mode 100644 index 0000000..e2400d0 --- /dev/null +++ b/migrations/20260528001400_release_core_relational_columns.up.sql @@ -0,0 +1,17 @@ +ALTER TABLE evidence_items + ADD COLUMN IF NOT EXISTS build_id text, + ADD COLUMN IF NOT EXISTS deployment_id text, + ADD COLUMN IF NOT EXISTS evidence_version integer NOT NULL DEFAULT 1; + +ALTER TABLE vulnerability_scans + ADD COLUMN IF NOT EXISTS release_id text; + +ALTER TABLE openapi_contracts + ADD COLUMN IF NOT EXISTS operations jsonb NOT NULL DEFAULT '[]'::jsonb; + +CREATE INDEX IF NOT EXISTS vulnerability_scans_tenant_release_idx + ON vulnerability_scans(tenant_id, release_id, created_at); + +CREATE INDEX IF NOT EXISTS evidence_items_tenant_build_idx + ON evidence_items(tenant_id, build_id) + WHERE build_id IS NOT NULL; diff --git a/migrations/20260528001500_package_retention_relational_columns.down.sql b/migrations/20260528001500_package_retention_relational_columns.down.sql new file mode 100644 index 0000000..9e3fe06 --- /dev/null +++ b/migrations/20260528001500_package_retention_relational_columns.down.sql @@ -0,0 +1,6 @@ +ALTER TABLE object_retention_policies + DROP COLUMN IF EXISTS verification_limitations, + DROP COLUMN IF EXISTS verification_checks; + +ALTER TABLE legal_holds + DROP COLUMN IF EXISTS released_at; diff --git a/migrations/20260528001500_package_retention_relational_columns.up.sql b/migrations/20260528001500_package_retention_relational_columns.up.sql new file mode 100644 index 0000000..0899cf1 --- /dev/null +++ b/migrations/20260528001500_package_retention_relational_columns.up.sql @@ -0,0 +1,6 @@ +ALTER TABLE legal_holds + ADD COLUMN IF NOT EXISTS released_at timestamptz; + +ALTER TABLE object_retention_policies + ADD COLUMN IF NOT EXISTS verification_checks jsonb NOT NULL DEFAULT '[]'::jsonb, + ADD COLUMN IF NOT EXISTS verification_limitations text[] NOT NULL DEFAULT '{}'; diff --git a/migrations/20260528001600_remaining_relational_recovery_columns.down.sql b/migrations/20260528001600_remaining_relational_recovery_columns.down.sql new file mode 100644 index 0000000..2d5520e --- /dev/null +++ b/migrations/20260528001600_remaining_relational_recovery_columns.down.sql @@ -0,0 +1,6 @@ +ALTER TABLE public_transparency_log_entries + DROP COLUMN IF EXISTS verification_limitations, + DROP COLUMN IF EXISTS verification_checks, + DROP COLUMN IF EXISTS inclusion_verified_at, + DROP COLUMN IF EXISTS inclusion_proof_hash, + DROP COLUMN IF EXISTS inclusion_root_hash; diff --git a/migrations/20260528001600_remaining_relational_recovery_columns.up.sql b/migrations/20260528001600_remaining_relational_recovery_columns.up.sql new file mode 100644 index 0000000..dcc161f --- /dev/null +++ b/migrations/20260528001600_remaining_relational_recovery_columns.up.sql @@ -0,0 +1,6 @@ +ALTER TABLE public_transparency_log_entries + ADD COLUMN IF NOT EXISTS inclusion_root_hash text, + ADD COLUMN IF NOT EXISTS inclusion_proof_hash text, + ADD COLUMN IF NOT EXISTS inclusion_verified_at timestamptz, + ADD COLUMN IF NOT EXISTS verification_checks jsonb NOT NULL DEFAULT '[]'::jsonb, + ADD COLUMN IF NOT EXISTS verification_limitations text[] NOT NULL DEFAULT '{}'; diff --git a/migrations/20260529000100_object_retention_object_key.down.sql b/migrations/20260529000100_object_retention_object_key.down.sql new file mode 100644 index 0000000..2a45da7 --- /dev/null +++ b/migrations/20260529000100_object_retention_object_key.down.sql @@ -0,0 +1,4 @@ +DROP INDEX IF EXISTS object_retention_policies_tenant_object_key_idx; + +ALTER TABLE object_retention_policies + DROP COLUMN IF EXISTS object_key; diff --git a/migrations/20260529000100_object_retention_object_key.up.sql b/migrations/20260529000100_object_retention_object_key.up.sql new file mode 100644 index 0000000..bcd7dae --- /dev/null +++ b/migrations/20260529000100_object_retention_object_key.up.sql @@ -0,0 +1,6 @@ +ALTER TABLE object_retention_policies + ADD COLUMN IF NOT EXISTS object_key text NOT NULL DEFAULT ''; + +CREATE INDEX IF NOT EXISTS object_retention_policies_tenant_object_key_idx + ON object_retention_policies (tenant_id, object_key) + WHERE object_key <> ''; diff --git a/migrations/20260529000200_sso_session_groups.down.sql b/migrations/20260529000200_sso_session_groups.down.sql new file mode 100644 index 0000000..ccf7267 --- /dev/null +++ b/migrations/20260529000200_sso_session_groups.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE sso_sessions + DROP COLUMN IF EXISTS groups; diff --git a/migrations/20260529000200_sso_session_groups.up.sql b/migrations/20260529000200_sso_session_groups.up.sql new file mode 100644 index 0000000..c508667 --- /dev/null +++ b/migrations/20260529000200_sso_session_groups.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE sso_sessions + ADD COLUMN IF NOT EXISTS groups jsonb NOT NULL DEFAULT '[]'::jsonb; diff --git a/migrations/20260531000100_object_retention_legal_hold.down.sql b/migrations/20260531000100_object_retention_legal_hold.down.sql new file mode 100644 index 0000000..d1e9f36 --- /dev/null +++ b/migrations/20260531000100_object_retention_legal_hold.down.sql @@ -0,0 +1,4 @@ +DROP INDEX IF EXISTS object_retention_policies_tenant_legal_hold_idx; + +ALTER TABLE object_retention_policies + DROP COLUMN IF EXISTS require_legal_hold; diff --git a/migrations/20260531000100_object_retention_legal_hold.up.sql b/migrations/20260531000100_object_retention_legal_hold.up.sql new file mode 100644 index 0000000..1acf941 --- /dev/null +++ b/migrations/20260531000100_object_retention_legal_hold.up.sql @@ -0,0 +1,6 @@ +ALTER TABLE object_retention_policies + ADD COLUMN IF NOT EXISTS require_legal_hold boolean NOT NULL DEFAULT false; + +CREATE INDEX IF NOT EXISTS object_retention_policies_tenant_legal_hold_idx + ON object_retention_policies (tenant_id, require_legal_hold) + WHERE require_legal_hold = true; diff --git a/migrations/20260601000100_vulnerability_decision_customer_fields.down.sql b/migrations/20260601000100_vulnerability_decision_customer_fields.down.sql new file mode 100644 index 0000000..f5d8819 --- /dev/null +++ b/migrations/20260601000100_vulnerability_decision_customer_fields.down.sql @@ -0,0 +1,5 @@ +ALTER TABLE IF EXISTS vulnerability_decisions + DROP COLUMN IF EXISTS internal_notes; + +ALTER TABLE IF EXISTS vulnerability_decisions + DROP COLUMN IF EXISTS customer_visible; diff --git a/migrations/20260601000100_vulnerability_decision_customer_fields.up.sql b/migrations/20260601000100_vulnerability_decision_customer_fields.up.sql new file mode 100644 index 0000000..3af42ea --- /dev/null +++ b/migrations/20260601000100_vulnerability_decision_customer_fields.up.sql @@ -0,0 +1,5 @@ +ALTER TABLE IF EXISTS vulnerability_decisions + ADD COLUMN IF NOT EXISTS customer_visible boolean NOT NULL DEFAULT false; + +ALTER TABLE IF EXISTS vulnerability_decisions + ADD COLUMN IF NOT EXISTS internal_notes text; diff --git a/migrations/20260601000200_vulnerability_decision_evidence_ids.down.sql b/migrations/20260601000200_vulnerability_decision_evidence_ids.down.sql new file mode 100644 index 0000000..cdf8c86 --- /dev/null +++ b/migrations/20260601000200_vulnerability_decision_evidence_ids.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE IF EXISTS vulnerability_decisions + DROP COLUMN IF EXISTS evidence_ids; diff --git a/migrations/20260601000200_vulnerability_decision_evidence_ids.up.sql b/migrations/20260601000200_vulnerability_decision_evidence_ids.up.sql new file mode 100644 index 0000000..ef4889e --- /dev/null +++ b/migrations/20260601000200_vulnerability_decision_evidence_ids.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE IF EXISTS vulnerability_decisions + ADD COLUMN IF NOT EXISTS evidence_ids text[] NOT NULL DEFAULT '{}'; diff --git a/migrations/20260601000300_vex_import_reports.down.sql b/migrations/20260601000300_vex_import_reports.down.sql new file mode 100644 index 0000000..a7268b2 --- /dev/null +++ b/migrations/20260601000300_vex_import_reports.down.sql @@ -0,0 +1,3 @@ +DROP INDEX IF EXISTS vex_import_reports_tenant_release_idx; +DROP INDEX IF EXISTS vex_import_reports_tenant_vex_idx; +DROP TABLE IF EXISTS vex_import_reports; diff --git a/migrations/20260601000300_vex_import_reports.up.sql b/migrations/20260601000300_vex_import_reports.up.sql new file mode 100644 index 0000000..0666eeb --- /dev/null +++ b/migrations/20260601000300_vex_import_reports.up.sql @@ -0,0 +1,26 @@ +CREATE TABLE IF NOT EXISTS vex_import_reports ( + id text PRIMARY KEY, + tenant_id text NOT NULL, + vex_document_id text NOT NULL, + evidence_id text NOT NULL, + release_id text, + artifact_id text, + parser_version text NOT NULL, + status text NOT NULL, + statement_count integer NOT NULL DEFAULT 0, + decisions_created integer NOT NULL DEFAULT 0, + decisions_superseded integer NOT NULL DEFAULT 0, + unsupported_fields text[] NOT NULL DEFAULT ARRAY[]::text[], + warnings jsonb NOT NULL DEFAULT '[]'::jsonb, + invalid_statements jsonb NOT NULL DEFAULT '[]'::jsonb, + mapping_failures jsonb NOT NULL DEFAULT '[]'::jsonb, + schema_version text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS vex_import_reports_tenant_vex_idx + ON vex_import_reports(tenant_id, vex_document_id); + +CREATE INDEX IF NOT EXISTS vex_import_reports_tenant_release_idx + ON vex_import_reports(tenant_id, release_id, created_at); diff --git a/migrations/20260601000400_customer_portal_nda_answer_library.down.sql b/migrations/20260601000400_customer_portal_nda_answer_library.down.sql new file mode 100644 index 0000000..9c7630d --- /dev/null +++ b/migrations/20260601000400_customer_portal_nda_answer_library.down.sql @@ -0,0 +1,7 @@ +DROP TABLE IF EXISTS questionnaire_answer_library; + +ALTER TABLE customer_portal_access + DROP COLUMN IF EXISTS watermark, + DROP COLUMN IF EXISTS nda_accepted_by, + DROP COLUMN IF EXISTS nda_accepted_at, + DROP COLUMN IF EXISTS require_nda; diff --git a/migrations/20260601000400_customer_portal_nda_answer_library.up.sql b/migrations/20260601000400_customer_portal_nda_answer_library.up.sql new file mode 100644 index 0000000..5c1f7a6 --- /dev/null +++ b/migrations/20260601000400_customer_portal_nda_answer_library.up.sql @@ -0,0 +1,26 @@ +ALTER TABLE customer_portal_access + ADD COLUMN IF NOT EXISTS require_nda boolean NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS nda_accepted_at timestamptz, + ADD COLUMN IF NOT EXISTS nda_accepted_by text, + ADD COLUMN IF NOT EXISTS watermark text; + +CREATE TABLE IF NOT EXISTS questionnaire_answer_library ( + id text PRIMARY KEY, + tenant_id text NOT NULL, + question_id text, + evidence_type text, + control_id text, + product_id text, + release_id text, + answer text NOT NULL, + evidence_ids text[] NOT NULL DEFAULT '{}', + limitations text[] NOT NULL DEFAULT '{}', + schema_version text NOT NULL, + created_at timestamptz NOT NULL +); + +CREATE INDEX IF NOT EXISTS questionnaire_answer_library_tenant_question_idx + ON questionnaire_answer_library (tenant_id, question_id, created_at); + +CREATE INDEX IF NOT EXISTS questionnaire_answer_library_tenant_release_idx + ON questionnaire_answer_library (tenant_id, release_id, created_at); diff --git a/migrations/20260601000500_customer_portal_reviewers.down.sql b/migrations/20260601000500_customer_portal_reviewers.down.sql new file mode 100644 index 0000000..2f112ef --- /dev/null +++ b/migrations/20260601000500_customer_portal_reviewers.down.sql @@ -0,0 +1,5 @@ +DROP INDEX IF EXISTS customer_portal_access_tenant_reviewer_idx; + +ALTER TABLE customer_portal_access + DROP COLUMN IF EXISTS reviewer_email, + DROP COLUMN IF EXISTS reviewer_name; diff --git a/migrations/20260601000500_customer_portal_reviewers.up.sql b/migrations/20260601000500_customer_portal_reviewers.up.sql new file mode 100644 index 0000000..853fdcf --- /dev/null +++ b/migrations/20260601000500_customer_portal_reviewers.up.sql @@ -0,0 +1,6 @@ +ALTER TABLE customer_portal_access + ADD COLUMN IF NOT EXISTS reviewer_name text, + ADD COLUMN IF NOT EXISTS reviewer_email text; + +CREATE INDEX IF NOT EXISTS customer_portal_access_tenant_reviewer_idx + ON customer_portal_access (tenant_id, reviewer_email, created_at); diff --git a/migrations/20260601000600_vulnerability_decision_review_times.down.sql b/migrations/20260601000600_vulnerability_decision_review_times.down.sql new file mode 100644 index 0000000..22727f3 --- /dev/null +++ b/migrations/20260601000600_vulnerability_decision_review_times.down.sql @@ -0,0 +1,5 @@ +ALTER TABLE IF EXISTS vulnerability_decisions + DROP COLUMN IF EXISTS review_due_at; + +ALTER TABLE IF EXISTS vulnerability_decisions + DROP COLUMN IF EXISTS reviewed_at; diff --git a/migrations/20260601000600_vulnerability_decision_review_times.up.sql b/migrations/20260601000600_vulnerability_decision_review_times.up.sql new file mode 100644 index 0000000..d25f8ca --- /dev/null +++ b/migrations/20260601000600_vulnerability_decision_review_times.up.sql @@ -0,0 +1,5 @@ +ALTER TABLE IF EXISTS vulnerability_decisions + ADD COLUMN IF NOT EXISTS reviewed_at timestamptz; + +ALTER TABLE IF EXISTS vulnerability_decisions + ADD COLUMN IF NOT EXISTS review_due_at timestamptz; diff --git a/migrations/20260601000700_vulnerability_decision_sbom_context.down.sql b/migrations/20260601000700_vulnerability_decision_sbom_context.down.sql new file mode 100644 index 0000000..b758962 --- /dev/null +++ b/migrations/20260601000700_vulnerability_decision_sbom_context.down.sql @@ -0,0 +1,8 @@ +ALTER TABLE IF EXISTS vulnerability_decisions + DROP COLUMN IF EXISTS sbom_component_name; + +ALTER TABLE IF EXISTS vulnerability_decisions + DROP COLUMN IF EXISTS sbom_component_purl; + +ALTER TABLE IF EXISTS vulnerability_decisions + DROP COLUMN IF EXISTS sbom_id; diff --git a/migrations/20260601000700_vulnerability_decision_sbom_context.up.sql b/migrations/20260601000700_vulnerability_decision_sbom_context.up.sql new file mode 100644 index 0000000..620527b --- /dev/null +++ b/migrations/20260601000700_vulnerability_decision_sbom_context.up.sql @@ -0,0 +1,8 @@ +ALTER TABLE IF EXISTS vulnerability_decisions + ADD COLUMN IF NOT EXISTS sbom_id text; + +ALTER TABLE IF EXISTS vulnerability_decisions + ADD COLUMN IF NOT EXISTS sbom_component_purl text; + +ALTER TABLE IF EXISTS vulnerability_decisions + ADD COLUMN IF NOT EXISTS sbom_component_name text; diff --git a/migrations/20260601000800_vulnerability_decision_supporting_refs.down.sql b/migrations/20260601000800_vulnerability_decision_supporting_refs.down.sql new file mode 100644 index 0000000..a3524d9 --- /dev/null +++ b/migrations/20260601000800_vulnerability_decision_supporting_refs.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE IF EXISTS vulnerability_decisions + DROP COLUMN IF EXISTS supporting_refs; diff --git a/migrations/20260601000800_vulnerability_decision_supporting_refs.up.sql b/migrations/20260601000800_vulnerability_decision_supporting_refs.up.sql new file mode 100644 index 0000000..9d86a61 --- /dev/null +++ b/migrations/20260601000800_vulnerability_decision_supporting_refs.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE IF EXISTS vulnerability_decisions + ADD COLUMN IF NOT EXISTS supporting_refs jsonb NOT NULL DEFAULT '[]'::jsonb; diff --git a/migrations/20260601000900_vex_import_report_failures.down.sql b/migrations/20260601000900_vex_import_report_failures.down.sql new file mode 100644 index 0000000..845eca8 --- /dev/null +++ b/migrations/20260601000900_vex_import_report_failures.down.sql @@ -0,0 +1,3 @@ +ALTER TABLE vex_import_reports + DROP COLUMN IF EXISTS failure_detail, + DROP COLUMN IF EXISTS failure_code; diff --git a/migrations/20260601000900_vex_import_report_failures.up.sql b/migrations/20260601000900_vex_import_report_failures.up.sql new file mode 100644 index 0000000..3cc55d0 --- /dev/null +++ b/migrations/20260601000900_vex_import_report_failures.up.sql @@ -0,0 +1,3 @@ +ALTER TABLE vex_import_reports + ADD COLUMN IF NOT EXISTS failure_code text NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS failure_detail text NOT NULL DEFAULT ''; diff --git a/openapi.yaml b/openapi.yaml index 5f7976d..13bd5d9 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -1 +1 @@ -{"components":{"schemas":{"Problem":{"properties":{"code":{"type":"string"},"detail":{"type":"string"},"instance":{"type":"string"},"status":{"type":"integer"},"title":{"type":"string"},"type":{"type":"string"}},"type":"object"}},"securitySchemes":{"BearerAuth":{"scheme":"bearer","type":"http"}}},"info":{"description":"Self-hosted API evidence and compliance-readiness ledger.","title":"Evydence API","version":"dev"},"openapi":"3.1.0","paths":{"/v1/admin/instance":{"get":{"operationId":"instanceAdminSnapshot","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Instance admin snapshot","tags":["evydence"],"x-scopes":["instance:admin"]}},"/v1/api-keys":{"get":{"operationId":"listAPIKeys","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"List API keys","tags":["evydence"],"x-scopes":["admin"]},"post":{"operationId":"createAPIKey","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Create API key","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["admin"]}},"/v1/api-security-scans":{"post":{"operationId":"uploadAPISecurityScan","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Upload API security scan","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["security:write"]}},"/v1/approvals":{"post":{"operationId":"createApproval","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Create approval record","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["release:write"]}},"/v1/artifact-signatures":{"post":{"operationId":"createArtifactSignature","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Create artifact signature","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["evidence:write"]}},"/v1/artifact-signatures/{id}":{"get":{"operationId":"getArtifactSignature","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Get artifact signature","tags":["evydence"],"x-scopes":["evidence:read"]}},"/v1/artifact-signatures/{id}/verify-cosign":{"post":{"operationId":"verifyCosignSignature","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Verify cosign-style artifact signature","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["verify:read"]}},"/v1/artifacts":{"post":{"operationId":"registerArtifact","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Register artifact","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["evidence:write"]}},"/v1/audit-chain/verify":{"get":{"operationId":"verifyAuditChain","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Verify audit chain","tags":["evydence"],"x-scopes":["verify:read"]}},"/v1/audit-log":{"get":{"operationId":"listAuditLog","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"List tenant audit log","tags":["evydence"],"x-scopes":["admin"]}},"/v1/backup-manifests":{"post":{"operationId":"generateBackupManifest","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Generate backup manifest","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["admin"]}},"/v1/backup-manifests/{id}/verify":{"get":{"operationId":"verifyBackupManifest","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Verify backup manifest","tags":["evydence"],"x-scopes":["verify:read"]}},"/v1/build-attestations/{id}/verify-signature":{"post":{"operationId":"verifyBuildAttestationSignature","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Verify build attestation signature","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["verify:read"]}},"/v1/builds":{"post":{"operationId":"createBuild","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Create build run","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["build:write"]}},"/v1/builds/{id}":{"get":{"operationId":"getBuild","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Get build run","tags":["evydence"],"x-scopes":["build:read"]}},"/v1/builds/{id}/attestations":{"post":{"operationId":"uploadBuildAttestation","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Upload build attestation","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["build:write"]}},"/v1/collectors":{"get":{"operationId":"listCollectors","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"List collectors","tags":["evydence"],"x-scopes":["collector:read"]},"post":{"operationId":"createCollector","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Create collector","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["collector:admin"]}},"/v1/collectors/github/source-snapshots":{"post":{"operationId":"uploadGitHubSourceSnapshot","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Upload GitHub source snapshot","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["source:write"]}},"/v1/collectors/gitlab/source-snapshots":{"post":{"operationId":"uploadGitLabSourceSnapshot","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Upload GitLab source snapshot","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["source:write"]}},"/v1/collectors/{id}/health":{"get":{"operationId":"collectorHealthReport","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Collector health report","tags":["evydence"],"x-scopes":["collector:read"]}},"/v1/collectors/{id}/releases":{"post":{"operationId":"recordCollectorRelease","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Record collector release evidence","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["collector:admin"]}},"/v1/commercial-collectors":{"get":{"operationId":"listCommercialCollectors","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"List commercial collector definitions","tags":["evydence"],"x-scopes":["collector:read"]},"post":{"operationId":"createCommercialCollector","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Create commercial collector definition","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["collector:admin"]}},"/v1/container-images":{"post":{"operationId":"registerContainerImage","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Register container image","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["evidence:write"]}},"/v1/control-evidence":{"get":{"operationId":"listControlEvidence","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"List control evidence","tags":["evydence"],"x-scopes":["controls:read"]}},"/v1/control-framework-template-packs":{"get":{"operationId":"listControlFrameworkTemplatePacks","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"List control framework template packs","tags":["evydence"],"x-scopes":["controls:read"]}},"/v1/control-framework-template-packs/{slug}/install":{"post":{"operationId":"installControlFrameworkTemplatePack","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Install control framework template pack","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["controls:admin"]}},"/v1/control-frameworks":{"get":{"operationId":"listControlFrameworks","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"List control frameworks","tags":["evydence"],"x-scopes":["controls:read"]},"post":{"operationId":"createControlFramework","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Create control framework","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["controls:admin"]}},"/v1/controls":{"post":{"operationId":"createSecurityControl","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Create security control","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["controls:admin"]}},"/v1/controls/{id}":{"get":{"operationId":"getSecurityControl","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Get security control","tags":["evydence"],"x-scopes":["controls:read"]}},"/v1/controls/{id}/evidence":{"post":{"operationId":"linkControlEvidence","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Link control evidence","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["controls:write"]}},"/v1/custom-policies":{"post":{"operationId":"createCustomPolicy","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Create custom policy","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["policy:write"]}},"/v1/custom-policies/{id}/evaluate":{"post":{"operationId":"evaluateCustomPolicy","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Evaluate custom policy","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["policy:read"]}},"/v1/customer-packages":{"post":{"operationId":"createCustomerPackage","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Create customer security package","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["package:write"]}},"/v1/customer-packages/{id}":{"get":{"operationId":"getCustomerPackage","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Get customer security package","tags":["evydence"],"x-scopes":["package:read"]}},"/v1/customer-portal/access":{"post":{"operationId":"createCustomerPortalAccess","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Create customer portal access","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["package:write"]}},"/v1/customer-portal/package":{"post":{"operationId":"accessCustomerPortalPackage","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"summary":"Access customer portal package","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true}}},"/v1/deployments":{"get":{"operationId":"listDeployments","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"List deployments","tags":["evydence"],"x-scopes":["deployment:read"]},"post":{"operationId":"recordDeployment","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Record deployment","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["deployment:write"]}},"/v1/deployments/{id}":{"get":{"operationId":"getDeployment","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Get deployment","tags":["evydence"],"x-scopes":["deployment:read"]}},"/v1/dsse-trust-roots":{"post":{"operationId":"createDSSETrustRoot","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Create DSSE trust root","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["keys:admin"]}},"/v1/environments":{"get":{"operationId":"listDeploymentEnvironments","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"List deployment environments","tags":["evydence"],"x-scopes":["deployment:read"]},"post":{"operationId":"createDeploymentEnvironment","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Create deployment environment","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["deployment:write"]}},"/v1/evidence":{"get":{"operationId":"listEvidence","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"List evidence","tags":["evydence"],"x-scopes":["evidence:read"]},"post":{"operationId":"createEvidence","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Create evidence","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["evidence:write"]}},"/v1/evidence-bundles":{"post":{"operationId":"exportEvidenceBundle","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Export evidence bundle","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["bundle:read"]}},"/v1/evidence-bundles/import":{"post":{"operationId":"importEvidenceBundle","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Import evidence bundle","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["bundle:write"]}},"/v1/evidence/search":{"get":{"operationId":"searchEvidence","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Search evidence","tags":["evydence"],"x-scopes":["evidence:read"]}},"/v1/evidence/{id}":{"get":{"operationId":"getEvidence","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Get evidence","tags":["evydence"],"x-scopes":["evidence:read"]}},"/v1/evidence/{id}/lifecycle-events":{"get":{"operationId":"listEvidenceLifecycleEvents","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"List evidence lifecycle events","tags":["evydence"],"x-scopes":["evidence:read"]},"post":{"operationId":"recordEvidenceLifecycleEvent","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Record evidence lifecycle event","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["evidence:write"]}},"/v1/evidence/{id}/link":{"post":{"operationId":"linkEvidence","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Link evidence","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["evidence:write"]}},"/v1/evidence/{id}/supersede":{"post":{"operationId":"supersedeEvidence","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Supersede evidence","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["evidence:write"]}},"/v1/exceptions":{"get":{"operationId":"listExceptions","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"List exceptions","tags":["evydence"],"x-scopes":["verify:read"]},"post":{"operationId":"createException","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Create exception","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["release:write"]}},"/v1/exceptions/{id}/approve":{"post":{"operationId":"approveException","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Approve exception","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["release:write"]}},"/v1/health":{"get":{"operationId":"health","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"summary":"Health","tags":["evydence"]}},"/v1/incidents":{"post":{"operationId":"createIncident","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Create incident","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["incident:write"]}},"/v1/incidents/{id}/timeline":{"post":{"operationId":"recordIncidentTimeline","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Record incident timeline event","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["incident:write"]}},"/v1/legal-holds":{"post":{"operationId":"createLegalHold","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Create legal hold","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["admin"]}},"/v1/merkle-batches":{"post":{"operationId":"createMerkleBatch","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Create Merkle checkpoint batch","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["keys:admin"]}},"/v1/merkle-batches/{id}/verify":{"get":{"operationId":"verifyMerkleBatch","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Verify Merkle checkpoint batch","tags":["evydence"],"x-scopes":["verify:read"]}},"/v1/metrics":{"get":{"operationId":"metrics","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Safe tenant metrics","tags":["evydence"],"x-scopes":["admin"]}},"/v1/object-retention-policies":{"post":{"operationId":"createObjectRetentionPolicy","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Create object retention policy record","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["admin"]}},"/v1/object-retention-policies/{id}/verify":{"post":{"operationId":"verifyObjectRetentionPolicy","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Verify object retention policy record","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["verify:read"]}},"/v1/openapi-contracts":{"post":{"operationId":"uploadOpenAPIContract","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Upload OpenAPI contract","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["evidence:write"]}},"/v1/openapi-contracts/{id}":{"get":{"operationId":"getOpenAPIContract","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Get OpenAPI contract","tags":["evydence"],"x-scopes":["evidence:read"]}},"/v1/openapi-diffs":{"post":{"operationId":"createOpenAPIDiff","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Create OpenAPI contract diff","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["evidence:read"]}},"/v1/openapi.json":{"get":{"operationId":"openapi","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"summary":"OpenAPI","tags":["evydence"]}},"/v1/organizations":{"post":{"operationId":"createOrganization","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Create organization","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["identity:admin"]}},"/v1/policies/evaluate":{"post":{"operationId":"evaluatePolicy","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Evaluate release policy","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["verify:read"]}},"/v1/products":{"get":{"operationId":"listProducts","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"List products","tags":["evydence"],"x-scopes":["product:read"]},"post":{"operationId":"createProduct","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Create product","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["product:write"]}},"/v1/projects":{"post":{"operationId":"createProject","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Create project","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["project:write"]}},"/v1/questionnaire-packages":{"post":{"operationId":"createQuestionnairePackage","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Create questionnaire package","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["package:write"]}},"/v1/questionnaire-templates":{"post":{"operationId":"createQuestionnaireTemplate","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Create questionnaire template","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["package:write"]}},"/v1/ready":{"get":{"operationId":"ready","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"summary":"Readiness","tags":["evydence"]}},"/v1/redaction-profiles":{"post":{"operationId":"createRedactionProfile","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Create redaction profile","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["package:write"]}},"/v1/release-bundles":{"post":{"operationId":"createReleaseBundle","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Create release bundle","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["bundle:write"]}},"/v1/release-bundles/{id}":{"get":{"operationId":"getReleaseBundle","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Get release bundle","tags":["evydence"],"x-scopes":["bundle:read"]}},"/v1/release-bundles/{id}/manifest":{"get":{"operationId":"getReleaseBundleManifest","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Get release bundle manifest","tags":["evydence"],"x-scopes":["bundle:read"]}},"/v1/release-bundles/{id}/verify":{"get":{"operationId":"verifyReleaseBundle","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Verify release bundle","tags":["evydence"],"x-scopes":["verify:read"]}},"/v1/release-candidates":{"get":{"operationId":"listReleaseCandidates","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"List release candidates","tags":["evydence"],"x-scopes":["release:read"]},"post":{"operationId":"createReleaseCandidate","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Create release candidate","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["release:write"]}},"/v1/release-candidates/{id}":{"get":{"operationId":"getReleaseCandidate","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Get release candidate","tags":["evydence"],"x-scopes":["release:read"]}},"/v1/release-candidates/{id}/promote":{"post":{"operationId":"promoteReleaseCandidate","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Promote release candidate","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["release:write"]}},"/v1/release-candidates/{id}/reject":{"post":{"operationId":"rejectReleaseCandidate","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Reject release candidate","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["release:write"]}},"/v1/releases":{"post":{"operationId":"createRelease","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Create release","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["release:write"]}},"/v1/releases/{id}":{"get":{"operationId":"getRelease","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Get release","tags":["evydence"],"x-scopes":["release:read"]}},"/v1/releases/{id}/approve":{"post":{"operationId":"approveRelease","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Approve release","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["release:write"]}},"/v1/releases/{id}/freeze":{"post":{"operationId":"freezeRelease","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Freeze release","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["release:write"]}},"/v1/remediation-tasks":{"post":{"operationId":"createRemediationTask","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Create remediation task","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["incident:write"]}},"/v1/report-templates":{"post":{"operationId":"createReportTemplate","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Create report template","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["report:read"]}},"/v1/report-templates/{id}/render":{"post":{"operationId":"renderReportTemplate","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Render report template","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["report:read"]}},"/v1/reports/control-coverage":{"get":{"operationId":"controlCoverageReport","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Control coverage report","tags":["evydence"],"x-scopes":["report:read"]}},"/v1/reports/cra-readiness":{"get":{"operationId":"craReadinessReport","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"CRA readiness report","tags":["evydence"],"x-scopes":["report:read"]}},"/v1/reports/cra-readiness-html":{"get":{"operationId":"craReadinessHTMLPackage","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"CRA readiness HTML package","tags":["evydence"],"x-scopes":["report:read"]}},"/v1/reports/incident-package":{"get":{"operationId":"incidentReport","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Incident package report","tags":["evydence"],"x-scopes":["incident:read"]}},"/v1/reports/missing-evidence":{"get":{"operationId":"missingEvidenceReport","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Missing evidence report","tags":["evydence"],"x-scopes":["verify:read"]}},"/v1/reports/release-readiness":{"get":{"operationId":"releaseReadinessReport","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Release readiness report","tags":["evydence"],"x-scopes":["verify:read"]}},"/v1/reports/retention":{"get":{"operationId":"retentionReport","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Retention report","tags":["evydence"],"x-scopes":["admin"]}},"/v1/reports/security-review-package":{"get":{"operationId":"securityReviewPackageReport","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Security review package report","tags":["evydence"],"x-scopes":["package:read"]}},"/v1/reports/vulnerability-posture":{"get":{"operationId":"vulnerabilityPostureReport","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Vulnerability posture report","tags":["evydence"],"x-scopes":["security:read"]}},"/v1/retention-overrides":{"post":{"operationId":"createRetentionOverride","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Create retention override","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["admin"]}},"/v1/role-bindings":{"get":{"operationId":"listRoleBindings","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"List role bindings","tags":["evydence"],"x-scopes":["identity:admin"]},"post":{"operationId":"createRoleBinding","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Create role binding","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["identity:admin"]}},"/v1/sbom-diffs":{"post":{"operationId":"createSBOMDiff","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Create SBOM diff","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["evidence:read"]}},"/v1/sboms":{"post":{"operationId":"uploadSBOM","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Upload CycloneDX SBOM","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["evidence:write"]}},"/v1/sboms/spdx":{"post":{"operationId":"uploadSPDXSBOM","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Upload SPDX SBOM","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["evidence:write"]}},"/v1/sboms/{id}":{"get":{"operationId":"getSBOM","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Get SBOM","tags":["evydence"],"x-scopes":["evidence:read"]}},"/v1/security-documents":{"post":{"operationId":"uploadManualSecurityDocument","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Upload manual security document","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["security:write"]}},"/v1/security-scans":{"post":{"operationId":"uploadSecurityScan","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Upload security scan","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["security:write"]}},"/v1/signing-keys":{"get":{"operationId":"listSigningKeys","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"List signing keys","tags":["evydence"],"x-scopes":["verify:read"]}},"/v1/signing-keys/rotate":{"post":{"operationId":"rotateSigningKey","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Rotate signing key","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["keys:admin"]}},"/v1/signing-keys/{id}/revoke":{"post":{"operationId":"revokeSigningKey","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Revoke signing key","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["keys:admin"]}},"/v1/signing-providers":{"post":{"operationId":"createSigningProvider","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Create signing provider record","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["keys:admin"]}},"/v1/source/branches":{"post":{"operationId":"upsertSourceBranch","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Record source branch","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["source:write"]}},"/v1/source/commits":{"post":{"operationId":"recordSourceCommit","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Record source commit","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["source:write"]}},"/v1/source/pull-requests":{"post":{"operationId":"recordPullRequest","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Record pull request","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["source:write"]}},"/v1/source/repositories":{"get":{"operationId":"listSourceRepositories","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"List source repositories","tags":["evydence"],"x-scopes":["source:read"]},"post":{"operationId":"createSourceRepository","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Create source repository","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["source:write"]}},"/v1/sso/identity-links":{"post":{"operationId":"linkSSOIdentity","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Link SSO identity","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["identity:admin"]}},"/v1/sso/providers":{"post":{"operationId":"createSSOProvider","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Create SSO provider","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["identity:admin"]}},"/v1/sso/sessions":{"post":{"operationId":"createSSOSession","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Create SSO session","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["identity:admin"]}},"/v1/sso/sessions/{id}/revoke":{"post":{"operationId":"revokeSSOSession","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Revoke SSO session","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["identity:admin"]}},"/v1/transparency-checkpoints":{"post":{"operationId":"createTransparencyCheckpoint","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Record external transparency checkpoint","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["keys:admin"]}},"/v1/users":{"post":{"operationId":"createUser","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Create user","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["identity:admin"]}},"/v1/users/{id}/deactivate":{"post":{"operationId":"deactivateUser","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Deactivate user","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["identity:admin"]}},"/v1/verify":{"post":{"operationId":"verify","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Verify subject","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["verify:read"]}},"/v1/version":{"get":{"operationId":"version","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"summary":"Version","tags":["evydence"]}},"/v1/vex":{"post":{"operationId":"uploadVEX","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Upload OpenVEX document","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["evidence:write"]}},"/v1/vex/cyclonedx":{"post":{"operationId":"uploadCycloneDXVEX","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Upload CycloneDX VEX document","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["evidence:write"]}},"/v1/vex/{id}":{"get":{"operationId":"getVEX","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Get VEX document","tags":["evydence"],"x-scopes":["evidence:read"]}},"/v1/vulnerability-findings/{id}/decisions":{"post":{"operationId":"createVulnerabilityDecision","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Create vulnerability decision","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["evidence:write"]}},"/v1/vulnerability-findings/{id}/workflow":{"post":{"operationId":"recordVulnerabilityWorkflow","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Record vulnerability workflow event","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["security:write"]}},"/v1/vulnerability-scans":{"post":{"operationId":"uploadVulnerabilityScan","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Upload vulnerability scan","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["evidence:write"]}},"/v1/vulnerability-scans/{id}":{"get":{"operationId":"getVulnerabilityScan","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Get vulnerability scan","tags":["evydence"],"x-scopes":["evidence:read"]}},"/v1/waivers":{"post":{"operationId":"createWaiver","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Create waiver","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["policy:write"]}},"/v1/waivers/{id}/approve":{"post":{"operationId":"approveWaiver","responses":{"200":{"description":"OK"},"201":{"description":"Created"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found"},"409":{"description":"Conflict"},"422":{"description":"Verification failed"}},"security":[{"BearerAuth":[]}],"summary":"Approve waiver","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["policy:write"]}}}} +{"components":{"schemas":{"APIKey":{"additionalProperties":false,"properties":{"created_at":{"format":"date-time","type":"string"},"expires_at":{"format":"date-time","type":"string"},"id":{"type":"string"},"last_used_at":{"format":"date-time","type":"string"},"name":{"type":"string"},"prefix":{"description":"Non-secret key prefix for lookup and audit displays.","type":"string"},"revoked_at":{"format":"date-time","type":"string"},"scopes":{"items":{"type":"string"},"type":"array"},"tenant_id":{"type":"string"}},"required":["id","tenant_id","name","prefix","scopes","created_at"],"type":"object"},"APIKeyCreateEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/APIKeyCreateResponse"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"APIKeyCreateResponse":{"additionalProperties":false,"properties":{"api_key":{"$ref":"#/components/schemas/APIKey"},"secret":{"description":"One-time API key secret; stored only as a peppered HMAC hash.","type":"string"}},"required":["api_key","secret"],"type":"object"},"APIKeyListEnvelope":{"additionalProperties":false,"properties":{"data":{"items":{"$ref":"#/components/schemas/APIKey"},"type":"array"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"AnomalyReport":{"additionalProperties":false,"properties":{"assumptions":{"items":{"type":"string"},"type":"array"},"created_at":{"format":"date-time","type":"string"},"id":{"type":"string"},"limitations":{"items":{"type":"string"},"type":"array"},"result":{"type":"string"},"schema_version":{"type":"string"},"signals":{"items":{"$ref":"#/components/schemas/AnomalySignal"},"type":"array"},"subject_id":{"type":"string"},"subject_type":{"type":"string"},"tenant_id":{"type":"string"}},"required":["id","tenant_id","subject_type","subject_id","result","assumptions","limitations","schema_version","created_at"],"type":"object"},"AnomalyReportEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/AnomalyReport"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"AnomalySignal":{"additionalProperties":false,"properties":{"detail":{"type":"string"},"name":{"type":"string"},"severity":{"type":"string"}},"required":["name","severity","detail"],"type":"object"},"ApprovalRecord":{"additionalProperties":false,"properties":{"approver_id":{"type":"string"},"created_at":{"format":"date-time","type":"string"},"decision":{"type":"string"},"evidence_id":{"type":"string"},"id":{"type":"string"},"reason":{"type":"string"},"schema_version":{"type":"string"},"subject_id":{"type":"string"},"subject_type":{"type":"string"},"tenant_id":{"type":"string"}},"required":["id","tenant_id","subject_type","subject_id","decision","reason","approver_id","schema_version","created_at"],"type":"object"},"ApprovalRecordEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/ApprovalRecord"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"Artifact":{"additionalProperties":false,"properties":{"created_at":{"format":"date-time","type":"string"},"digest":{"type":"string"},"id":{"type":"string"},"media_type":{"type":"string"},"name":{"type":"string"},"release_id":{"type":"string"},"schema_version":{"type":"string"},"size":{"type":"integer"},"tenant_id":{"type":"string"}},"required":["id","tenant_id","name","digest","schema_version","created_at"],"type":"object"},"ArtifactEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/Artifact"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"ArtifactSignature":{"additionalProperties":false,"properties":{"algorithm":{"type":"string"},"artifact_id":{"type":"string"},"created_at":{"format":"date-time","type":"string"},"id":{"type":"string"},"key_id":{"type":"string"},"payload_hash":{"pattern":"^sha256:","type":"string"},"payload_ref":{"type":"string"},"schema_version":{"type":"string"},"signature":{"type":"string"},"subject_digest":{"type":"string"},"tenant_id":{"type":"string"},"verification_status":{"type":"string"}},"required":["id","tenant_id","artifact_id","subject_digest","algorithm","signature","verification_status","schema_version","created_at"],"type":"object"},"ArtifactSignatureEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/ArtifactSignature"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"AuditChainEntry":{"additionalProperties":false,"properties":{"actor_id":{"type":"string"},"actor_type":{"type":"string"},"canonical_entry_hash":{"pattern":"^sha256:","type":"string"},"entry_hash":{"pattern":"^sha256:","type":"string"},"entry_type":{"type":"string"},"id":{"type":"string"},"idempotency_key":{"type":"string"},"metadata":{"additionalProperties":true,"type":"object"},"occurred_at":{"format":"date-time","type":"string"},"payload_hash":{"type":"string"},"previous_entry_hash":{"type":"string"},"request_id":{"type":"string"},"schema_version":{"type":"string"},"sequence":{"format":"int64","type":"integer"},"signature_ref":{"type":"string"},"subject_id":{"type":"string"},"subject_type":{"type":"string"},"tenant_id":{"type":"string"}},"required":["id","tenant_id","sequence","entry_type","subject_type","subject_id","actor_type","actor_id","occurred_at","canonical_entry_hash","previous_entry_hash","entry_hash","schema_version"],"type":"object"},"AuditChainEntryListEnvelope":{"additionalProperties":false,"properties":{"data":{"items":{"$ref":"#/components/schemas/AuditChainEntry"},"type":"array"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"BackupManifest":{"additionalProperties":false,"properties":{"consistency_checks":{"items":{"$ref":"#/components/schemas/VerifyCheck"},"type":"array"},"created_at":{"format":"date-time","type":"string"},"id":{"type":"string"},"limitations":{"items":{"type":"string"},"type":"array"},"resource_counts":{"additionalProperties":{"type":"integer"},"type":"object"},"schema_version":{"type":"string"},"state_hash":{"pattern":"^sha256:","type":"string"},"tenant_id":{"type":"string"}},"required":["id","tenant_id","state_hash","resource_counts","consistency_checks","limitations","schema_version","created_at"],"type":"object"},"BackupManifestEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/BackupManifest"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"BuildAttestation":{"additionalProperties":false,"properties":{"build_id":{"type":"string"},"build_type":{"type":"string"},"builder_id":{"type":"string"},"created_at":{"format":"date-time","type":"string"},"evidence_id":{"type":"string"},"id":{"type":"string"},"materials_count":{"type":"integer"},"payload_hash":{"pattern":"^sha256:","type":"string"},"payload_ref":{"type":"string"},"payload_size":{"type":"integer"},"payload_type":{"type":"string"},"predicate_type":{"type":"string"},"schema_version":{"type":"string"},"signature_count":{"type":"integer"},"subject_digests":{"items":{"type":"string"},"type":"array"},"tenant_id":{"type":"string"},"verification_status":{"type":"string"}},"required":["id","tenant_id","build_id","evidence_id","payload_hash","payload_size","payload_type","predicate_type","subject_digests","signature_count","verification_status","schema_version","created_at"],"type":"object"},"BuildAttestationEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/BuildAttestation"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"BuildRun":{"additionalProperties":false,"properties":{"collector_id":{"type":"string"},"commit_sha":{"type":"string"},"created_at":{"format":"date-time","type":"string"},"id":{"type":"string"},"outputs":{"items":{"type":"object"},"type":"array"},"project_id":{"type":"string"},"provider":{"type":"string"},"release_id":{"type":"string"},"schema_version":{"type":"string"},"status":{"type":"string"},"tenant_id":{"type":"string"}},"required":["id","tenant_id","project_id","release_id","provider","commit_sha","status","schema_version","created_at"],"type":"object"},"BuildRunEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/BuildRun"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"CRAVulnerabilityHandlingReport":{"additionalProperties":false,"properties":{"accepted_exceptions":{"items":{"$ref":"#/components/schemas/Exception"},"type":"array"},"assumptions":{"items":{"type":"string"},"type":"array"},"decisions":{"items":{"$ref":"#/components/schemas/VulnerabilityDecisionCustomerSummary"},"type":"array"},"evidence_ids":{"items":{"type":"string"},"type":"array"},"generated_at":{"format":"date-time","type":"string"},"limitations":{"items":{"type":"string"},"type":"array"},"product_id":{"type":"string"},"release_id":{"type":"string"},"report_type":{"type":"string"},"summary":{"additionalProperties":{"type":"integer"},"type":"object"},"template_version":{"type":"string"}},"required":["report_type","template_version","product_id","release_id","summary","assumptions","limitations","generated_at"],"type":"object"},"CRAVulnerabilityHandlingReportEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/CRAVulnerabilityHandlingReport"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"Collector":{"additionalProperties":false,"properties":{"allowed_scopes":{"items":{"type":"string"},"type":"array"},"api_key_id":{"type":"string"},"created_at":{"format":"date-time","type":"string"},"id":{"type":"string"},"last_seen_at":{"format":"date-time","type":"string"},"name":{"type":"string"},"schema_version":{"type":"string"},"status":{"type":"string"},"tenant_id":{"type":"string"},"type":{"type":"string"},"version":{"type":"string"}},"required":["id","tenant_id","name","type","version","api_key_id","status","allowed_scopes","schema_version","created_at"],"type":"object"},"CollectorCreateEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/CollectorCreateResponse"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"CollectorCreateResponse":{"additionalProperties":false,"properties":{"api_key":{"$ref":"#/components/schemas/APIKey"},"collector":{"$ref":"#/components/schemas/Collector"},"secret":{"description":"One-time collector API key secret; stored only as a peppered HMAC hash.","type":"string"}},"required":["collector","api_key","secret"],"type":"object"},"CollectorHealthReport":{"additionalProperties":false,"properties":{"assumptions":{"items":{"type":"string"},"type":"array"},"checks":{"items":{"$ref":"#/components/schemas/VerifyCheck"},"type":"array"},"collector_id":{"type":"string"},"collector_status":{"type":"string"},"generated_at":{"format":"date-time","type":"string"},"latest_release":{"$ref":"#/components/schemas/CollectorRelease"},"limitations":{"items":{"type":"string"},"type":"array"},"pinned_release_id":{"type":"string"},"report_type":{"type":"string"},"supply_chain_status":{"type":"string"},"version":{"type":"string"}},"required":["report_type","collector_id","collector_status","supply_chain_status","checks","assumptions","limitations","generated_at"],"type":"object"},"CollectorHealthReportEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/CollectorHealthReport"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"CollectorListEnvelope":{"additionalProperties":false,"properties":{"data":{"items":{"$ref":"#/components/schemas/Collector"},"type":"array"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"CollectorRelease":{"additionalProperties":false,"properties":{"artifact_digest":{"type":"string"},"collector_id":{"type":"string"},"created_at":{"format":"date-time","type":"string"},"health_status":{"type":"string"},"id":{"type":"string"},"limitations":{"items":{"type":"string"},"type":"array"},"pinned":{"type":"boolean"},"sbom_id":{"type":"string"},"scan_id":{"type":"string"},"schema_version":{"type":"string"},"signature_id":{"type":"string"},"tenant_id":{"type":"string"},"verification_status":{"type":"string"},"version":{"type":"string"}},"required":["id","tenant_id","collector_id","version","artifact_digest","pinned","verification_status","health_status","schema_version","created_at"],"type":"object"},"CollectorReleaseEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/CollectorRelease"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"CommercialCollectorDefinition":{"additionalProperties":false,"properties":{"allowed_scopes":{"items":{"type":"string"},"type":"array"},"created_at":{"format":"date-time","type":"string"},"id":{"type":"string"},"manifest_hash":{"pattern":"^sha256:","type":"string"},"name":{"type":"string"},"provider":{"type":"string"},"schema_version":{"type":"string"},"status":{"type":"string"},"tenant_id":{"type":"string"},"version":{"type":"string"}},"required":["id","tenant_id","name","provider","version","manifest_hash","allowed_scopes","status","schema_version","created_at"],"type":"object"},"CommercialCollectorDefinitionEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/CommercialCollectorDefinition"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"CommercialCollectorDefinitionListEnvelope":{"additionalProperties":false,"properties":{"data":{"items":{"$ref":"#/components/schemas/CommercialCollectorDefinition"},"type":"array"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"ContainerImage":{"additionalProperties":false,"properties":{"artifact_id":{"type":"string"},"created_at":{"format":"date-time","type":"string"},"digest":{"type":"string"},"id":{"type":"string"},"platform":{"type":"string"},"repository":{"type":"string"},"schema_version":{"type":"string"},"tag":{"type":"string"},"tenant_id":{"type":"string"}},"required":["id","tenant_id","repository","digest","schema_version","created_at"],"type":"object"},"ContainerImageEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/ContainerImage"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"ContractDiff":{"additionalProperties":false,"properties":{"base_contract_id":{"type":"string"},"breaking_changes":{"items":{"type":"string"},"type":"array"},"created_at":{"format":"date-time","type":"string"},"id":{"type":"string"},"non_breaking_changes":{"items":{"type":"string"},"type":"array"},"product_id":{"type":"string"},"release_id":{"type":"string"},"result":{"type":"string"},"schema_version":{"type":"string"},"target_contract_id":{"type":"string"},"tenant_id":{"type":"string"}},"required":["id","tenant_id","base_contract_id","target_contract_id","product_id","result","schema_version","created_at"],"type":"object"},"ContractDiffEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/ContractDiff"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"ControlEvidence":{"additionalProperties":false,"properties":{"confidence":{"type":"string"},"control_id":{"type":"string"},"created_at":{"format":"date-time","type":"string"},"evidence_type":{"type":"string"},"id":{"type":"string"},"notes":{"type":"string"},"product_id":{"type":"string"},"release_id":{"type":"string"},"schema_version":{"type":"string"},"subject_id":{"type":"string"},"subject_type":{"type":"string"},"tenant_id":{"type":"string"}},"required":["id","tenant_id","control_id","evidence_type","subject_type","subject_id","confidence","schema_version","created_at"],"type":"object"},"ControlEvidenceEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/ControlEvidence"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"ControlEvidenceListEnvelope":{"additionalProperties":false,"properties":{"data":{"items":{"$ref":"#/components/schemas/ControlEvidence"},"type":"array"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"ControlEvidenceRequirement":{"additionalProperties":false,"properties":{"freshness_days":{"minimum":0,"type":"integer"},"required":{"type":"boolean"},"type":{"type":"string"}},"required":["type","required"],"type":"object"},"ControlFramework":{"additionalProperties":false,"properties":{"created_at":{"format":"date-time","type":"string"},"description":{"type":"string"},"id":{"type":"string"},"name":{"type":"string"},"schema_version":{"type":"string"},"slug":{"type":"string"},"status":{"type":"string"},"tenant_id":{"type":"string"},"version":{"type":"string"}},"required":["id","tenant_id","name","slug","version","status","schema_version","created_at"],"type":"object"},"ControlFrameworkEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/ControlFramework"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"ControlFrameworkListEnvelope":{"additionalProperties":false,"properties":{"data":{"items":{"$ref":"#/components/schemas/ControlFramework"},"type":"array"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"ControlFrameworkTemplatePack":{"additionalProperties":false,"properties":{"controls":{"items":{"$ref":"#/components/schemas/SecurityControl"},"type":"array"},"description":{"type":"string"},"id":{"type":"string"},"name":{"type":"string"},"schema_version":{"type":"string"},"slug":{"type":"string"},"version":{"type":"string"}},"required":["id","name","slug","version","controls","schema_version"],"type":"object"},"ControlFrameworkTemplatePackListEnvelope":{"additionalProperties":false,"properties":{"data":{"items":{"$ref":"#/components/schemas/ControlFrameworkTemplatePack"},"type":"array"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"CosignVerification":{"additionalProperties":false,"properties":{"artifact_id":{"type":"string"},"artifact_signature_id":{"type":"string"},"certificate_identity":{"type":"string"},"certificate_issuer":{"type":"string"},"checks":{"items":{"$ref":"#/components/schemas/VerifyCheck"},"type":"array"},"container_image_id":{"type":"string"},"created_at":{"format":"date-time","type":"string"},"id":{"type":"string"},"rekor_log_index":{"type":"string"},"rekor_uuid":{"type":"string"},"result":{"enum":["passed","failed"],"type":"string"},"schema_version":{"type":"string"},"subject_digest":{"type":"string"},"tenant_id":{"type":"string"}},"required":["id","tenant_id","artifact_signature_id","subject_digest","result","checks","schema_version","created_at"],"type":"object"},"CosignVerificationEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/CosignVerification"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"CreateAPIKeyRequest":{"additionalProperties":false,"properties":{"expires_at":{"format":"date-time","type":"string"},"name":{"type":"string"},"scopes":{"items":{"type":"string"},"type":"array"}},"required":["name","scopes"],"type":"object"},"CreateAnomalyReportRequest":{"additionalProperties":false,"properties":{"subject_id":{"type":"string"},"subject_type":{"type":"string"}},"required":["subject_type","subject_id"],"type":"object"},"CreateApprovalRequest":{"additionalProperties":false,"properties":{"decision":{"enum":["approved","rejected","accepted"],"type":"string"},"evidence_id":{"type":"string"},"reason":{"type":"string"},"subject_id":{"type":"string"},"subject_type":{"type":"string"}},"required":["subject_type","subject_id","decision","reason"],"type":"object"},"CreateArtifactSignatureRequest":{"additionalProperties":false,"properties":{"algorithm":{"type":"string"},"artifact_id":{"type":"string"},"key_id":{"type":"string"},"payload":{"additionalProperties":true,"type":"object"},"payload_media_type":{"type":"string"},"signature":{"type":"string"}},"required":["artifact_id","algorithm","signature"],"type":"object"},"CreateBuildRequest":{"additionalProperties":false,"properties":{"commit_sha":{"type":"string"},"completed_at":{"format":"date-time","type":"string"},"github":{"type":"object"},"outputs":{"items":{"type":"object"},"type":"array"},"project_id":{"type":"string"},"provider":{"type":"string"},"release_id":{"type":"string"},"started_at":{"format":"date-time","type":"string"},"status":{"enum":["queued","running","passed","failed","cancelled"],"type":"string"}},"required":["project_id","release_id","provider","commit_sha","status","started_at"],"type":"object"},"CreateCollectorRequest":{"additionalProperties":false,"properties":{"name":{"type":"string"},"scopes":{"items":{"type":"string"},"type":"array"},"type":{"enum":["github_actions","gitlab_ci","generic_ci","import_bundle"],"type":"string"},"version":{"type":"string"}},"required":["name","type","version"],"type":"object"},"CreateCommercialCollectorRequest":{"additionalProperties":false,"properties":{"allowed_scopes":{"items":{"type":"string"},"type":"array"},"manifest_hash":{"pattern":"^sha256:","type":"string"},"name":{"type":"string"},"provider":{"type":"string"},"version":{"type":"string"}},"required":["name","provider","version","manifest_hash"],"type":"object"},"CreateControlFrameworkRequest":{"additionalProperties":false,"properties":{"description":{"type":"string"},"name":{"type":"string"},"slug":{"type":"string"},"version":{"type":"string"}},"required":["name","version"],"type":"object"},"CreateCustomPolicyRequest":{"additionalProperties":false,"properties":{"description":{"type":"string"},"name":{"type":"string"},"rules":{"items":{"$ref":"#/components/schemas/PolicyRule"},"type":"array"},"version":{"type":"string"}},"required":["name","version","rules"],"type":"object"},"CreateCustomerPackageRequest":{"additionalProperties":false,"properties":{"expires_at":{"format":"date-time","type":"string"},"product_id":{"type":"string"},"redaction_profile_id":{"type":"string"},"release_id":{"type":"string"},"title":{"type":"string"}},"required":["product_id","redaction_profile_id","title","expires_at"],"type":"object"},"CreateCustomerPortalAccessRequest":{"additionalProperties":false,"properties":{"customer_name":{"type":"string"},"expires_at":{"format":"date-time","type":"string"},"package_id":{"type":"string"},"require_nda":{"type":"boolean"},"reviewer_email":{"description":"Optional external reviewer email label. It is not used as an authentication secret.","type":"string"},"reviewer_name":{"description":"Optional external reviewer display name for this package access record.","type":"string"},"watermark":{"description":"Optional customer-visible distribution watermark. It is not a token or secret.","type":"string"}},"required":["package_id","customer_name","expires_at"],"type":"object"},"CreateDSSETrustRootRequest":{"additionalProperties":false,"properties":{"algorithm":{"enum":["Ed25519"],"type":"string"},"key_id":{"type":"string"},"name":{"type":"string"},"public_key":{"description":"Base64-encoded Ed25519 public key.","type":"string"}},"required":["name","key_id","algorithm","public_key"],"type":"object"},"CreateDeploymentEnvironmentRequest":{"additionalProperties":false,"properties":{"kind":{"type":"string"},"name":{"type":"string"},"product_id":{"type":"string"}},"required":["product_id","name","kind"],"type":"object"},"CreateEvidenceRequest":{"additionalProperties":false,"properties":{"build_id":{"type":"string"},"collector_id":{"type":"string"},"deployment_id":{"type":"string"},"limitations":{"items":{"type":"string"},"type":"array"},"metadata":{"additionalProperties":true,"type":"object"},"observed_at":{"format":"date-time","type":"string"},"payload_hash":{"pattern":"^sha256:","type":"string"},"payload_media_type":{"type":"string"},"payload_ref":{"type":"string"},"payload_size":{"minimum":0,"type":"integer"},"product_id":{"type":"string"},"project_id":{"type":"string"},"release_id":{"type":"string"},"source_identity":{"additionalProperties":true,"type":"object"},"source_system":{"type":"string"},"subject_refs":{"items":{"$ref":"#/components/schemas/SubjectRef"},"type":"array"},"subtype":{"type":"string"},"tags":{"items":{"type":"string"},"type":"array"},"title":{"type":"string"},"type":{"type":"string"}},"required":["type","title","payload_hash"],"type":"object"},"CreateEvidenceSummaryRequest":{"additionalProperties":false,"properties":{"evidence_ids":{"items":{"type":"string"},"type":"array"},"subject_id":{"type":"string"},"subject_type":{"type":"string"}},"required":["subject_type","subject_id","evidence_ids"],"type":"object"},"CreateExceptionRequest":{"additionalProperties":false,"properties":{"control_id":{"type":"string"},"expires_at":{"format":"date-time","type":"string"},"finding_id":{"type":"string"},"owner":{"type":"string"},"reason":{"type":"string"},"release_id":{"type":"string"}},"required":["release_id","reason","owner","expires_at"],"type":"object"},"CreateGraphSnapshotRequest":{"additionalProperties":false,"properties":{"product_id":{"type":"string"},"release_id":{"type":"string"}},"type":"object"},"CreateIncidentRequest":{"additionalProperties":false,"properties":{"opened_at":{"format":"date-time","type":"string"},"product_id":{"type":"string"},"release_id":{"type":"string"},"severity":{"enum":["low","medium","high","critical"],"type":"string"},"title":{"type":"string"}},"required":["product_id","title","severity"],"type":"object"},"CreateIncidentWebhookReceiverRequest":{"additionalProperties":false,"properties":{"name":{"type":"string"},"provider":{"type":"string"},"public_key":{"description":"Ed25519 public key used to verify signed incident webhook events.","type":"string"}},"required":["name","provider","public_key"],"type":"object"},"CreateLegalHoldRequest":{"additionalProperties":false,"properties":{"owner":{"type":"string"},"reason":{"type":"string"},"scope_id":{"type":"string"},"scope_type":{"type":"string"}},"required":["scope_type","scope_id","reason","owner"],"type":"object"},"CreateMarketplaceCollectorRequest":{"additionalProperties":false,"properties":{"manifest_hash":{"pattern":"^sha256:","type":"string"},"name":{"type":"string"},"provider":{"type":"string"},"publisher":{"type":"string"},"sbom_id":{"type":"string"},"scan_id":{"type":"string"},"signature_id":{"type":"string"},"version":{"type":"string"}},"required":["name","provider","version","publisher","manifest_hash"],"type":"object"},"CreateMerkleBatchRequest":{"additionalProperties":false,"properties":{"from_sequence":{"format":"int64","type":"integer"},"to_sequence":{"format":"int64","type":"integer"}},"type":"object"},"CreateObjectRetentionPolicyRequest":{"additionalProperties":false,"properties":{"mode":{"enum":["governance","compliance"],"type":"string"},"name":{"type":"string"},"object_key":{"description":"Optional tenant-prefixed sample object key used for object-level retention verification when supported by the object store.","type":"string"},"object_prefix":{"type":"string"},"require_legal_hold":{"description":"When true, the sample object key must have provider-reported legal hold enabled.","type":"boolean"},"retention_days":{"minimum":1,"type":"integer"}},"required":["name","mode","retention_days"],"type":"object"},"CreateOpenAPIDiffRequest":{"additionalProperties":false,"properties":{"base_contract_id":{"type":"string"},"release_id":{"type":"string"},"target_contract_id":{"type":"string"}},"required":["base_contract_id","target_contract_id","release_id"],"type":"object"},"CreateOrganizationRequest":{"additionalProperties":false,"properties":{"name":{"type":"string"},"slug":{"type":"string"}},"required":["name","slug"],"type":"object"},"CreatePDFReportPackageRequest":{"additionalProperties":false,"properties":{"product_id":{"type":"string"},"release_id":{"type":"string"},"report_type":{"type":"string"},"title":{"type":"string"}},"required":["report_type","title"],"type":"object"},"CreateProductRequest":{"additionalProperties":false,"properties":{"name":{"type":"string"},"slug":{"type":"string"}},"required":["name","slug"],"type":"object"},"CreateProjectRequest":{"additionalProperties":false,"properties":{"name":{"type":"string"},"product_id":{"type":"string"},"slug":{"type":"string"}},"required":["product_id","name","slug"],"type":"object"},"CreatePublicTransparencyLogRequest":{"additionalProperties":false,"properties":{"endpoint":{"type":"string"},"name":{"type":"string"},"public_key":{"type":"string"}},"required":["name","endpoint","public_key"],"type":"object"},"CreateQuestionnaireAnswerLibraryEntryRequest":{"additionalProperties":false,"properties":{"answer":{"type":"string"},"control_id":{"type":"string"},"evidence_ids":{"items":{"type":"string"},"type":"array"},"evidence_type":{"type":"string"},"limitations":{"items":{"type":"string"},"type":"array"},"product_id":{"type":"string"},"question_id":{"type":"string"},"release_id":{"type":"string"}},"required":["answer"],"type":"object"},"CreateQuestionnaireDraftRequest":{"additionalProperties":false,"properties":{"product_id":{"type":"string"},"release_id":{"type":"string"},"template_id":{"type":"string"}},"required":["template_id"],"type":"object"},"CreateQuestionnairePackageRequest":{"additionalProperties":false,"properties":{"package_id":{"type":"string"},"product_id":{"type":"string"},"release_id":{"type":"string"},"template_id":{"type":"string"}},"required":["template_id"],"type":"object"},"CreateQuestionnaireTemplateRequest":{"additionalProperties":false,"properties":{"name":{"type":"string"},"questions":{"items":{"$ref":"#/components/schemas/QuestionnaireQuestion"},"type":"array"},"version":{"type":"string"}},"required":["name","version","questions"],"type":"object"},"CreateRedactionProfileRequest":{"additionalProperties":false,"properties":{"allowed_types":{"items":{"type":"string"},"type":"array"},"description":{"type":"string"},"excluded_fields":{"items":{"type":"string"},"type":"array"},"name":{"type":"string"},"preset":{"description":"Optional standard profile preset. When set, allowed_types and excluded_fields are server-defined and must be omitted.","enum":["customer_safe","security_review"],"type":"string"}},"type":"object"},"CreateReleaseBundleRequest":{"additionalProperties":false,"properties":{"release_id":{"type":"string"}},"required":["release_id"],"type":"object"},"CreateReleaseCandidateRequest":{"additionalProperties":false,"properties":{"artifact_ids":{"items":{"type":"string"},"type":"array"},"build_ids":{"items":{"type":"string"},"type":"array"},"bundle_ids":{"items":{"type":"string"},"type":"array"},"contract_ids":{"items":{"type":"string"},"type":"array"},"name":{"type":"string"},"release_id":{"type":"string"},"sbom_ids":{"items":{"type":"string"},"type":"array"},"scan_ids":{"items":{"type":"string"},"type":"array"},"vex_ids":{"items":{"type":"string"},"type":"array"}},"required":["release_id","name"],"type":"object"},"CreateReleaseRequest":{"additionalProperties":false,"properties":{"product_id":{"type":"string"},"project_id":{"type":"string"},"version":{"type":"string"}},"required":["product_id","version"],"type":"object"},"CreateRemediationTaskRequest":{"additionalProperties":false,"properties":{"due_at":{"format":"date-time","type":"string"},"evidence_id":{"type":"string"},"incident_id":{"type":"string"},"owner":{"type":"string"},"release_id":{"type":"string"},"title":{"type":"string"}},"required":["title","owner"],"type":"object"},"CreateReportTemplateRequest":{"additionalProperties":false,"properties":{"allowed_fields":{"items":{"type":"string"},"type":"array"},"name":{"type":"string"},"report_type":{"type":"string"},"template":{"type":"string"},"version":{"type":"string"}},"required":["name","version","report_type","allowed_fields","template"],"type":"object"},"CreateRetentionOverrideRequest":{"additionalProperties":false,"properties":{"owner":{"type":"string"},"reason":{"type":"string"},"retention_until":{"format":"date-time","type":"string"},"scope_id":{"type":"string"},"scope_type":{"type":"string"}},"required":["scope_type","scope_id","retention_until","reason","owner"],"type":"object"},"CreateRoleBindingRequest":{"additionalProperties":false,"properties":{"resource_id":{"type":"string"},"resource_type":{"type":"string"},"role":{"type":"string"},"subject_id":{"type":"string"},"subject_type":{"enum":["user","collector"],"type":"string"}},"required":["subject_type","subject_id","role"],"type":"object"},"CreateSBOMDiffRequest":{"additionalProperties":false,"properties":{"base_sbom_id":{"type":"string"},"release_id":{"type":"string"},"target_sbom_id":{"type":"string"}},"required":["base_sbom_id","target_sbom_id"],"type":"object"},"CreateSSOProviderRequest":{"additionalProperties":false,"properties":{"client_id":{"type":"string"},"groups_claim":{"type":"string"},"issuer":{"type":"string"},"jwks":{"description":"Optional static JWKS public-key material for local OIDC ID-token verification. Private keys and provider secrets must not be supplied.","type":"object"},"name":{"type":"string"},"role_mapping":{"additionalProperties":{"type":"string"},"type":"object"},"saml_signing_certificates":{"description":"Optional PEM-encoded SAML assertion signing certificates. Private keys and provider secrets must not be supplied.","items":{"type":"string"},"type":"array"},"type":{"enum":["oidc","saml"],"type":"string"}},"required":["name","type","issuer","client_id"],"type":"object"},"CreateSSOSessionRequest":{"additionalProperties":false,"properties":{"expires_at":{"format":"date-time","type":"string"},"provider_id":{"type":"string"},"user_id":{"type":"string"}},"required":["user_id","provider_id","expires_at"],"type":"object"},"CreateSaaSEditionProfileRequest":{"additionalProperties":false,"properties":{"admin_tenant_id":{"type":"string"},"isolation_model":{"type":"string"},"name":{"type":"string"},"region":{"type":"string"}},"required":["name","region","admin_tenant_id","isolation_model"],"type":"object"},"CreateSecurityControlRequest":{"additionalProperties":false,"properties":{"applicability":{"items":{"type":"string"},"type":"array"},"code":{"type":"string"},"evidence_requirements":{"items":{"$ref":"#/components/schemas/ControlEvidenceRequirement"},"type":"array"},"framework_id":{"type":"string"},"limitations":{"items":{"type":"string"},"type":"array"},"objective":{"type":"string"},"title":{"type":"string"}},"required":["framework_id","code","title","objective"],"type":"object"},"CreateSigningOperationRequest":{"additionalProperties":false,"properties":{"external_signature":{"description":"Optional when a server-side signing executor is configured. The executor signs payload_hash and Evydence records only the returned signature receipt.","type":"string"},"payload_hash":{"pattern":"^sha256:","type":"string"},"provider_id":{"type":"string"},"subject_id":{"type":"string"},"subject_type":{"type":"string"}},"required":["provider_id","subject_type","subject_id","payload_hash"],"type":"object"},"CreateSigningProviderRequest":{"additionalProperties":false,"properties":{"encrypted":{"type":"boolean"},"key_ref":{"type":"string"},"name":{"type":"string"},"type":{"type":"string"}},"required":["name","type","key_ref"],"type":"object"},"CreateSourceRepositoryRequest":{"additionalProperties":false,"properties":{"clone_url":{"type":"string"},"default_branch":{"type":"string"},"full_name":{"type":"string"},"project_id":{"type":"string"},"provider":{"type":"string"}},"required":["provider","full_name"],"type":"object"},"CreateTransparencyCheckpointRequest":{"additionalProperties":false,"properties":{"batch_id":{"type":"string"},"external_id":{"type":"string"},"external_url":{"type":"string"},"provider":{"type":"string"}},"required":["batch_id","provider"],"type":"object"},"CreateUserRequest":{"additionalProperties":false,"properties":{"display_name":{"type":"string"},"email":{"format":"email","type":"string"},"organization_id":{"type":"string"}},"required":["email","display_name"],"type":"object"},"CreateVulnerabilityDecisionRequest":{"additionalProperties":false,"properties":{"action_statement":{"type":"string"},"customer_visible":{"type":"boolean"},"evidence_ids":{"items":{"type":"string"},"type":"array"},"impact_statement":{"type":"string"},"internal_notes":{"description":"Tenant-internal notes; excluded from customer-safe package summaries.","type":"string"},"justification":{"type":"string"},"review_due_at":{"description":"Optional UTC time when this decision should be reviewed again.","format":"date-time","type":"string"},"reviewed_at":{"description":"Optional UTC time when the decision was reviewed. Defaults to creation time.","format":"date-time","type":"string"},"status":{"enum":["affected","not_affected","fixed","under_investigation"],"type":"string"},"supporting_refs":{"description":"Optional tenant-scoped first-class decision support records from the same release. Supported types are approval, exception, waiver, remediation_task, release_bundle, and incident.","items":{"$ref":"#/components/schemas/SubjectRef"},"type":"array"},"vex_document_id":{"description":"Optional tenant-scoped VEX document from the same release to link to this manual decision.","type":"string"}},"required":["status","justification"],"type":"object"},"CreateWaiverRequest":{"additionalProperties":false,"properties":{"control_id":{"type":"string"},"expires_at":{"format":"date-time","type":"string"},"owner":{"type":"string"},"policy_id":{"type":"string"},"reason":{"type":"string"},"risk":{"type":"string"},"scope_id":{"type":"string"},"scope_type":{"type":"string"},"supersedes":{"type":"string"}},"required":["scope_type","scope_id","owner","risk","reason","expires_at"],"type":"object"},"CustomPolicy":{"additionalProperties":false,"properties":{"created_at":{"format":"date-time","type":"string"},"description":{"type":"string"},"id":{"type":"string"},"name":{"type":"string"},"rules":{"items":{"$ref":"#/components/schemas/PolicyRule"},"type":"array"},"schema_version":{"type":"string"},"tenant_id":{"type":"string"},"version":{"type":"string"}},"required":["id","tenant_id","name","version","rules","schema_version","created_at"],"type":"object"},"CustomPolicyEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/CustomPolicy"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"CustomPolicyEvaluation":{"additionalProperties":false,"properties":{"checks":{"items":{"$ref":"#/components/schemas/PolicyCheck"},"type":"array"},"created_at":{"format":"date-time","type":"string"},"id":{"type":"string"},"input_hash":{"pattern":"^sha256:","type":"string"},"policy_id":{"type":"string"},"release_id":{"type":"string"},"result":{"enum":["passed","failed"],"type":"string"},"schema_version":{"type":"string"},"tenant_id":{"type":"string"}},"required":["id","tenant_id","policy_id","release_id","result","checks","input_hash","schema_version","created_at"],"type":"object"},"CustomPolicyEvaluationEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/CustomPolicyEvaluation"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"CustomReportTemplate":{"additionalProperties":false,"properties":{"allowed_fields":{"items":{"type":"string"},"type":"array"},"created_at":{"format":"date-time","type":"string"},"id":{"type":"string"},"name":{"type":"string"},"report_type":{"type":"string"},"schema_version":{"type":"string"},"template":{"type":"string"},"tenant_id":{"type":"string"},"version":{"type":"string"}},"required":["id","tenant_id","name","version","report_type","allowed_fields","template","schema_version","created_at"],"type":"object"},"CustomReportTemplateEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/CustomReportTemplate"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"CustomerPortalAccess":{"additionalProperties":false,"properties":{"access_count":{"type":"integer"},"created_at":{"format":"date-time","type":"string"},"customer_name":{"type":"string"},"expires_at":{"format":"date-time","type":"string"},"failed_access_count":{"type":"integer"},"id":{"type":"string"},"last_accessed_at":{"format":"date-time","type":"string"},"last_failed_at":{"format":"date-time","type":"string"},"nda_accepted_at":{"format":"date-time","type":"string"},"nda_accepted_by":{"type":"string"},"package_id":{"type":"string"},"prefix":{"description":"Non-secret portal token prefix for operational lookup.","type":"string"},"require_nda":{"type":"boolean"},"reviewer_email":{"type":"string"},"reviewer_name":{"type":"string"},"revoked_at":{"format":"date-time","type":"string"},"schema_version":{"type":"string"},"tenant_id":{"type":"string"},"watermark":{"type":"string"}},"required":["id","tenant_id","package_id","customer_name","prefix","expires_at","access_count","failed_access_count","schema_version","created_at"],"type":"object"},"CustomerPortalAccessCreateEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/CustomerPortalAccessCreateResponse"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"CustomerPortalAccessCreateResponse":{"additionalProperties":false,"properties":{"access":{"$ref":"#/components/schemas/CustomerPortalAccess"},"secret":{"description":"One-time portal token; stored only as a HMAC hash.","type":"string"}},"required":["access","secret"],"type":"object"},"CustomerPortalAccessEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/CustomerPortalAccess"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"CustomerPortalAccessListEnvelope":{"additionalProperties":false,"properties":{"data":{"items":{"$ref":"#/components/schemas/CustomerPortalAccess"},"type":"array"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"CustomerPortalPackageRequest":{"additionalProperties":false,"properties":{"nda_accepted":{"description":"Set true to record acceptance for NDA-gated portal access.","type":"boolean"},"nda_accepted_by":{"description":"Reviewer label recorded when NDA acceptance is required. Do not include secrets.","type":"string"},"token":{"description":"Customer portal access token issued by createCustomerPortalAccess.","type":"string"}},"required":["token"],"type":"object"},"CustomerSecurityPackage":{"additionalProperties":false,"properties":{"access_count":{"type":"integer"},"created_at":{"format":"date-time","type":"string"},"distribution_watermark":{"type":"string"},"expires_at":{"format":"date-time","type":"string"},"id":{"type":"string"},"manifest":{"additionalProperties":true,"type":"object"},"manifest_hash":{"pattern":"^sha256:","type":"string"},"product_id":{"type":"string"},"redaction_profile_id":{"type":"string"},"release_id":{"type":"string"},"schema_version":{"type":"string"},"state":{"type":"string"},"tenant_id":{"type":"string"},"title":{"type":"string"}},"required":["id","tenant_id","product_id","redaction_profile_id","title","state","manifest","manifest_hash","expires_at","access_count","schema_version","created_at"],"type":"object"},"CustomerSecurityPackageEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/CustomerSecurityPackage"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"DSSEEnvelope":{"additionalProperties":false,"properties":{"payload":{"type":"string"},"payloadType":{"type":"string"},"signatures":{"items":{"additionalProperties":false,"properties":{"keyid":{"type":"string"},"sig":{"type":"string"}},"required":["sig"],"type":"object"},"type":"array"}},"required":["payloadType","payload","signatures"],"type":"object"},"DSSETrustRoot":{"additionalProperties":false,"properties":{"algorithm":{"type":"string"},"created_at":{"format":"date-time","type":"string"},"id":{"type":"string"},"key_id":{"type":"string"},"name":{"type":"string"},"public_key":{"type":"string"},"schema_version":{"type":"string"},"status":{"type":"string"},"tenant_id":{"type":"string"}},"required":["id","tenant_id","name","key_id","algorithm","public_key","status","schema_version","created_at"],"type":"object"},"DSSETrustRootEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/DSSETrustRoot"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"DataEnvelope":{"additionalProperties":false,"properties":{"data":{},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"DependencyChange":{"additionalProperties":false,"properties":{"change_type":{"enum":["added","removed","changed"],"type":"string"},"component":{"$ref":"#/components/schemas/SBOMComponent"},"created_at":{"format":"date-time","type":"string"},"id":{"type":"string"},"sbom_diff_id":{"type":"string"},"schema_version":{"type":"string"},"tenant_id":{"type":"string"}},"required":["id","tenant_id","sbom_diff_id","change_type","component","schema_version","created_at"],"type":"object"},"DeploymentEnvironment":{"additionalProperties":false,"properties":{"created_at":{"format":"date-time","type":"string"},"id":{"type":"string"},"kind":{"type":"string"},"name":{"type":"string"},"product_id":{"type":"string"},"schema_version":{"type":"string"},"tenant_id":{"type":"string"}},"required":["id","tenant_id","product_id","name","kind","schema_version","created_at"],"type":"object"},"DeploymentEnvironmentEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/DeploymentEnvironment"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"DeploymentEnvironmentListEnvelope":{"additionalProperties":false,"properties":{"data":{"items":{"$ref":"#/components/schemas/DeploymentEnvironment"},"type":"array"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"DeploymentEvent":{"additionalProperties":false,"properties":{"artifact_ids":{"items":{"type":"string"},"type":"array"},"created_at":{"format":"date-time","type":"string"},"environment_id":{"type":"string"},"evidence_id":{"type":"string"},"finished_at":{"format":"date-time","type":"string"},"id":{"type":"string"},"release_id":{"type":"string"},"rollback_of":{"type":"string"},"schema_version":{"type":"string"},"started_at":{"format":"date-time","type":"string"},"status":{"type":"string"},"tenant_id":{"type":"string"}},"required":["id","tenant_id","environment_id","release_id","status","started_at","schema_version","created_at"],"type":"object"},"DeploymentEventEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/DeploymentEvent"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"DeploymentEventListEnvelope":{"additionalProperties":false,"properties":{"data":{"items":{"$ref":"#/components/schemas/DeploymentEvent"},"type":"array"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"EmptyObject":{"additionalProperties":false,"properties":{},"type":"object"},"EvaluatePolicyRequest":{"additionalProperties":false,"properties":{"release_id":{"type":"string"}},"required":["release_id"],"type":"object"},"EvidenceBundle":{"additionalProperties":false,"properties":{"created_at":{"format":"date-time","type":"string"},"evidence_ids":{"items":{"type":"string"},"type":"array"},"id":{"type":"string"},"manifest":{"additionalProperties":true,"type":"object"},"manifest_hash":{"pattern":"^sha256:","type":"string"},"release_id":{"type":"string"},"schema_version":{"type":"string"},"signature_refs":{"items":{"type":"string"},"type":"array"},"tenant_id":{"type":"string"},"verification_text":{"type":"string"}},"required":["id","tenant_id","evidence_ids","manifest","manifest_hash","verification_text","schema_version","created_at"],"type":"object"},"EvidenceBundleEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/EvidenceBundle"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"EvidenceBundleImport":{"additionalProperties":false,"properties":{"bundle_hash":{"pattern":"^sha256:","type":"string"},"created_at":{"format":"date-time","type":"string"},"id":{"type":"string"},"imported_count":{"type":"integer"},"result":{"type":"string"},"schema_version":{"type":"string"},"tenant_id":{"type":"string"}},"required":["id","tenant_id","bundle_hash","result","imported_count","schema_version","created_at"],"type":"object"},"EvidenceBundleImportEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/EvidenceBundleImport"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"EvidenceCitation":{"additionalProperties":false,"properties":{"canonical_hash":{"pattern":"^sha256:","type":"string"},"evidence_id":{"type":"string"},"title":{"type":"string"},"type":{"type":"string"}},"required":["evidence_id","type","title","canonical_hash"],"type":"object"},"EvidenceGraphEdge":{"additionalProperties":false,"properties":{"from":{"type":"string"},"relationship":{"type":"string"},"to":{"type":"string"}},"required":["from","to","relationship"],"type":"object"},"EvidenceGraphNode":{"additionalProperties":false,"properties":{"id":{"type":"string"},"label":{"type":"string"},"type":{"type":"string"}},"required":["id","type","label"],"type":"object"},"EvidenceGraphSnapshot":{"additionalProperties":false,"properties":{"created_at":{"format":"date-time","type":"string"},"edges":{"items":{"$ref":"#/components/schemas/EvidenceGraphEdge"},"type":"array"},"graph_hash":{"pattern":"^sha256:","type":"string"},"id":{"type":"string"},"limitations":{"items":{"type":"string"},"type":"array"},"nodes":{"items":{"$ref":"#/components/schemas/EvidenceGraphNode"},"type":"array"},"product_id":{"type":"string"},"release_id":{"type":"string"},"schema_version":{"type":"string"},"tenant_id":{"type":"string"}},"required":["id","tenant_id","nodes","edges","graph_hash","limitations","schema_version","created_at"],"type":"object"},"EvidenceGraphSnapshotEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/EvidenceGraphSnapshot"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"EvidenceItem":{"additionalProperties":false,"properties":{"build_id":{"type":"string"},"canonical_hash":{"pattern":"^sha256:","type":"string"},"canonicalization":{"type":"string"},"chain_entry_id":{"type":"string"},"collector_id":{"type":"string"},"created_at":{"format":"date-time","type":"string"},"deployment_id":{"type":"string"},"evidence_version":{"type":"integer"},"id":{"type":"string"},"limitations":{"items":{"type":"string"},"type":"array"},"metadata":{"additionalProperties":true,"type":"object"},"observed_at":{"format":"date-time","type":"string"},"payload_hash":{"pattern":"^sha256:","type":"string"},"payload_media_type":{"type":"string"},"payload_ref":{"type":"string"},"payload_size":{"type":"integer"},"product_id":{"type":"string"},"project_id":{"type":"string"},"related_evidence_refs":{"items":{"$ref":"#/components/schemas/EvidenceRef"},"type":"array"},"release_id":{"type":"string"},"schema_version":{"type":"string"},"signature_refs":{"items":{"type":"string"},"type":"array"},"source_identity":{"additionalProperties":true,"type":"object"},"source_system":{"type":"string"},"subject_refs":{"items":{"$ref":"#/components/schemas/SubjectRef"},"type":"array"},"subtype":{"type":"string"},"superseded_by":{"type":"string"},"supersedes":{"type":"string"},"tags":{"items":{"type":"string"},"type":"array"},"tenant_id":{"type":"string"},"title":{"type":"string"},"trust_level":{"type":"string"},"type":{"type":"string"},"uploaded_by":{"type":"string"},"verification_status":{"type":"string"},"warnings":{"items":{"$ref":"#/components/schemas/EvidenceNotice"},"type":"array"}},"required":["id","tenant_id","type","title","source_system","observed_at","evidence_version","schema_version","payload_hash","canonical_hash","canonicalization","trust_level","verification_status","chain_entry_id","created_at"],"type":"object"},"EvidenceItemEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/EvidenceItem"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"EvidenceItemListEnvelope":{"additionalProperties":false,"properties":{"data":{"items":{"$ref":"#/components/schemas/EvidenceItem"},"type":"array"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"EvidenceLifecycleEvent":{"additionalProperties":false,"properties":{"action":{"type":"string"},"actor_id":{"type":"string"},"created_at":{"format":"date-time","type":"string"},"details":{"additionalProperties":true,"type":"object"},"evidence_id":{"type":"string"},"id":{"type":"string"},"reason":{"type":"string"},"replacement_id":{"type":"string"},"schema_version":{"type":"string"},"tenant_id":{"type":"string"}},"required":["id","tenant_id","evidence_id","action","reason","actor_id","schema_version","created_at"],"type":"object"},"EvidenceLifecycleEventEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/EvidenceLifecycleEvent"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"EvidenceLifecycleEventListEnvelope":{"additionalProperties":false,"properties":{"data":{"items":{"$ref":"#/components/schemas/EvidenceLifecycleEvent"},"type":"array"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"EvidenceNotice":{"additionalProperties":false,"properties":{"code":{"type":"string"},"message":{"type":"string"}},"required":["code","message"],"type":"object"},"EvidenceRef":{"additionalProperties":false,"properties":{"id":{"type":"string"},"relationship":{"type":"string"},"type":{"type":"string"}},"required":["type","id"],"type":"object"},"EvidenceSearchEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/EvidenceSearchResponse"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"EvidenceSearchResponse":{"additionalProperties":false,"properties":{"items":{"items":{"$ref":"#/components/schemas/EvidenceItem"},"type":"array"},"next_cursor":{"type":"string"}},"required":["items"],"type":"object"},"EvidenceSummary":{"additionalProperties":false,"properties":{"assumptions":{"items":{"type":"string"},"type":"array"},"citations":{"items":{"$ref":"#/components/schemas/EvidenceCitation"},"type":"array"},"created_at":{"format":"date-time","type":"string"},"evidence_ids":{"items":{"type":"string"},"type":"array"},"id":{"type":"string"},"limitations":{"items":{"type":"string"},"type":"array"},"schema_version":{"type":"string"},"subject_id":{"type":"string"},"subject_type":{"type":"string"},"summary":{"type":"string"},"tenant_id":{"type":"string"}},"required":["id","tenant_id","subject_type","subject_id","evidence_ids","summary","citations","assumptions","limitations","schema_version","created_at"],"type":"object"},"EvidenceSummaryEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/EvidenceSummary"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"EvidenceUploadRequest":{"additionalProperties":false,"properties":{"artifact_id":{"type":"string"},"payload":{"type":"object"},"release_id":{"type":"string"}},"required":["release_id","payload"],"type":"object"},"Exception":{"additionalProperties":false,"properties":{"approved":{"type":"boolean"},"approved_at":{"format":"date-time","type":"string"},"approved_by":{"type":"string"},"control_id":{"type":"string"},"created_at":{"format":"date-time","type":"string"},"expires_at":{"format":"date-time","type":"string"},"finding_id":{"type":"string"},"id":{"type":"string"},"owner":{"type":"string"},"reason":{"type":"string"},"release_id":{"type":"string"},"tenant_id":{"type":"string"}},"required":["id","tenant_id","release_id","reason","owner","expires_at","approved","created_at"],"type":"object"},"ExceptionEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/Exception"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"ExceptionListEnvelope":{"additionalProperties":false,"properties":{"data":{"items":{"$ref":"#/components/schemas/Exception"},"type":"array"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"ExchangeSSOCredentialRequest":{"additionalProperties":false,"properties":{"expires_at":{"description":"Optional session expiry, capped by the server.","format":"date-time","type":"string"},"id_token":{"description":"OIDC ID token verified locally against configured public JWKS trust material.","type":"string"},"provider_id":{"type":"string"},"saml_assertion":{"description":"SAML assertion verified locally against configured SAML signing certificates.","type":"string"},"subject":{"type":"string"}},"required":["provider_id","subject"],"type":"object"},"ExportEvidenceBundleRequest":{"additionalProperties":false,"properties":{"evidence_ids":{"items":{"type":"string"},"type":"array"},"release_id":{"type":"string"}},"type":"object"},"HTMLReportPackage":{"additionalProperties":false,"properties":{"created_at":{"format":"date-time","type":"string"},"hash":{"pattern":"^sha256:","type":"string"},"html":{"type":"string"},"id":{"type":"string"},"product_id":{"type":"string"},"release_id":{"type":"string"},"report_type":{"type":"string"},"schema_version":{"type":"string"},"tenant_id":{"type":"string"}},"required":["id","tenant_id","report_type","product_id","html","hash","schema_version","created_at"],"type":"object"},"HTMLReportPackageEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/HTMLReportPackage"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"HealthStatus":{"additionalProperties":false,"properties":{"status":{"enum":["ok"],"type":"string"}},"required":["status"],"type":"object"},"HealthStatusEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/HealthStatus"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"HumanUser":{"additionalProperties":false,"properties":{"created_at":{"format":"date-time","type":"string"},"deactivated_at":{"format":"date-time","type":"string"},"display_name":{"type":"string"},"email":{"format":"email","type":"string"},"id":{"type":"string"},"organization_id":{"type":"string"},"schema_version":{"type":"string"},"status":{"type":"string"},"tenant_id":{"type":"string"}},"required":["id","tenant_id","email","display_name","status","schema_version","created_at"],"type":"object"},"HumanUserEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/HumanUser"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"Incident":{"additionalProperties":false,"properties":{"closed_at":{"format":"date-time","type":"string"},"created_at":{"format":"date-time","type":"string"},"id":{"type":"string"},"opened_at":{"format":"date-time","type":"string"},"product_id":{"type":"string"},"release_id":{"type":"string"},"schema_version":{"type":"string"},"severity":{"type":"string"},"status":{"type":"string"},"tenant_id":{"type":"string"},"title":{"type":"string"}},"required":["id","tenant_id","product_id","title","severity","status","opened_at","schema_version","created_at"],"type":"object"},"IncidentEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/Incident"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"IncidentReport":{"additionalProperties":false,"properties":{"assumptions":{"items":{"type":"string"},"type":"array"},"generated_at":{"format":"date-time","type":"string"},"incident_id":{"type":"string"},"limitations":{"items":{"type":"string"},"type":"array"},"linked_evidence":{"items":{"type":"string"},"type":"array"},"report_type":{"type":"string"},"result":{"type":"string"},"tasks":{"items":{"$ref":"#/components/schemas/RemediationTask"},"type":"array"},"template_version":{"type":"string"},"timeline":{"items":{"$ref":"#/components/schemas/IncidentTimelineEvent"},"type":"array"}},"required":["report_type","template_version","incident_id","result","timeline","tasks","assumptions","limitations","generated_at"],"type":"object"},"IncidentReportEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/IncidentReport"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"IncidentTimelineEvent":{"additionalProperties":false,"properties":{"created_at":{"format":"date-time","type":"string"},"event_type":{"type":"string"},"evidence_id":{"type":"string"},"id":{"type":"string"},"incident_id":{"type":"string"},"occurred_at":{"format":"date-time","type":"string"},"schema_version":{"type":"string"},"summary":{"type":"string"},"tenant_id":{"type":"string"}},"required":["id","tenant_id","incident_id","event_type","summary","occurred_at","schema_version","created_at"],"type":"object"},"IncidentTimelineEventEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/IncidentTimelineEvent"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"IncidentWebhookDelivery":{"additionalProperties":false,"properties":{"timeline_event":{"$ref":"#/components/schemas/IncidentTimelineEvent"},"webhook_event":{"$ref":"#/components/schemas/IncidentWebhookEvent"}},"required":["webhook_event","timeline_event"],"type":"object"},"IncidentWebhookDeliveryEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/IncidentWebhookDelivery"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"IncidentWebhookEvent":{"additionalProperties":false,"properties":{"created_at":{"format":"date-time","type":"string"},"event_id":{"type":"string"},"id":{"type":"string"},"incident_id":{"type":"string"},"payload_hash":{"pattern":"^sha256:","type":"string"},"provider":{"type":"string"},"receiver_id":{"type":"string"},"result":{"type":"string"},"schema_version":{"type":"string"},"signature_hash":{"pattern":"^sha256:","type":"string"},"tenant_id":{"type":"string"},"timeline_event_id":{"type":"string"}},"required":["id","tenant_id","receiver_id","incident_id","provider","event_id","payload_hash","signature_hash","result","schema_version","created_at"],"type":"object"},"IncidentWebhookReceiver":{"additionalProperties":false,"properties":{"created_at":{"format":"date-time","type":"string"},"id":{"type":"string"},"incident_id":{"type":"string"},"name":{"type":"string"},"provider":{"type":"string"},"public_key":{"type":"string"},"schema_version":{"type":"string"},"status":{"type":"string"},"tenant_id":{"type":"string"}},"required":["id","tenant_id","incident_id","name","provider","public_key","status","schema_version","created_at"],"type":"object"},"IncidentWebhookReceiverEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/IncidentWebhookReceiver"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"InstanceAdminSnapshot":{"additionalProperties":false,"properties":{"generated_at":{"format":"date-time","type":"string"},"limitations":{"items":{"type":"string"},"type":"array"},"report_type":{"type":"string"},"resource_counts":{"additionalProperties":{"type":"integer"},"type":"object"},"tenant_count":{"type":"integer"}},"required":["report_type","tenant_count","resource_counts","limitations","generated_at"],"type":"object"},"InstanceAdminSnapshotEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/InstanceAdminSnapshot"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"LegalHold":{"additionalProperties":false,"properties":{"created_at":{"format":"date-time","type":"string"},"id":{"type":"string"},"owner":{"type":"string"},"reason":{"type":"string"},"released_at":{"format":"date-time","type":"string"},"schema_version":{"type":"string"},"scope_id":{"type":"string"},"scope_type":{"type":"string"},"tenant_id":{"type":"string"}},"required":["id","tenant_id","scope_type","scope_id","reason","owner","schema_version","created_at"],"type":"object"},"LegalHoldEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/LegalHold"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"LinkControlEvidenceRequest":{"additionalProperties":false,"properties":{"confidence":{"enum":["high","medium","low","unsupported"],"type":"string"},"evidence_type":{"type":"string"},"notes":{"type":"string"},"product_id":{"type":"string"},"release_id":{"type":"string"},"subject_id":{"type":"string"},"subject_type":{"type":"string"}},"required":["evidence_type","subject_type","subject_id","confidence"],"type":"object"},"LinkEvidenceRequest":{"additionalProperties":false,"properties":{"target_id":{"type":"string"},"target_type":{"type":"string"}},"required":["target_type","target_id"],"type":"object"},"LinkSSOIdentityRequest":{"additionalProperties":false,"properties":{"email":{"format":"email","type":"string"},"provider_id":{"type":"string"},"subject":{"type":"string"},"user_id":{"type":"string"},"verified":{"type":"boolean"}},"required":["user_id","provider_id","subject","email","verified"],"type":"object"},"ManualSecurityDocument":{"additionalProperties":false,"properties":{"created_at":{"format":"date-time","type":"string"},"document_type":{"type":"string"},"evidence_id":{"type":"string"},"id":{"type":"string"},"payload_hash":{"pattern":"^sha256:","type":"string"},"payload_ref":{"type":"string"},"product_id":{"type":"string"},"release_id":{"type":"string"},"schema_version":{"type":"string"},"sensitivity":{"type":"string"},"tenant_id":{"type":"string"},"title":{"type":"string"}},"required":["id","tenant_id","document_type","title","sensitivity","evidence_id","payload_hash","schema_version","created_at"],"type":"object"},"ManualSecurityDocumentEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/ManualSecurityDocument"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"MarketplaceCollector":{"additionalProperties":false,"properties":{"created_at":{"format":"date-time","type":"string"},"id":{"type":"string"},"limitations":{"items":{"type":"string"},"type":"array"},"manifest_hash":{"pattern":"^sha256:","type":"string"},"name":{"type":"string"},"provider":{"type":"string"},"publisher":{"type":"string"},"sbom_id":{"type":"string"},"scan_id":{"type":"string"},"schema_version":{"type":"string"},"signature_id":{"type":"string"},"state":{"type":"string"},"tenant_id":{"type":"string"},"version":{"type":"string"}},"required":["id","tenant_id","name","provider","version","publisher","manifest_hash","state","schema_version","created_at"],"type":"object"},"MarketplaceCollectorEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/MarketplaceCollector"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"MarketplaceCollectorHealthReport":{"additionalProperties":false,"properties":{"assumptions":{"items":{"type":"string"},"type":"array"},"checks":{"items":{"$ref":"#/components/schemas/VerifyCheck"},"type":"array"},"collector":{"$ref":"#/components/schemas/MarketplaceCollector"},"collector_id":{"type":"string"},"generated_at":{"format":"date-time","type":"string"},"limitations":{"items":{"type":"string"},"type":"array"},"name":{"type":"string"},"provider":{"type":"string"},"report_type":{"type":"string"},"supply_chain_status":{"type":"string"},"version":{"type":"string"}},"required":["report_type","collector_id","name","provider","version","supply_chain_status","checks","collector","assumptions","limitations","generated_at"],"type":"object"},"MarketplaceCollectorHealthReportEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/MarketplaceCollectorHealthReport"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"MarketplaceCollectorListEnvelope":{"additionalProperties":false,"properties":{"data":{"items":{"$ref":"#/components/schemas/MarketplaceCollector"},"type":"array"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"MerkleBatch":{"additionalProperties":false,"properties":{"created_at":{"format":"date-time","type":"string"},"entry_count":{"type":"integer"},"from_sequence":{"format":"int64","type":"integer"},"id":{"type":"string"},"leaf_hashes":{"items":{"pattern":"^sha256:","type":"string"},"type":"array"},"root_hash":{"pattern":"^sha256:","type":"string"},"schema_version":{"type":"string"},"signature_refs":{"items":{"type":"string"},"type":"array"},"tenant_id":{"type":"string"},"to_sequence":{"format":"int64","type":"integer"}},"required":["id","tenant_id","from_sequence","to_sequence","entry_count","leaf_hashes","root_hash","schema_version","created_at"],"type":"object"},"MerkleBatchEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/MerkleBatch"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"MetricsSnapshot":{"additionalProperties":false,"properties":{"customer_portal_failed_access_count":{"type":"integer"},"customer_portal_revoked_access_count":{"type":"integer"},"resource_counts":{"additionalProperties":{"type":"integer"},"type":"object"},"tenant_id":{"type":"string"}},"required":["tenant_id","resource_counts","customer_portal_failed_access_count","customer_portal_revoked_access_count"],"type":"object"},"MetricsSnapshotEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/MetricsSnapshot"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"MissingEvidenceReport":{"additionalProperties":false,"properties":{"assumptions":{"items":{"type":"string"},"type":"array"},"limitations":{"items":{"type":"string"},"type":"array"},"missing":{"items":{"type":"string"},"type":"array"},"release_id":{"type":"string"},"report_type":{"type":"string"},"result":{"enum":["passed","failed"],"type":"string"},"template_version":{"type":"string"}},"required":["report_type","template_version","release_id","result","missing","assumptions","limitations"],"type":"object"},"MissingEvidenceReportEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/MissingEvidenceReport"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"ObjectRetentionPolicy":{"additionalProperties":false,"properties":{"created_at":{"format":"date-time","type":"string"},"id":{"type":"string"},"mode":{"type":"string"},"name":{"type":"string"},"object_key":{"type":"string"},"object_prefix":{"type":"string"},"require_legal_hold":{"type":"boolean"},"retention_days":{"type":"integer"},"schema_version":{"type":"string"},"status":{"type":"string"},"tenant_id":{"type":"string"},"verification_checks":{"items":{"$ref":"#/components/schemas/VerifyCheck"},"type":"array"},"verification_hash":{"pattern":"^sha256:","type":"string"},"verification_limitations":{"items":{"type":"string"},"type":"array"},"verified_at":{"format":"date-time","type":"string"}},"required":["id","tenant_id","name","object_prefix","mode","retention_days","status","schema_version","created_at"],"type":"object"},"ObjectRetentionPolicyEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/ObjectRetentionPolicy"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"OpenAPIContract":{"additionalProperties":false,"properties":{"created_at":{"format":"date-time","type":"string"},"evidence_id":{"type":"string"},"hash":{"pattern":"^sha256:","type":"string"},"id":{"type":"string"},"operations":{"items":{"$ref":"#/components/schemas/OpenAPIOperationRecord"},"type":"array"},"path_count":{"type":"integer"},"product_id":{"type":"string"},"release_id":{"type":"string"},"tenant_id":{"type":"string"},"version":{"type":"string"}},"required":["id","tenant_id","product_id","version","hash","path_count","evidence_id","created_at"],"type":"object"},"OpenAPIContractEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/OpenAPIContract"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"OpenAPIDocument":{"additionalProperties":true,"properties":{"components":{"additionalProperties":true,"type":"object"},"info":{"additionalProperties":true,"type":"object"},"openapi":{"type":"string"},"paths":{"additionalProperties":true,"type":"object"}},"required":["openapi","info","paths"],"type":"object"},"OpenAPIOperationRecord":{"additionalProperties":false,"properties":{"deprecated":{"type":"boolean"},"method":{"type":"string"},"operation_id":{"type":"string"},"path":{"type":"string"},"request_body_required":{"type":"boolean"},"required_request_fields":{"items":{"type":"string"},"type":"array"},"response_statuses":{"items":{"type":"string"},"type":"array"}},"required":["path","method"],"type":"object"},"Organization":{"additionalProperties":false,"properties":{"created_at":{"format":"date-time","type":"string"},"id":{"type":"string"},"name":{"type":"string"},"schema_version":{"type":"string"},"slug":{"type":"string"},"status":{"type":"string"},"tenant_id":{"type":"string"}},"required":["id","tenant_id","name","slug","status","schema_version","created_at"],"type":"object"},"OrganizationEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/Organization"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"PDFReportPackage":{"additionalProperties":false,"properties":{"created_at":{"format":"date-time","type":"string"},"id":{"type":"string"},"limitations":{"items":{"type":"string"},"type":"array"},"payload_hash":{"pattern":"^sha256:","type":"string"},"payload_ref":{"type":"string"},"payload_size":{"type":"integer"},"product_id":{"type":"string"},"release_id":{"type":"string"},"report_type":{"type":"string"},"schema_version":{"type":"string"},"tenant_id":{"type":"string"},"title":{"type":"string"}},"required":["id","tenant_id","report_type","title","payload_hash","payload_size","limitations","schema_version","created_at"],"type":"object"},"PDFReportPackageEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/PDFReportPackage"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"PolicyCheck":{"additionalProperties":false,"properties":{"explanation":{"type":"string"},"missing":{"items":{"type":"string"},"type":"array"},"name":{"type":"string"},"remediation":{"type":"string"},"result":{"enum":["passed","failed","warning","skipped"],"type":"string"},"severity":{"type":"string"}},"required":["name","result","severity","explanation"],"type":"object"},"PolicyEvaluation":{"additionalProperties":false,"properties":{"checks":{"items":{"$ref":"#/components/schemas/PolicyCheck"},"type":"array"},"created_at":{"format":"date-time","type":"string"},"id":{"type":"string"},"policy_set":{"type":"string"},"release_id":{"type":"string"},"result":{"enum":["passed","failed"],"type":"string"},"tenant_id":{"type":"string"}},"required":["id","tenant_id","release_id","result","policy_set","checks","created_at"],"type":"object"},"PolicyEvaluationEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/PolicyEvaluation"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"PolicyRule":{"additionalProperties":false,"properties":{"evidence_type":{"type":"string"},"name":{"type":"string"},"required":{"type":"boolean"},"severity":{"type":"string"}},"required":["name","severity","required"],"type":"object"},"Problem":{"properties":{"code":{"type":"string"},"detail":{"type":"string"},"instance":{"type":"string"},"request_id":{"description":"Request identifier mirrored from the X-Request-ID response header.","type":"string"},"status":{"type":"integer"},"title":{"type":"string"},"type":{"type":"string"}},"type":"object"},"Product":{"additionalProperties":false,"properties":{"created_at":{"format":"date-time","type":"string"},"id":{"type":"string"},"name":{"type":"string"},"schema_version":{"type":"string"},"slug":{"type":"string"},"tenant_id":{"type":"string"}},"required":["id","tenant_id","name","slug","schema_version","created_at"],"type":"object"},"ProductEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/Product"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"ProductListEnvelope":{"additionalProperties":false,"properties":{"data":{"items":{"$ref":"#/components/schemas/Product"},"type":"array"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"Project":{"additionalProperties":false,"properties":{"created_at":{"format":"date-time","type":"string"},"id":{"type":"string"},"name":{"type":"string"},"product_id":{"type":"string"},"schema_version":{"type":"string"},"slug":{"type":"string"},"tenant_id":{"type":"string"}},"required":["id","tenant_id","product_id","name","slug","schema_version","created_at"],"type":"object"},"ProjectEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/Project"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"ProviderVerification":{"additionalProperties":false,"properties":{"checks":{"items":{"$ref":"#/components/schemas/VerifyCheck"},"type":"array"},"created_at":{"format":"date-time","type":"string"},"id":{"type":"string"},"limitations":{"items":{"type":"string"},"type":"array"},"provider_id":{"type":"string"},"provider_type":{"enum":["oidc","saml"],"type":"string"},"result":{"enum":["passed","failed"],"type":"string"},"schema_version":{"type":"string"},"subject":{"type":"string"},"tenant_id":{"type":"string"}},"required":["id","tenant_id","provider_type","provider_id","subject","result","checks","limitations","schema_version","created_at"],"type":"object"},"ProviderVerificationEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/ProviderVerification"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"PublicTransparencyLog":{"additionalProperties":false,"properties":{"created_at":{"format":"date-time","type":"string"},"endpoint":{"type":"string"},"id":{"type":"string"},"name":{"type":"string"},"public_key":{"type":"string"},"schema_version":{"type":"string"},"state":{"type":"string"},"tenant_id":{"type":"string"}},"required":["id","tenant_id","name","endpoint","public_key","state","schema_version","created_at"],"type":"object"},"PublicTransparencyLogEntry":{"additionalProperties":false,"properties":{"checkpoint_id":{"type":"string"},"created_at":{"format":"date-time","type":"string"},"entry_hash":{"pattern":"^sha256:","type":"string"},"external_id":{"type":"string"},"id":{"type":"string"},"inclusion_proof_hash":{"pattern":"^sha256:","type":"string"},"inclusion_root_hash":{"pattern":"^sha256:","type":"string"},"inclusion_verified_at":{"format":"date-time","type":"string"},"log_id":{"type":"string"},"merkle_batch_id":{"type":"string"},"schema_version":{"type":"string"},"state":{"type":"string"},"tenant_id":{"type":"string"},"verification_checks":{"items":{"$ref":"#/components/schemas/VerifyCheck"},"type":"array"},"verification_limitations":{"items":{"type":"string"},"type":"array"}},"required":["id","tenant_id","log_id","checkpoint_id","merkle_batch_id","external_id","entry_hash","state","schema_version","created_at"],"type":"object"},"PublicTransparencyLogEntryEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/PublicTransparencyLogEntry"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"PublicTransparencyLogEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/PublicTransparencyLog"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"PublishPublicTransparencyLogEntryRequest":{"additionalProperties":false,"properties":{"checkpoint_id":{"type":"string"},"external_id":{"type":"string"},"log_id":{"type":"string"}},"required":["log_id","checkpoint_id","external_id"],"type":"object"},"PullRequest":{"additionalProperties":false,"properties":{"created_at":{"format":"date-time","type":"string"},"head_commit_id":{"type":"string"},"id":{"type":"string"},"provider":{"type":"string"},"provider_id":{"type":"string"},"repository_id":{"type":"string"},"review_decision":{"type":"string"},"schema_version":{"type":"string"},"source_branch":{"type":"string"},"state":{"type":"string"},"target_branch":{"type":"string"},"tenant_id":{"type":"string"},"title":{"type":"string"}},"required":["id","tenant_id","repository_id","provider","provider_id","state","schema_version","created_at"],"type":"object"},"PullRequestEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/PullRequest"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"QuestionnaireAnswerLibraryEntry":{"additionalProperties":false,"properties":{"answer":{"type":"string"},"control_id":{"type":"string"},"created_at":{"format":"date-time","type":"string"},"evidence_ids":{"items":{"type":"string"},"type":"array"},"evidence_type":{"type":"string"},"id":{"type":"string"},"limitations":{"items":{"type":"string"},"type":"array"},"product_id":{"type":"string"},"question_id":{"type":"string"},"release_id":{"type":"string"},"schema_version":{"type":"string"},"tenant_id":{"type":"string"}},"required":["id","tenant_id","answer","schema_version","created_at"],"type":"object"},"QuestionnaireAnswerLibraryEntryEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/QuestionnaireAnswerLibraryEntry"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"QuestionnaireAnswerLibraryEntryListEnvelope":{"additionalProperties":false,"properties":{"data":{"items":{"$ref":"#/components/schemas/QuestionnaireAnswerLibraryEntry"},"type":"array"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"QuestionnaireDraft":{"additionalProperties":false,"properties":{"created_at":{"format":"date-time","type":"string"},"id":{"type":"string"},"limitations":{"items":{"type":"string"},"type":"array"},"manifest_hash":{"pattern":"^sha256:","type":"string"},"product_id":{"type":"string"},"release_id":{"type":"string"},"responses":{"items":{"$ref":"#/components/schemas/QuestionnaireResponse"},"type":"array"},"schema_version":{"type":"string"},"template_id":{"type":"string"},"tenant_id":{"type":"string"}},"required":["id","tenant_id","template_id","responses","manifest_hash","limitations","schema_version","created_at"],"type":"object"},"QuestionnaireDraftEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/QuestionnaireDraft"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"QuestionnairePackage":{"additionalProperties":false,"properties":{"created_at":{"format":"date-time","type":"string"},"id":{"type":"string"},"manifest_hash":{"pattern":"^sha256:","type":"string"},"package_id":{"type":"string"},"product_id":{"type":"string"},"release_id":{"type":"string"},"responses":{"items":{"$ref":"#/components/schemas/QuestionnaireResponse"},"type":"array"},"schema_version":{"type":"string"},"template_id":{"type":"string"},"tenant_id":{"type":"string"}},"required":["id","tenant_id","template_id","responses","manifest_hash","schema_version","created_at"],"type":"object"},"QuestionnairePackageEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/QuestionnairePackage"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"QuestionnaireQuestion":{"additionalProperties":false,"properties":{"allowed_fields":{"items":{"type":"string"},"type":"array"},"control_id":{"type":"string"},"evidence_type":{"type":"string"},"id":{"type":"string"},"prompt":{"type":"string"}},"required":["id","prompt"],"type":"object"},"QuestionnaireResponse":{"additionalProperties":false,"properties":{"answer":{"type":"string"},"evidence_ids":{"items":{"type":"string"},"type":"array"},"limitations":{"items":{"type":"string"},"type":"array"},"question_id":{"type":"string"}},"required":["question_id","answer"],"type":"object"},"QuestionnaireTemplate":{"additionalProperties":false,"properties":{"created_at":{"format":"date-time","type":"string"},"id":{"type":"string"},"name":{"type":"string"},"questions":{"items":{"$ref":"#/components/schemas/QuestionnaireQuestion"},"type":"array"},"schema_version":{"type":"string"},"tenant_id":{"type":"string"},"version":{"type":"string"}},"required":["id","tenant_id","name","version","questions","schema_version","created_at"],"type":"object"},"QuestionnaireTemplateEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/QuestionnaireTemplate"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"ReadinessReport":{"additionalProperties":false,"properties":{"assumptions":{"items":{"type":"string"},"type":"array"},"checks":{"items":{"type":"object"},"type":"array"},"failed_policies":{"items":{"type":"string"},"type":"array"},"gaps":{"items":{"type":"string"},"type":"array"},"generated_at":{"format":"date-time","type":"string"},"known_limitations":{"items":{"type":"string"},"type":"array"},"limitations":{"items":{"type":"string"},"type":"array"},"missing_evidence":{"items":{"type":"string"},"type":"array"},"non_claims":{"items":{"type":"string"},"type":"array"},"policy_set":{"type":"string"},"product_id":{"type":"string"},"release_id":{"type":"string"},"report_type":{"type":"string"},"result":{"type":"string"},"sections":{"items":{"additionalProperties":false,"properties":{"id":{"type":"string"},"questions":{"items":{"additionalProperties":false,"properties":{"answer":{"type":"string"},"checks":{"items":{"type":"string"},"type":"array"},"evidence":{"items":{"type":"string"},"type":"array"},"failed_policies":{"items":{"type":"string"},"type":"array"},"id":{"type":"string"},"known_limitations":{"items":{"type":"string"},"type":"array"},"missing_evidence":{"items":{"type":"string"},"type":"array"},"question":{"type":"string"},"status":{"type":"string"}},"type":"object"},"type":"array"},"status":{"type":"string"},"summary":{"type":"string"},"title":{"type":"string"}},"type":"object"},"type":"array"},"summary":{"additionalProperties":false,"properties":{"headline":{"type":"string"},"human_summary":{"type":"string"},"policy_set":{"type":"string"},"result":{"type":"string"}},"type":"object"},"template_version":{"type":"string"}},"required":["report_type","template_version","result","assumptions","limitations","generated_at"],"type":"object"},"ReadinessReportEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/ReadinessReport"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"ReadinessStatus":{"additionalProperties":false,"properties":{"checks":{"items":{"additionalProperties":false,"properties":{"name":{"type":"string"},"status":{"type":"string"}},"required":["name","status"],"type":"object"},"type":"array"},"status":{"type":"string"}},"required":["status","checks"],"type":"object"},"ReadinessStatusEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/ReadinessStatus"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"RecordCollectorReleaseRequest":{"additionalProperties":false,"properties":{"artifact_digest":{"type":"string"},"pinned":{"type":"boolean"},"sbom_id":{"type":"string"},"scan_id":{"type":"string"},"signature_id":{"type":"string"},"version":{"type":"string"}},"required":["version","artifact_digest"],"type":"object"},"RecordDeploymentRequest":{"additionalProperties":false,"properties":{"artifact_ids":{"items":{"type":"string"},"type":"array"},"environment_id":{"type":"string"},"finished_at":{"format":"date-time","type":"string"},"release_id":{"type":"string"},"rollback_of":{"type":"string"},"started_at":{"format":"date-time","type":"string"},"status":{"type":"string"}},"required":["environment_id","release_id","status","started_at"],"type":"object"},"RecordEvidenceLifecycleEventRequest":{"additionalProperties":false,"properties":{"action":{"type":"string"},"details":{"additionalProperties":true,"type":"object"},"reason":{"type":"string"},"replacement_id":{"type":"string"}},"required":["action","reason"],"type":"object"},"RecordIncidentTimelineRequest":{"additionalProperties":false,"properties":{"event_type":{"type":"string"},"evidence_id":{"type":"string"},"occurred_at":{"format":"date-time","type":"string"},"summary":{"type":"string"}},"required":["event_type","summary"],"type":"object"},"RecordPullRequestRequest":{"additionalProperties":false,"properties":{"head_commit_id":{"type":"string"},"provider":{"type":"string"},"provider_id":{"type":"string"},"repository_id":{"type":"string"},"review_decision":{"type":"string"},"source_branch":{"type":"string"},"state":{"type":"string"},"target_branch":{"type":"string"},"title":{"type":"string"}},"required":["repository_id","provider","provider_id","title","state"],"type":"object"},"RecordSourceCommitRequest":{"additionalProperties":false,"properties":{"author":{"type":"string"},"committed_at":{"format":"date-time","type":"string"},"message":{"description":"Commit message is hashed before storage.","type":"string"},"repository_id":{"type":"string"},"sha":{"type":"string"}},"required":["repository_id","sha","committed_at"],"type":"object"},"RecordVulnerabilityWorkflowRequest":{"additionalProperties":false,"properties":{"action":{"type":"string"},"reason":{"type":"string"}},"required":["action","reason"],"type":"object"},"RedactionProfile":{"additionalProperties":false,"properties":{"allowed_types":{"items":{"type":"string"},"type":"array"},"created_at":{"format":"date-time","type":"string"},"description":{"type":"string"},"excluded_fields":{"items":{"type":"string"},"type":"array"},"id":{"type":"string"},"name":{"type":"string"},"schema_version":{"type":"string"},"tenant_id":{"type":"string"}},"required":["id","tenant_id","name","schema_version","created_at"],"type":"object"},"RedactionProfileEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/RedactionProfile"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"RegisterArtifactRequest":{"additionalProperties":false,"properties":{"digest":{"pattern":"^sha256:","type":"string"},"media_type":{"type":"string"},"name":{"type":"string"},"release_id":{"type":"string"},"size":{"minimum":0,"type":"integer"},"subject_ref":{"type":"string"}},"required":["name","digest"],"type":"object"},"RegisterContainerImageRequest":{"additionalProperties":false,"properties":{"artifact_id":{"type":"string"},"digest":{"type":"string"},"platform":{"type":"string"},"repository":{"type":"string"},"tag":{"type":"string"}},"required":["repository","digest"],"type":"object"},"Release":{"additionalProperties":false,"properties":{"approved_at":{"format":"date-time","type":"string"},"created_at":{"format":"date-time","type":"string"},"frozen_at":{"format":"date-time","type":"string"},"id":{"type":"string"},"product_id":{"type":"string"},"project_id":{"type":"string"},"schema_version":{"type":"string"},"status":{"enum":["draft","frozen","approved"],"type":"string"},"tenant_id":{"type":"string"},"version":{"type":"string"}},"required":["id","tenant_id","product_id","version","status","schema_version","created_at"],"type":"object"},"ReleaseBundle":{"additionalProperties":false,"properties":{"created_at":{"format":"date-time","type":"string"},"id":{"type":"string"},"manifest":{"$ref":"#/components/schemas/ReleaseBundleManifest"},"manifest_hash":{"pattern":"^sha256:","type":"string"},"published_at":{"format":"date-time","type":"string"},"release_id":{"type":"string"},"revoked_at":{"format":"date-time","type":"string"},"signature_refs":{"items":{"type":"string"},"type":"array"},"state":{"type":"string"},"tenant_id":{"type":"string"}},"required":["id","tenant_id","release_id","state","manifest","manifest_hash","signature_refs","created_at"],"type":"object"},"ReleaseBundleEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/ReleaseBundle"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"ReleaseBundleManifest":{"additionalProperties":false,"properties":{"bundle_id":{"type":"string"},"chain_checkpoint":{"additionalProperties":true,"type":"object"},"evidence_ids":{"items":{"type":"string"},"type":"array"},"generated_at":{"format":"date-time","type":"string"},"generator":{"additionalProperties":true,"type":"object"},"manifest_version":{"type":"string"},"release":{"additionalProperties":true,"type":"object"},"tenant_id":{"type":"string"}},"required":["manifest_version","bundle_id","tenant_id","release","evidence_ids","chain_checkpoint","generated_at","generator"],"type":"object"},"ReleaseBundleManifestEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/ReleaseBundleManifest"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"ReleaseBundleVerification":{"additionalProperties":false,"properties":{"checked_at":{"format":"date-time","type":"string"},"result":{"enum":["passed","failed"],"type":"string"},"subject_id":{"type":"string"},"subject_type":{"type":"string"}},"required":["result"],"type":"object"},"ReleaseBundleVerificationEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/ReleaseBundleVerification"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"ReleaseCandidate":{"additionalProperties":false,"properties":{"artifact_ids":{"items":{"type":"string"},"type":"array"},"build_ids":{"items":{"type":"string"},"type":"array"},"bundle_ids":{"items":{"type":"string"},"type":"array"},"contract_ids":{"items":{"type":"string"},"type":"array"},"created_at":{"format":"date-time","type":"string"},"id":{"type":"string"},"name":{"type":"string"},"promoted_at":{"format":"date-time","type":"string"},"rejected_at":{"format":"date-time","type":"string"},"release_id":{"type":"string"},"sbom_ids":{"items":{"type":"string"},"type":"array"},"scan_ids":{"items":{"type":"string"},"type":"array"},"schema_version":{"type":"string"},"snapshot_hash":{"pattern":"^sha256:","type":"string"},"state":{"type":"string"},"tenant_id":{"type":"string"},"vex_ids":{"items":{"type":"string"},"type":"array"}},"required":["id","tenant_id","release_id","name","state","snapshot_hash","schema_version","created_at"],"type":"object"},"ReleaseCandidateEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/ReleaseCandidate"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"ReleaseCandidateListEnvelope":{"additionalProperties":false,"properties":{"data":{"items":{"$ref":"#/components/schemas/ReleaseCandidate"},"type":"array"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"ReleaseCandidateTransitionRequest":{"additionalProperties":false,"properties":{"reason":{"type":"string"}},"type":"object"},"ReleaseEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/Release"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"ReleaseEvidenceFlow":{"additionalProperties":false,"properties":{"assumptions":{"items":{"type":"string"},"type":"array"},"counts":{"additionalProperties":{"type":"integer"},"type":"object"},"generated_at":{"format":"date-time","type":"string"},"limitations":{"items":{"type":"string"},"type":"array"},"product_id":{"type":"string"},"release_id":{"type":"string"},"schema_version":{"type":"string"},"status":{"enum":["needs_evidence","ready_for_review"],"type":"string"},"steps":{"items":{"$ref":"#/components/schemas/ReleaseEvidenceFlowStep"},"type":"array"}},"required":["release_id","product_id","status","counts","steps","assumptions","limitations","schema_version","generated_at"],"type":"object"},"ReleaseEvidenceFlowEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/ReleaseEvidenceFlow"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"ReleaseEvidenceFlowStep":{"additionalProperties":false,"properties":{"description":{"type":"string"},"id":{"type":"string"},"idempotency_required":{"type":"boolean"},"method":{"type":"string"},"next_reference":{"type":"string"},"path":{"type":"string"},"required":{"type":"boolean"},"required_scopes":{"items":{"type":"string"},"type":"array"},"status":{"enum":["present","missing","optional"],"type":"string"},"title":{"type":"string"}},"required":["id","title","status","required","method","path","required_scopes","idempotency_required","description"],"type":"object"},"ReleaseSecurityApprovalSummary":{"additionalProperties":false,"properties":{"approved":{"type":"integer"},"total":{"type":"integer"}},"required":["total","approved"],"type":"object"},"ReleaseSecurityExceptionSummary":{"additionalProperties":false,"properties":{"approved_unexpired":{"type":"integer"},"expired":{"type":"integer"},"total":{"type":"integer"},"unapproved":{"type":"integer"}},"required":["total","approved_unexpired","unapproved","expired"],"type":"object"},"ReleaseSecurityMissingDecision":{"additionalProperties":false,"properties":{"component":{"type":"string"},"finding_id":{"type":"string"},"scan_id":{"type":"string"},"severity":{"type":"string"},"state":{"type":"string"},"vulnerability":{"type":"string"}},"required":["finding_id","scan_id","vulnerability","severity","state"],"type":"object"},"ReleaseSecurityProductSummary":{"additionalProperties":false,"properties":{"id":{"type":"string"},"name":{"type":"string"},"slug":{"type":"string"}},"required":["id","name","slug"],"type":"object"},"ReleaseSecurityReleaseSummary":{"additionalProperties":false,"properties":{"id":{"type":"string"},"state":{"type":"string"},"version":{"type":"string"}},"required":["id","version","state"],"type":"object"},"ReleaseSecuritySummary":{"additionalProperties":false,"properties":{"approval_summary":{"$ref":"#/components/schemas/ReleaseSecurityApprovalSummary"},"artifact_count":{"type":"integer"},"assumptions":{"items":{"type":"string"},"type":"array"},"counts":{"additionalProperties":{"type":"integer"},"type":"object"},"decisions_by_status":{"additionalProperties":{"type":"integer"},"type":"object"},"exception_summary":{"$ref":"#/components/schemas/ReleaseSecurityExceptionSummary"},"generated_at":{"format":"date-time","type":"string"},"limitations":{"items":{"type":"string"},"type":"array"},"missing_required_decisions":{"items":{"$ref":"#/components/schemas/ReleaseSecurityMissingDecision"},"type":"array"},"open_findings_by_severity":{"additionalProperties":{"type":"integer"},"type":"object"},"package_status":{"enum":["generated","not_generated"],"type":"string"},"product":{"$ref":"#/components/schemas/ReleaseSecurityProductSummary"},"readiness_status":{"enum":["passed","failed"],"type":"string"},"release":{"$ref":"#/components/schemas/ReleaseSecurityReleaseSummary"},"sbom_status":{"enum":["present","missing"],"type":"string"},"schema_version":{"type":"string"},"vulnerability_scan_status":{"enum":["present","missing"],"type":"string"}},"required":["product","release","artifact_count","sbom_status","vulnerability_scan_status","open_findings_by_severity","decisions_by_status","approval_summary","exception_summary","readiness_status","package_status","counts","assumptions","limitations","schema_version","generated_at"],"type":"object"},"ReleaseSecuritySummaryEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/ReleaseSecuritySummary"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"RemediationTask":{"additionalProperties":false,"properties":{"created_at":{"format":"date-time","type":"string"},"due_at":{"format":"date-time","type":"string"},"evidence_id":{"type":"string"},"id":{"type":"string"},"incident_id":{"type":"string"},"owner":{"type":"string"},"release_id":{"type":"string"},"schema_version":{"type":"string"},"status":{"type":"string"},"tenant_id":{"type":"string"},"title":{"type":"string"}},"required":["id","tenant_id","title","owner","status","schema_version","created_at"],"type":"object"},"RemediationTaskEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/RemediationTask"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"RenderReportTemplateRequest":{"additionalProperties":false,"properties":{"subject_id":{"type":"string"},"subject_type":{"type":"string"}},"required":["subject_type","subject_id"],"type":"object"},"RenderedCustomReport":{"additionalProperties":false,"properties":{"created_at":{"format":"date-time","type":"string"},"hash":{"pattern":"^sha256:","type":"string"},"id":{"type":"string"},"output":{"additionalProperties":true,"type":"object"},"schema_version":{"type":"string"},"subject_id":{"type":"string"},"subject_type":{"type":"string"},"template_id":{"type":"string"},"tenant_id":{"type":"string"}},"required":["id","tenant_id","template_id","subject_type","subject_id","output","hash","schema_version","created_at"],"type":"object"},"RenderedCustomReportEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/RenderedCustomReport"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"RetentionOverride":{"additionalProperties":false,"properties":{"created_at":{"format":"date-time","type":"string"},"id":{"type":"string"},"owner":{"type":"string"},"reason":{"type":"string"},"retention_until":{"format":"date-time","type":"string"},"schema_version":{"type":"string"},"scope_id":{"type":"string"},"scope_type":{"type":"string"},"tenant_id":{"type":"string"}},"required":["id","tenant_id","scope_type","scope_id","retention_until","reason","owner","schema_version","created_at"],"type":"object"},"RetentionOverrideEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/RetentionOverride"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"RetentionReport":{"additionalProperties":false,"properties":{"generated_at":{"format":"date-time","type":"string"},"legal_holds":{"items":{"$ref":"#/components/schemas/LegalHold"},"type":"array"},"limitations":{"items":{"type":"string"},"type":"array"},"report_type":{"type":"string"},"retention_overrides":{"items":{"$ref":"#/components/schemas/RetentionOverride"},"type":"array"},"scope_id":{"type":"string"},"scope_type":{"type":"string"}},"required":["report_type","legal_holds","retention_overrides","limitations","generated_at"],"type":"object"},"RetentionReportEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/RetentionReport"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"RoleBinding":{"additionalProperties":false,"properties":{"created_at":{"format":"date-time","type":"string"},"id":{"type":"string"},"resource_id":{"type":"string"},"resource_type":{"type":"string"},"role":{"type":"string"},"schema_version":{"type":"string"},"subject_id":{"type":"string"},"subject_type":{"type":"string"},"tenant_id":{"type":"string"}},"required":["id","tenant_id","subject_type","subject_id","role","schema_version","created_at"],"type":"object"},"RoleBindingEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/RoleBinding"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"RoleBindingListEnvelope":{"additionalProperties":false,"properties":{"data":{"items":{"$ref":"#/components/schemas/RoleBinding"},"type":"array"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"SBOM":{"additionalProperties":false,"properties":{"artifact_id":{"type":"string"},"component_count":{"type":"integer"},"created_at":{"format":"date-time","type":"string"},"evidence_id":{"type":"string"},"format":{"type":"string"},"id":{"type":"string"},"release_id":{"type":"string"},"schema_version":{"type":"string"},"spec_version":{"type":"string"},"tenant_id":{"type":"string"}},"required":["id","tenant_id","evidence_id","release_id","format","component_count","created_at"],"type":"object"},"SBOMComponent":{"additionalProperties":false,"properties":{"hashes":{"additionalProperties":{"type":"string"},"type":"object"},"name":{"type":"string"},"purl":{"type":"string"},"version":{"type":"string"}},"required":["name"],"type":"object"},"SBOMComponentRecord":{"additionalProperties":false,"properties":{"artifact_id":{"type":"string"},"component":{"$ref":"#/components/schemas/SBOMComponent"},"release_id":{"type":"string"},"sbom_id":{"type":"string"}},"required":["sbom_id","component"],"type":"object"},"SBOMComponentRecordListEnvelope":{"additionalProperties":false,"properties":{"data":{"items":{"$ref":"#/components/schemas/SBOMComponentRecord"},"type":"array"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"SBOMDiff":{"additionalProperties":false,"properties":{"added_components":{"items":{"$ref":"#/components/schemas/SBOMComponent"},"type":"array"},"base_sbom_id":{"type":"string"},"created_at":{"format":"date-time","type":"string"},"dependency_changes":{"items":{"$ref":"#/components/schemas/DependencyChange"},"type":"array"},"id":{"type":"string"},"release_id":{"type":"string"},"removed_components":{"items":{"$ref":"#/components/schemas/SBOMComponent"},"type":"array"},"schema_version":{"type":"string"},"target_sbom_id":{"type":"string"},"tenant_id":{"type":"string"},"unchanged_count":{"type":"integer"}},"required":["id","tenant_id","base_sbom_id","target_sbom_id","unchanged_count","schema_version","created_at"],"type":"object"},"SBOMDiffEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/SBOMDiff"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"SBOMEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/SBOM"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"SSOCredentialExchangeEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/SSOCredentialExchangeResponse"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"SSOCredentialExchangeResponse":{"additionalProperties":false,"properties":{"secret":{"description":"One-time SSO session bearer secret; also set as an HttpOnly cookie for browser clients.","type":"string"},"session":{"$ref":"#/components/schemas/SSOSession"},"verification":{"$ref":"#/components/schemas/ProviderVerification"}},"required":["verification","session","secret"],"type":"object"},"SSOProvider":{"additionalProperties":false,"properties":{"client_id":{"type":"string"},"created_at":{"format":"date-time","type":"string"},"groups_claim":{"type":"string"},"id":{"type":"string"},"issuer":{"type":"string"},"jwks":{"description":"Configured public JWKS material, when supplied.","type":"object"},"name":{"type":"string"},"role_mapping":{"additionalProperties":{"type":"string"},"type":"object"},"saml_signing_certificates":{"description":"Configured SAML assertion signing certificates, when supplied.","items":{"type":"string"},"type":"array"},"schema_version":{"type":"string"},"status":{"type":"string"},"tenant_id":{"type":"string"},"trust_material_updated_at":{"format":"date-time","type":"string"},"type":{"enum":["oidc","saml"],"type":"string"}},"required":["id","tenant_id","name","type","issuer","client_id","status","schema_version","created_at"],"type":"object"},"SSOProviderEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/SSOProvider"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"SSOSession":{"additionalProperties":false,"properties":{"created_at":{"format":"date-time","type":"string"},"expires_at":{"format":"date-time","type":"string"},"groups":{"description":"Provider group claim values captured for session-scoped role mapping.","items":{"type":"string"},"type":"array"},"id":{"type":"string"},"prefix":{"description":"Non-secret session token prefix for audit displays.","type":"string"},"provider_id":{"type":"string"},"revoked_at":{"format":"date-time","type":"string"},"schema_version":{"type":"string"},"tenant_id":{"type":"string"},"user_id":{"type":"string"}},"required":["id","tenant_id","user_id","provider_id","prefix","expires_at","schema_version","created_at"],"type":"object"},"SSOSessionCreateEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/SSOSessionCreateResponse"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"SSOSessionCreateResponse":{"additionalProperties":false,"properties":{"secret":{"description":"One-time SSO session bearer secret; not returned by list/read operations.","type":"string"},"session":{"$ref":"#/components/schemas/SSOSession"}},"required":["session","secret"],"type":"object"},"SSOSessionEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/SSOSession"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"SaaSEditionProfile":{"additionalProperties":false,"properties":{"admin_tenant_id":{"type":"string"},"config_hash":{"pattern":"^sha256:","type":"string"},"created_at":{"format":"date-time","type":"string"},"id":{"type":"string"},"isolation_model":{"type":"string"},"limitations":{"items":{"type":"string"},"type":"array"},"name":{"type":"string"},"region":{"type":"string"},"schema_version":{"type":"string"},"status":{"type":"string"},"tenant_id":{"type":"string"}},"required":["id","tenant_id","name","region","admin_tenant_id","isolation_model","status","config_hash","limitations","schema_version","created_at"],"type":"object"},"SaaSEditionProfileEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/SaaSEditionProfile"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"SecurityControl":{"additionalProperties":false,"properties":{"applicability":{"items":{"type":"string"},"type":"array"},"code":{"type":"string"},"created_at":{"format":"date-time","type":"string"},"evidence_requirements":{"items":{"$ref":"#/components/schemas/ControlEvidenceRequirement"},"type":"array"},"framework_id":{"type":"string"},"id":{"type":"string"},"limitations":{"items":{"type":"string"},"type":"array"},"objective":{"type":"string"},"schema_version":{"type":"string"},"tenant_id":{"type":"string"},"title":{"type":"string"}},"required":["id","tenant_id","framework_id","code","title","objective","schema_version","created_at"],"type":"object"},"SecurityControlEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/SecurityControl"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"SecurityReviewPackageReport":{"additionalProperties":false,"properties":{"assumptions":{"items":{"type":"string"},"type":"array"},"evidence_ids":{"items":{"type":"string"},"type":"array"},"generated_at":{"format":"date-time","type":"string"},"limitations":{"items":{"type":"string"},"type":"array"},"package_id":{"type":"string"},"product_id":{"type":"string"},"release_id":{"type":"string"},"report_type":{"type":"string"},"template_version":{"type":"string"}},"required":["report_type","template_version","package_id","product_id","evidence_ids","assumptions","limitations","generated_at"],"type":"object"},"SecurityReviewPackageReportEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/SecurityReviewPackageReport"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"SecurityScan":{"additionalProperties":false,"properties":{"artifact_id":{"type":"string"},"category":{"type":"string"},"created_at":{"format":"date-time","type":"string"},"evidence_id":{"type":"string"},"finding_count":{"type":"integer"},"format":{"type":"string"},"id":{"type":"string"},"payload_hash":{"pattern":"^sha256:","type":"string"},"payload_ref":{"type":"string"},"product_id":{"type":"string"},"quarantined":{"type":"boolean"},"redacted":{"type":"boolean"},"release_id":{"type":"string"},"scanner":{"type":"string"},"schema_version":{"type":"string"},"summary":{"additionalProperties":{"type":"integer"},"type":"object"},"target_ref":{"type":"string"},"tenant_id":{"type":"string"}},"required":["id","tenant_id","category","format","scanner","target_ref","evidence_id","payload_hash","finding_count","redacted","quarantined","schema_version","created_at"],"type":"object"},"SecurityScanEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/SecurityScan"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"SecurityUpdateEvidenceReport":{"additionalProperties":false,"properties":{"assumptions":{"items":{"type":"string"},"type":"array"},"evidence_ids":{"items":{"type":"string"},"type":"array"},"fixed_decisions":{"items":{"$ref":"#/components/schemas/VulnerabilityDecisionCustomerSummary"},"type":"array"},"generated_at":{"format":"date-time","type":"string"},"incidents":{"items":{"$ref":"#/components/schemas/Incident"},"type":"array"},"limitations":{"items":{"type":"string"},"type":"array"},"product_id":{"type":"string"},"release_id":{"type":"string"},"remediation_tasks":{"items":{"$ref":"#/components/schemas/RemediationTask"},"type":"array"},"report_type":{"type":"string"},"summary":{"additionalProperties":{"type":"integer"},"type":"object"},"template_version":{"type":"string"}},"required":["report_type","template_version","product_id","release_id","summary","assumptions","limitations","generated_at"],"type":"object"},"SecurityUpdateEvidenceReportEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/SecurityUpdateEvidenceReport"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"SignedIncidentWebhookPayload":{"additionalProperties":false,"properties":{"event_type":{"type":"string"},"evidence_id":{"type":"string"},"occurred_at":{"format":"date-time","type":"string"},"summary":{"type":"string"}},"required":["event_type","summary"],"type":"object"},"SigningCustodyReviewReport":{"additionalProperties":false,"properties":{"assumptions":{"items":{"type":"string"},"type":"array"},"checks":{"items":{"$ref":"#/components/schemas/VerifyCheck"},"type":"array"},"generated_at":{"format":"date-time","type":"string"},"limitations":{"items":{"type":"string"},"type":"array"},"object_retention_policies":{"items":{"$ref":"#/components/schemas/ObjectRetentionPolicy"},"type":"array"},"report_type":{"type":"string"},"signing_providers":{"items":{"$ref":"#/components/schemas/SigningProvider"},"type":"array"},"tenant_id":{"type":"string"}},"required":["report_type","tenant_id","checks","assumptions","limitations","generated_at"],"type":"object"},"SigningCustodyReviewReportEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/SigningCustodyReviewReport"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"SigningKey":{"additionalProperties":false,"properties":{"algorithm":{"type":"string"},"created_at":{"format":"date-time","type":"string"},"id":{"type":"string"},"kid":{"type":"string"},"public_key":{"type":"string"},"revoked_at":{"format":"date-time","type":"string"},"status":{"type":"string"},"tenant_id":{"type":"string"}},"required":["id","tenant_id","kid","algorithm","status","public_key","created_at"],"type":"object"},"SigningKeyEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/SigningKey"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"SigningKeyListEnvelope":{"additionalProperties":false,"properties":{"data":{"items":{"$ref":"#/components/schemas/SigningKey"},"type":"array"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"SigningKeyTransitionRequest":{"additionalProperties":false,"properties":{"reason":{"type":"string"}},"type":"object"},"SigningOperation":{"additionalProperties":false,"properties":{"checks":{"items":{"$ref":"#/components/schemas/VerifyCheck"},"type":"array"},"created_at":{"format":"date-time","type":"string"},"id":{"type":"string"},"payload_hash":{"pattern":"^sha256:","type":"string"},"provider_id":{"type":"string"},"result":{"type":"string"},"schema_version":{"type":"string"},"signature_ref":{"type":"string"},"subject_id":{"type":"string"},"subject_type":{"type":"string"},"tenant_id":{"type":"string"}},"required":["id","tenant_id","provider_id","subject_type","subject_id","payload_hash","result","checks","schema_version","created_at"],"type":"object"},"SigningOperationEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/SigningOperation"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"SigningProvider":{"additionalProperties":false,"properties":{"created_at":{"format":"date-time","type":"string"},"encrypted":{"type":"boolean"},"id":{"type":"string"},"key_ref":{"type":"string"},"name":{"type":"string"},"schema_version":{"type":"string"},"status":{"type":"string"},"tenant_id":{"type":"string"},"type":{"type":"string"}},"required":["id","tenant_id","name","type","status","key_ref","encrypted","schema_version","created_at"],"type":"object"},"SigningProviderEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/SigningProvider"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"SourceBranch":{"additionalProperties":false,"properties":{"created_at":{"format":"date-time","type":"string"},"head_commit_id":{"type":"string"},"id":{"type":"string"},"name":{"type":"string"},"protected":{"type":"boolean"},"protection_hash":{"type":"string"},"repository_id":{"type":"string"},"schema_version":{"type":"string"},"tenant_id":{"type":"string"}},"required":["id","tenant_id","repository_id","name","protected","schema_version","created_at"],"type":"object"},"SourceBranchEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/SourceBranch"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"SourceCommit":{"additionalProperties":false,"properties":{"author":{"type":"string"},"committed_at":{"format":"date-time","type":"string"},"created_at":{"format":"date-time","type":"string"},"id":{"type":"string"},"message_hash":{"type":"string"},"repository_id":{"type":"string"},"schema_version":{"type":"string"},"sha":{"type":"string"},"tenant_id":{"type":"string"}},"required":["id","tenant_id","repository_id","sha","schema_version","created_at"],"type":"object"},"SourceCommitEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/SourceCommit"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"SourceRepository":{"additionalProperties":false,"properties":{"clone_url":{"type":"string"},"created_at":{"format":"date-time","type":"string"},"default_branch":{"type":"string"},"full_name":{"type":"string"},"id":{"type":"string"},"project_id":{"type":"string"},"provider":{"type":"string"},"schema_version":{"type":"string"},"tenant_id":{"type":"string"}},"required":["id","tenant_id","provider","full_name","schema_version","created_at"],"type":"object"},"SourceRepositoryEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/SourceRepository"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"SourceRepositoryListEnvelope":{"additionalProperties":false,"properties":{"data":{"items":{"$ref":"#/components/schemas/SourceRepository"},"type":"array"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"SourceSnapshotBranchInput":{"additionalProperties":false,"properties":{"name":{"type":"string"},"protected":{"type":"boolean"},"protection_hash":{"type":"string"}},"required":["name"],"type":"object"},"SourceSnapshotCommitInput":{"additionalProperties":false,"properties":{"author":{"type":"string"},"committed_at":{"format":"date-time","type":"string"},"message":{"description":"Commit message supplied by the collector; Evydence stores a message hash.","type":"string"},"sha":{"type":"string"}},"required":["sha","committed_at"],"type":"object"},"SourceSnapshotEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/SourceSnapshotResult"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"SourceSnapshotPullRequestInput":{"additionalProperties":false,"properties":{"provider_id":{"type":"string"},"review_decision":{"type":"string"},"source_branch":{"type":"string"},"state":{"type":"string"},"target_branch":{"type":"string"},"title":{"type":"string"}},"required":["provider_id","state"],"type":"object"},"SourceSnapshotRepositoryInput":{"additionalProperties":false,"properties":{"clone_url":{"type":"string"},"default_branch":{"type":"string"},"full_name":{"type":"string"}},"required":["full_name"],"type":"object"},"SourceSnapshotRequest":{"additionalProperties":false,"properties":{"branch":{"$ref":"#/components/schemas/SourceSnapshotBranchInput"},"commit":{"$ref":"#/components/schemas/SourceSnapshotCommitInput"},"project_id":{"type":"string"},"pull_request":{"$ref":"#/components/schemas/SourceSnapshotPullRequestInput"},"repository":{"$ref":"#/components/schemas/SourceSnapshotRepositoryInput"}},"required":["repository"],"type":"object"},"SourceSnapshotResult":{"additionalProperties":false,"properties":{"branch":{"$ref":"#/components/schemas/SourceBranch"},"commit":{"$ref":"#/components/schemas/SourceCommit"},"pull_request":{"$ref":"#/components/schemas/PullRequest"},"repository":{"$ref":"#/components/schemas/SourceRepository"}},"required":["repository"],"type":"object"},"SubjectRef":{"additionalProperties":false,"properties":{"digest":{"type":"string"},"id":{"type":"string"},"type":{"type":"string"}},"required":["type"],"type":"object"},"SupersedeEvidenceRequest":{"additionalProperties":false,"properties":{"reason":{"type":"string"},"replacement_evidence_id":{"type":"string"}},"required":["replacement_evidence_id","reason"],"type":"object"},"TransparencyCheckpoint":{"additionalProperties":false,"properties":{"batch_id":{"type":"string"},"created_at":{"format":"date-time","type":"string"},"external_id":{"type":"string"},"external_url":{"type":"string"},"id":{"type":"string"},"provider":{"type":"string"},"schema_version":{"type":"string"},"state":{"type":"string"},"tenant_id":{"type":"string"},"timestamp_hash":{"pattern":"^sha256:","type":"string"}},"required":["id","tenant_id","batch_id","provider","timestamp_hash","state","schema_version","created_at"],"type":"object"},"TransparencyCheckpointEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/TransparencyCheckpoint"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"UpdateSSOProviderTrustMaterialRequest":{"additionalProperties":false,"properties":{"jwks":{"description":"OIDC public JWKS material. Private keys and provider secrets must not be supplied.","type":"object"},"saml_signing_certificates":{"description":"PEM-encoded SAML assertion signing certificates. Private keys and provider secrets must not be supplied.","items":{"type":"string"},"type":"array"}},"type":"object"},"UploadManualSecurityDocumentRequest":{"additionalProperties":false,"properties":{"document_type":{"enum":["threat_model","security_review","pentest_report"],"type":"string"},"media_type":{"type":"string"},"payload":{"description":"Document payload or text supplied for object storage; responses expose only payload hash/ref metadata.","type":"string"},"product_id":{"type":"string"},"release_id":{"type":"string"},"sensitivity":{"enum":["internal","restricted","confidential"],"type":"string"},"title":{"type":"string"}},"required":["document_type","title","sensitivity","payload"],"type":"object"},"UploadOpenAPIContractRequest":{"additionalProperties":false,"properties":{"product_id":{"type":"string"},"release_id":{"type":"string"},"spec":{"additionalProperties":true,"type":"object"},"version":{"type":"string"}},"required":["product_id","release_id","version","spec"],"type":"object"},"UploadSPDXSBOMRequest":{"additionalProperties":false,"properties":{"artifact_id":{"type":"string"},"payload":{"additionalProperties":true,"type":"object"},"release_id":{"type":"string"}},"required":["release_id","payload"],"type":"object"},"UploadSecurityScanRequest":{"additionalProperties":false,"properties":{"artifact_id":{"type":"string"},"category":{"enum":["sast","dast","secret","license","api_security"],"type":"string"},"format":{"type":"string"},"payload":{"additionalProperties":true,"type":"object"},"product_id":{"type":"string"},"release_id":{"type":"string"},"scanner":{"type":"string"},"target_ref":{"type":"string"}},"required":["category","format","scanner","target_ref","payload"],"type":"object"},"UploadVulnerabilityScanRequest":{"additionalProperties":false,"properties":{"findings":{"items":{"additionalProperties":false,"properties":{"component":{"type":"string"},"severity":{"type":"string"},"state":{"type":"string"},"vulnerability":{"type":"string"}},"required":["vulnerability","severity"],"type":"object"},"type":"array"},"release_id":{"type":"string"},"scanner":{"type":"string"},"target_ref":{"type":"string"}},"required":["scanner","target_ref","release_id","findings"],"type":"object"},"UpsertSourceBranchRequest":{"additionalProperties":false,"properties":{"head_commit_id":{"type":"string"},"name":{"type":"string"},"protected":{"type":"boolean"},"protection_hash":{"type":"string"},"repository_id":{"type":"string"}},"required":["repository_id","name"],"type":"object"},"UserIdentityLink":{"additionalProperties":false,"properties":{"created_at":{"format":"date-time","type":"string"},"email":{"format":"email","type":"string"},"id":{"type":"string"},"provider_id":{"type":"string"},"schema_version":{"type":"string"},"subject":{"type":"string"},"tenant_id":{"type":"string"},"user_id":{"type":"string"},"verified":{"type":"boolean"}},"required":["id","tenant_id","user_id","provider_id","subject","email","verified","schema_version","created_at"],"type":"object"},"UserIdentityLinkEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/UserIdentityLink"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"VEXDocument":{"additionalProperties":false,"properties":{"artifact_id":{"type":"string"},"author":{"type":"string"},"created_at":{"format":"date-time","type":"string"},"evidence_id":{"type":"string"},"format":{"type":"string"},"id":{"type":"string"},"release_id":{"type":"string"},"schema_version":{"type":"string"},"statement_count":{"type":"integer"},"status_summary":{"additionalProperties":{"type":"integer"},"type":"object"},"tenant_id":{"type":"string"}},"required":["id","tenant_id","evidence_id","release_id","format","statement_count","schema_version","created_at"],"type":"object"},"VEXDocumentEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/VEXDocument"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"VEXImportIssue":{"additionalProperties":false,"properties":{"code":{"type":"string"},"detail":{"type":"string"},"statement_index":{"type":"integer"}},"required":["code","detail"],"type":"object"},"VEXImportPreview":{"additionalProperties":false,"properties":{"advisory":{"type":"boolean"},"artifact_id":{"type":"string"},"assumptions":{"items":{"type":"string"},"type":"array"},"decisions_would_create":{"type":"integer"},"decisions_would_supersede":{"type":"integer"},"format":{"enum":["openvex","cyclonedx"],"type":"string"},"generated_at":{"format":"date-time","type":"string"},"invalid_statements":{"items":{"$ref":"#/components/schemas/VEXImportIssue"},"type":"array"},"limitations":{"items":{"type":"string"},"type":"array"},"mapping_failures":{"items":{"$ref":"#/components/schemas/VEXImportIssue"},"type":"array"},"parser_version":{"type":"string"},"release_id":{"type":"string"},"schema_version":{"type":"string"},"statement_count":{"type":"integer"},"status_summary":{"additionalProperties":{"type":"integer"},"type":"object"},"tenant_id":{"type":"string"},"warnings":{"items":{"type":"string"},"type":"array"}},"required":["tenant_id","release_id","format","parser_version","advisory","statement_count","status_summary","decisions_would_create","decisions_would_supersede","assumptions","limitations","schema_version","generated_at"],"type":"object"},"VEXImportPreviewEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/VEXImportPreview"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"VEXImportReport":{"additionalProperties":false,"properties":{"artifact_id":{"type":"string"},"created_at":{"format":"date-time","type":"string"},"decisions_created":{"type":"integer"},"decisions_superseded":{"type":"integer"},"evidence_id":{"type":"string"},"failure_code":{"type":"string"},"failure_detail":{"type":"string"},"id":{"type":"string"},"invalid_statements":{"items":{"$ref":"#/components/schemas/VEXImportIssue"},"type":"array"},"mapping_failures":{"items":{"$ref":"#/components/schemas/VEXImportIssue"},"type":"array"},"parser_version":{"type":"string"},"release_id":{"type":"string"},"schema_version":{"type":"string"},"statement_count":{"type":"integer"},"status":{"enum":["accepted","parsed","failed"],"type":"string"},"tenant_id":{"type":"string"},"unsupported_fields":{"items":{"type":"string"},"type":"array"},"updated_at":{"format":"date-time","type":"string"},"vex_document_id":{"type":"string"},"warnings":{"items":{"type":"string"},"type":"array"}},"required":["id","tenant_id","vex_document_id","evidence_id","parser_version","status","statement_count","decisions_created","decisions_superseded","schema_version","created_at","updated_at"],"type":"object"},"VEXImportReportEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/VEXImportReport"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"VerificationResult":{"additionalProperties":false,"properties":{"checks":{"items":{"$ref":"#/components/schemas/VerifyCheck"},"type":"array"},"id":{"type":"string"},"result":{"enum":["passed","failed"],"type":"string"},"subject_id":{"type":"string"},"subject_type":{"type":"string"},"tenant_id":{"type":"string"},"verified_at":{"format":"date-time","type":"string"}},"required":["id","tenant_id","subject_type","subject_id","result","checks","verified_at"],"type":"object"},"VerificationResultEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/VerificationResult"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"VerifyCheck":{"additionalProperties":false,"properties":{"detail":{"type":"string"},"name":{"type":"string"},"result":{"enum":["passed","failed","warning","skipped"],"type":"string"}},"required":["name","result"],"type":"object"},"VerifyCosignSignatureRequest":{"additionalProperties":false,"properties":{"certificate_identity":{"type":"string"},"certificate_issuer":{"type":"string"},"rekor_log_index":{"type":"string"},"rekor_uuid":{"type":"string"}},"type":"object"},"VerifyProviderIdentityRequest":{"additionalProperties":false,"properties":{"access_token":{"description":"Optional OIDC access token used only for live UserInfo validation. It is not persisted and must not be supplied for SAML providers.","type":"string"},"id_token":{"description":"Optional OIDC ID token verified locally against the provider's configured static JWKS.","type":"string"},"provider_id":{"type":"string"},"provider_type":{"enum":["oidc","saml"],"type":"string"},"saml_assertion":{"description":"Optional SAML assertion verified locally against configured SAML signing certificates.","type":"string"},"subject":{"type":"string"}},"required":["provider_type","provider_id","subject"],"type":"object"},"VerifyPublicTransparencyLogEntryRequest":{"additionalProperties":false,"properties":{"inclusion_proof":{"items":{"pattern":"^sha256:","type":"string"},"type":"array"},"leaf_hash":{"pattern":"^sha256:","type":"string"},"leaf_index":{"minimum":0,"type":"integer"},"root_hash":{"pattern":"^sha256:","type":"string"},"tree_size":{"minimum":1,"type":"integer"}},"required":["root_hash","leaf_index","tree_size","inclusion_proof"],"type":"object"},"VerifySubjectRequest":{"additionalProperties":false,"properties":{"subject_id":{"type":"string"},"subject_type":{"type":"string"}},"required":["subject_type"],"type":"object"},"VersionInfo":{"additionalProperties":false,"properties":{"version":{"type":"string"}},"required":["version"],"type":"object"},"VersionInfoEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/VersionInfo"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"VulnerabilityDecision":{"additionalProperties":false,"properties":{"action_statement":{"type":"string"},"approved_by":{"type":"string"},"component":{"type":"string"},"created_at":{"format":"date-time","type":"string"},"customer_visible":{"type":"boolean"},"evidence_id":{"type":"string"},"evidence_ids":{"items":{"type":"string"},"type":"array"},"finding_id":{"type":"string"},"id":{"type":"string"},"impact_statement":{"type":"string"},"internal_notes":{"description":"Tenant-internal notes; do not include in customer-safe exports.","type":"string"},"justification":{"type":"string"},"release_id":{"type":"string"},"review_due_at":{"format":"date-time","type":"string"},"reviewed_at":{"format":"date-time","type":"string"},"sbom_component_name":{"type":"string"},"sbom_component_purl":{"type":"string"},"sbom_id":{"description":"Same-release SBOM that contained the matched component, when available.","type":"string"},"scan_id":{"type":"string"},"schema_version":{"type":"string"},"source":{"type":"string"},"status":{"type":"string"},"superseded_by":{"type":"string"},"supersedes":{"type":"string"},"supporting_refs":{"items":{"$ref":"#/components/schemas/SubjectRef"},"type":"array"},"tenant_id":{"type":"string"},"vex_document_id":{"type":"string"},"vulnerability":{"type":"string"}},"required":["id","tenant_id","finding_id","scan_id","vulnerability","status","justification","source","schema_version","created_at"],"type":"object"},"VulnerabilityDecisionCustomerSummary":{"additionalProperties":false,"properties":{"action_statement":{"type":"string"},"component":{"type":"string"},"created_at":{"format":"date-time","type":"string"},"evidence_id":{"type":"string"},"evidence_ids":{"items":{"type":"string"},"type":"array"},"finding_id":{"type":"string"},"id":{"type":"string"},"impact_statement":{"type":"string"},"justification":{"type":"string"},"release_id":{"type":"string"},"review_due_at":{"format":"date-time","type":"string"},"reviewed_at":{"format":"date-time","type":"string"},"sbom_component_name":{"type":"string"},"sbom_component_purl":{"type":"string"},"sbom_id":{"description":"Same-release SBOM that contained the matched component, when available.","type":"string"},"scan_id":{"type":"string"},"source":{"type":"string"},"status":{"type":"string"},"supporting_refs":{"items":{"$ref":"#/components/schemas/SubjectRef"},"type":"array"},"vex_document_id":{"type":"string"},"vulnerability":{"type":"string"}},"required":["id","finding_id","scan_id","release_id","vulnerability","status","impact_statement","source","created_at"],"type":"object"},"VulnerabilityDecisionEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/VulnerabilityDecision"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"VulnerabilityDecisionListEnvelope":{"additionalProperties":false,"properties":{"data":{"items":{"$ref":"#/components/schemas/VulnerabilityDecision"},"type":"array"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"VulnerabilityDecisionSummaryReport":{"additionalProperties":false,"properties":{"assumptions":{"items":{"type":"string"},"type":"array"},"decisions":{"items":{"$ref":"#/components/schemas/VulnerabilityDecisionCustomerSummary"},"type":"array"},"generated_at":{"format":"date-time","type":"string"},"limitations":{"items":{"type":"string"},"type":"array"},"product_id":{"type":"string"},"release_id":{"type":"string"},"report_type":{"type":"string"},"template_version":{"type":"string"}},"required":["report_type","template_version","product_id","release_id","decisions","assumptions","limitations","generated_at"],"type":"object"},"VulnerabilityDecisionSummaryReportEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/VulnerabilityDecisionSummaryReport"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"VulnerabilityPostureReport":{"additionalProperties":false,"properties":{"assumptions":{"items":{"type":"string"},"type":"array"},"generated_at":{"format":"date-time","type":"string"},"limitations":{"items":{"type":"string"},"type":"array"},"open_critical":{"type":"integer"},"release_id":{"type":"string"},"report_type":{"type":"string"},"summary":{"additionalProperties":{"type":"integer"},"type":"object"},"template_version":{"type":"string"}},"required":["report_type","template_version","summary","open_critical","assumptions","limitations","generated_at"],"type":"object"},"VulnerabilityPostureReportEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/VulnerabilityPostureReport"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"VulnerabilityScan":{"additionalProperties":false,"properties":{"created_at":{"format":"date-time","type":"string"},"findings":{"items":{"type":"object"},"type":"array"},"id":{"type":"string"},"release_id":{"type":"string"},"scanner":{"type":"string"},"summary":{"additionalProperties":{"type":"integer"},"type":"object"},"target_ref":{"type":"string"},"tenant_id":{"type":"string"}},"required":["id","tenant_id","release_id","scanner","target_ref","summary","findings","created_at"],"type":"object"},"VulnerabilityScanEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/VulnerabilityScan"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"VulnerabilityWorkflowRecord":{"additionalProperties":false,"properties":{"action":{"type":"string"},"actor_id":{"type":"string"},"created_at":{"format":"date-time","type":"string"},"finding_id":{"type":"string"},"id":{"type":"string"},"reason":{"type":"string"},"release_id":{"type":"string"},"schema_version":{"type":"string"},"tenant_id":{"type":"string"}},"required":["id","tenant_id","finding_id","action","reason","actor_id","schema_version","created_at"],"type":"object"},"VulnerabilityWorkflowRecordEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/VulnerabilityWorkflowRecord"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"},"Waiver":{"additionalProperties":false,"properties":{"approved":{"type":"boolean"},"approved_at":{"format":"date-time","type":"string"},"approved_by":{"type":"string"},"control_id":{"type":"string"},"created_at":{"format":"date-time","type":"string"},"expires_at":{"format":"date-time","type":"string"},"id":{"type":"string"},"owner":{"type":"string"},"policy_id":{"type":"string"},"reason":{"type":"string"},"risk":{"type":"string"},"schema_version":{"type":"string"},"scope_id":{"type":"string"},"scope_type":{"type":"string"},"superseded_by":{"type":"string"},"supersedes":{"type":"string"},"tenant_id":{"type":"string"}},"required":["id","tenant_id","scope_type","scope_id","owner","risk","reason","expires_at","approved","schema_version","created_at"],"type":"object"},"WaiverEnvelope":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/Waiver"},"meta":{"additionalProperties":false,"properties":{"api_version":{"type":"string"}},"required":["api_version"],"type":"object"}},"required":["data","meta"],"type":"object"}},"securitySchemes":{"BearerAuth":{"scheme":"bearer","type":"http"}}},"info":{"description":"Self-hosted API evidence and compliance-readiness ledger.","title":"Evydence API","version":"dev"},"openapi":"3.1.0","paths":{"/v1/admin/instance":{"get":{"description":"Returns instance-level diagnostic counts. Requires the explicit instance:admin scope; tenant admin and ordinary wildcard tenant keys are insufficient.","operationId":"instanceAdminSnapshot","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/InstanceAdminSnapshotEnvelope"}}},"description":"Instance admin snapshot envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Instance admin snapshot","tags":["evydence"],"x-scopes":["instance:admin"]}},"/v1/api-keys":{"get":{"description":"Lists tenant-scoped API key metadata without key hashes or one-time secrets.","operationId":"listAPIKeys","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIKeyListEnvelope"}}},"description":"API key list envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"List API keys","tags":["evydence"],"x-scopes":["admin"]},"post":{"description":"Creates a tenant-scoped API key and returns the secret exactly once. Stored records expose only non-secret key metadata.","operationId":"createAPIKey","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateAPIKeyRequest"}}},"description":"API key creation request.","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIKeyCreateEnvelope"}}},"description":"Created API key and one-time secret envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Create API key","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["admin"]}},"/v1/api-security-scans":{"post":{"description":"Uploads SAST, DAST, secret, license, or API security scan metadata and raw JSON payload evidence without exposing raw payload bytes in responses.","operationId":"uploadAPISecurityScan","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UploadSecurityScanRequest"}}},"description":"Security scan upload request.","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SecurityScanEnvelope"}}},"description":"Created security scan envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Upload API security scan","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["security:write"]}},"/v1/approvals":{"post":{"description":"Creates an immutable approval record for a release, waiver, package, or review subject.","operationId":"createApproval","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateApprovalRequest"}}},"description":"Approval creation request.","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApprovalRecordEnvelope"}}},"description":"Created approval envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Create approval record","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["release:write"]}},"/v1/artifact-signatures":{"post":{"description":"Records detached artifact signature evidence and optional raw signature payload metadata.","operationId":"createArtifactSignature","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateArtifactSignatureRequest"}}},"description":"Artifact signature creation request.","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ArtifactSignatureEnvelope"}}},"description":"Created artifact signature envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Create artifact signature","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["evidence:write"]}},"/v1/artifact-signatures/{id}":{"get":{"description":"Returns tenant-scoped artifact signature metadata by id.","operationId":"getArtifactSignature","parameters":[{"description":"Artifact signature id.","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ArtifactSignatureEnvelope"}}},"description":"Artifact signature envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Get artifact signature","tags":["evydence"],"x-scopes":["evidence:read"]}},"/v1/artifact-signatures/{id}/verify-cosign":{"post":{"description":"Records deterministic cosign-style verification metadata for an artifact signature without implying online transparency trust.","operationId":"verifyCosignSignature","parameters":[{"description":"Artifact signature id.","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/VerifyCosignSignatureRequest"}}},"description":"Cosign verification metadata request.","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CosignVerificationEnvelope"}}},"description":"Cosign verification envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Verify cosign-style artifact signature","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["verify:read"]}},"/v1/artifacts":{"post":{"description":"Registers an artifact digest for release evidence and later build/attestation matching.","operationId":"registerArtifact","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RegisterArtifactRequest"}}},"description":"Artifact registration request.","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ArtifactEnvelope"}}},"description":"Registered artifact envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Register artifact","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["evidence:write"]}},"/v1/artifacts/{id}":{"get":{"description":"Returns a tenant-scoped artifact by id.","operationId":"getArtifact","parameters":[{"description":"Artifact id.","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ArtifactEnvelope"}}},"description":"Artifact envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Get artifact","tags":["evydence"],"x-scopes":["evidence:read"]}},"/v1/audit-chain/verify":{"get":{"description":"Verifies the tenant audit chain continuity and returns deterministic verification checks.","operationId":"verifyAuditChain","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/VerificationResultEnvelope"}}},"description":"Audit chain verification envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Verify audit chain","tags":["evydence"],"x-scopes":["verify:read"]}},"/v1/audit-log":{"get":{"description":"Lists tenant-scoped append-only audit-chain entries in reverse chronological order.","operationId":"listAuditLog","parameters":[{"description":"Maximum returned entries; defaults to 100 and caps at 500.","in":"query","name":"limit","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Only include entries at or after this RFC3339 timestamp.","in":"query","name":"since","required":false,"schema":{"type":"string"}},{"description":"Filter by audited subject id.","in":"query","name":"subject_id","required":false,"schema":{"type":"string"}},{"description":"Filter by audited subject type.","in":"query","name":"subject_type","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuditChainEntryListEnvelope"}}},"description":"Audit-chain entry list envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"List tenant audit log","tags":["evydence"],"x-scopes":["admin"]}},"/v1/backup-manifests":{"post":{"description":"Generates a tenant-scoped backup manifest after an operator backup completes. The manifest excludes raw payload bytes and private key material.","operationId":"generateBackupManifest","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EmptyObject"}}},"description":"Empty JSON object.","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BackupManifestEnvelope"}}},"description":"Backup manifest envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Generate backup manifest","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["admin"]}},"/v1/backup-manifests/{id}/verify":{"get":{"description":"Verifies a tenant-scoped backup manifest and returns deterministic manifest verification checks.","operationId":"verifyBackupManifest","parameters":[{"description":"Backup manifest id.","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/VerificationResultEnvelope"}}},"description":"Backup manifest verification envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Verify backup manifest","tags":["evydence"],"x-scopes":["verify:read"]}},"/v1/build-attestations/{id}/verify-signature":{"post":{"description":"Verifies a build attestation signature against configured tenant DSSE trust roots.","operationId":"verifyBuildAttestationSignature","parameters":[{"description":"Build attestation id.","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EmptyObject"}}},"description":"Empty JSON object.","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/VerificationResultEnvelope"}}},"description":"Build attestation verification envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Verify build attestation signature","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["verify:read"]}},"/v1/builds":{"post":{"description":"Records an immutable CI build run. Collector identity is derived from the authenticated key when present.","operationId":"createBuild","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateBuildRequest"}}},"description":"Build run creation request.","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BuildRunEnvelope"}}},"description":"Created build run envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Create build run","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["build:write"]}},"/v1/builds/{id}":{"get":{"description":"Returns a tenant-scoped build run by id.","operationId":"getBuild","parameters":[{"description":"Build run id.","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BuildRunEnvelope"}}},"description":"Build run envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Get build run","tags":["evydence"],"x-scopes":["build:read"]}},"/v1/builds/{id}/attestations":{"post":{"description":"Uploads a DSSE/in-toto build attestation for a tenant-scoped build and stores raw bytes in object storage.","operationId":"uploadBuildAttestation","parameters":[{"description":"Build run id.","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DSSEEnvelope"}}},"description":"DSSE envelope.","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BuildAttestationEnvelope"}}},"description":"Created build attestation envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Upload build attestation","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["build:write"]}},"/v1/collectors":{"get":{"description":"Lists tenant-scoped collector metadata without API key hashes or one-time secrets.","operationId":"listCollectors","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CollectorListEnvelope"}}},"description":"Collector list envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"List collectors","tags":["evydence"],"x-scopes":["collector:read"]},"post":{"description":"Creates a tenant-scoped collector identity, binds a scoped API key, and returns the collector key secret exactly once.","operationId":"createCollector","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateCollectorRequest"}}},"description":"Collector creation request.","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CollectorCreateEnvelope"}}},"description":"Created collector and one-time key secret envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Create collector","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["collector:admin"]}},"/v1/collectors/github/source-snapshots":{"post":{"description":"Uploads a strict GitHub source snapshot, hashes commit messages, and stores repository, commit, branch, and pull-request evidence records.","operationId":"uploadGitHubSourceSnapshot","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SourceSnapshotRequest"}}},"description":"GitHub source snapshot upload request.","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SourceSnapshotEnvelope"}}},"description":"Created source snapshot resources envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Upload GitHub source snapshot","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["source:write"]}},"/v1/collectors/gitlab/source-snapshots":{"post":{"description":"Uploads a strict GitLab source snapshot, hashes commit messages, and stores repository, commit, branch, and pull-request evidence records.","operationId":"uploadGitLabSourceSnapshot","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SourceSnapshotRequest"}}},"description":"GitLab source snapshot upload request.","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SourceSnapshotEnvelope"}}},"description":"Created source snapshot resources envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Upload GitLab source snapshot","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["source:write"]}},"/v1/collectors/{id}/health":{"get":{"description":"Returns collector supply-chain health from recorded tenant evidence, assumptions, and limitations.","operationId":"collectorHealthReport","parameters":[{"description":"Collector id.","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CollectorHealthReportEnvelope"}}},"description":"Collector health report envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Collector health report","tags":["evydence"],"x-scopes":["collector:read"]}},"/v1/collectors/{id}/releases":{"post":{"description":"Records collector release supply-chain evidence for a tenant-scoped collector.","operationId":"recordCollectorRelease","parameters":[{"description":"Collector id.","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RecordCollectorReleaseRequest"}}},"description":"Collector release record request.","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CollectorReleaseEnvelope"}}},"description":"Created collector release envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Record collector release evidence","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["collector:admin"]}},"/v1/commercial-collectors":{"get":{"description":"Lists tenant-scoped commercial collector definitions.","operationId":"listCommercialCollectors","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CommercialCollectorDefinitionListEnvelope"}}},"description":"Commercial collector definition list envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"List commercial collector definitions","tags":["evydence"],"x-scopes":["collector:read"]},"post":{"description":"Creates tenant-scoped commercial collector metadata without installing external code.","operationId":"createCommercialCollector","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateCommercialCollectorRequest"}}},"description":"Commercial collector definition request.","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CommercialCollectorDefinitionEnvelope"}}},"description":"Created commercial collector definition envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Create commercial collector definition","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["collector:admin"]}},"/v1/container-images":{"post":{"description":"Registers OCI/container image metadata and digest evidence linked to an optional artifact.","operationId":"registerContainerImage","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RegisterContainerImageRequest"}}},"description":"Container image registration request.","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ContainerImageEnvelope"}}},"description":"Registered container image envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Register container image","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["evidence:write"]}},"/v1/control-evidence":{"get":{"description":"Lists tenant-scoped control evidence links with optional control, product, and release filters.","operationId":"listControlEvidence","parameters":[{"description":"Filter by security control id.","in":"query","name":"control_id","required":false,"schema":{"type":"string"}},{"description":"Filter by product id.","in":"query","name":"product_id","required":false,"schema":{"type":"string"}},{"description":"Filter by release id.","in":"query","name":"release_id","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ControlEvidenceListEnvelope"}}},"description":"Control evidence list envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"List control evidence","tags":["evydence"],"x-scopes":["controls:read"]}},"/v1/control-framework-template-packs":{"get":{"description":"Lists built-in control framework template packs available for explicit tenant installation.","operationId":"listControlFrameworkTemplatePacks","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ControlFrameworkTemplatePackListEnvelope"}}},"description":"Control framework template pack list envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"List control framework template packs","tags":["evydence"],"x-scopes":["controls:read"]}},"/v1/control-framework-template-packs/{slug}/install":{"post":{"description":"Installs a named control framework template pack into the tenant as ordinary framework/control records.","operationId":"installControlFrameworkTemplatePack","parameters":[{"description":"Control framework template pack slug.","in":"path","name":"slug","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EmptyObject"}}},"description":"Empty JSON object.","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ControlFrameworkEnvelope"}}},"description":"Installed control framework envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Install control framework template pack","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["controls:admin"]}},"/v1/control-frameworks":{"get":{"description":"Lists tenant-scoped control frameworks.","operationId":"listControlFrameworks","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ControlFrameworkListEnvelope"}}},"description":"Control framework list envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"List control frameworks","tags":["evydence"],"x-scopes":["controls:read"]},"post":{"description":"Creates a tenant-scoped versioned control framework.","operationId":"createControlFramework","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateControlFrameworkRequest"}}},"description":"Control framework creation request.","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ControlFrameworkEnvelope"}}},"description":"Created control framework envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Create control framework","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["controls:admin"]}},"/v1/controls":{"post":{"description":"Creates a framework-owned security control with deterministic evidence requirements.","operationId":"createSecurityControl","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateSecurityControlRequest"}}},"description":"Security control creation request.","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SecurityControlEnvelope"}}},"description":"Created security control envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Create security control","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["controls:admin"]}},"/v1/controls/{id}":{"get":{"description":"Returns a tenant-scoped security control by id.","operationId":"getSecurityControl","parameters":[{"description":"Security control id.","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SecurityControlEnvelope"}}},"description":"Security control envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Get security control","tags":["evydence"],"x-scopes":["controls:read"]}},"/v1/controls/{id}/evidence":{"post":{"description":"Creates an append-only link between a security control and tenant-scoped evidence or related release resource.","operationId":"linkControlEvidence","parameters":[{"description":"Security control id.","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LinkControlEvidenceRequest"}}},"description":"Control evidence link request.","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ControlEvidenceEnvelope"}}},"description":"Created control evidence link envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Link control evidence","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["controls:write"]}},"/v1/custom-policies":{"post":{"description":"Creates a deterministic custom policy definition for tenant-managed release checks.","operationId":"createCustomPolicy","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateCustomPolicyRequest"}}},"description":"Custom policy creation request.","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CustomPolicyEnvelope"}}},"description":"Created custom policy envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Create custom policy","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["policy:write"]}},"/v1/custom-policies/{id}/evaluate":{"post":{"description":"Evaluates a tenant custom policy against a release and records the input hash.","operationId":"evaluateCustomPolicy","parameters":[{"description":"Custom policy id.","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluatePolicyRequest"}}},"description":"Custom policy evaluation request.","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CustomPolicyEvaluationEnvelope"}}},"description":"Custom policy evaluation envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Evaluate custom policy","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["policy:read"]}},"/v1/customer-packages":{"post":{"description":"Creates a scoped customer security package manifest using an explicit redaction profile.","operationId":"createCustomerPackage","requestBody":{"content":{"application/json":{"examples":{"release-evidence-package":{"summary":"Create a scoped release evidence package","value":{"expires_at":"2026-06-30T00:00:00Z","product_id":"prod_20260527120000","redaction_profile_id":"rp_20260527120000","release_id":"rel_20260527120000","title":"Payments API 1.0.0 release evidence package"}}},"schema":{"$ref":"#/components/schemas/CreateCustomerPackageRequest"}}},"description":"Customer package creation request.","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CustomerSecurityPackageEnvelope"}}},"description":"Created customer security package envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Create customer security package","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["package:write"]}},"/v1/customer-packages/{id}":{"get":{"description":"Returns a tenant-scoped customer security package manifest by id.","operationId":"getCustomerPackage","parameters":[{"description":"Customer package id.","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CustomerSecurityPackageEnvelope"}}},"description":"Customer security package envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Get customer security package","tags":["evydence"],"x-scopes":["package:read"]}},"/v1/customer-packages/{id}/download":{"get":{"description":"Downloads a scoped customer security package ZIP. The archive contains redacted manifest metadata and verification guidance, not raw tenant evidence payload bytes.","operationId":"downloadCustomerPackage","parameters":[{"description":"Customer package id.","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/zip":{"schema":{"format":"binary","type":"string"}}},"description":"Customer security package ZIP archive."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Download customer security package ZIP","tags":["evydence"],"x-scopes":["package:read"]}},"/v1/customer-portal/access":{"get":{"description":"Lists tenant-scoped external reviewer access records without token hashes or token secrets.","operationId":"listCustomerPortalAccess","parameters":[{"description":"Optional customer package id filter.","in":"query","name":"package_id","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CustomerPortalAccessListEnvelope"}}},"description":"Customer portal access list envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"List customer portal access records","tags":["evydence"],"x-scopes":["package:read"]},"post":{"description":"Creates a named, expiring external reviewer access record for a customer package and returns the portal token once.","operationId":"createCustomerPortalAccess","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateCustomerPortalAccessRequest"}}},"description":"Customer portal access creation request.","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CustomerPortalAccessCreateEnvelope"}}},"description":"Created portal access and one-time token envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Create customer portal access","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["package:write"]}},"/v1/customer-portal/access/{id}/revoke":{"post":{"description":"Revokes a tenant-scoped external reviewer access record; revocation is append-only and the original token cannot be used afterwards.","operationId":"revokeCustomerPortalAccess","parameters":[{"description":"Customer portal access id.","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EmptyObject"}}},"description":"Empty JSON object.","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CustomerPortalAccessEnvelope"}}},"description":"Revoked customer portal access envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Revoke customer portal access","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["package:write"]}},"/v1/customer-portal/package":{"post":{"description":"Public token exchange endpoint for a scoped customer package. It intentionally uses no bearer authentication and accepts only the issued portal token in the JSON body.","operationId":"accessCustomerPortalPackage","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CustomerPortalPackageRequest"}}},"description":"Customer portal token request.","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CustomerSecurityPackageEnvelope"}}},"description":"Scoped customer package envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"summary":"Access customer portal package","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true}}},"/v1/customer-portal/package/download":{"post":{"description":"Public token exchange endpoint for downloading a scoped customer package ZIP. It intentionally uses no bearer authentication and accepts only the issued portal token in the JSON body.","operationId":"downloadCustomerPortalPackage","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CustomerPortalPackageRequest"}}},"description":"Customer portal token request.","required":true},"responses":{"200":{"content":{"application/zip":{"schema":{"format":"binary","type":"string"}}},"description":"Customer security package ZIP archive."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"summary":"Download customer portal package ZIP","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true}}},"/v1/customer-portal/package/view":{"get":{"description":"Public HTML form for reviewing or downloading a scoped customer package with a portal token. It does not accept tokens in URLs and does not use bearer authentication.","operationId":"customerPortalPackageViewForm","responses":{"200":{"content":{"text/html":{"schema":{"type":"string"}}},"description":"Customer portal package review form."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"summary":"Customer portal package review form","tags":["evydence"]},"post":{"description":"Public HTML package review endpoint backed by the customer portal token exchange. Tokens are accepted only as form body fields.","operationId":"customerPortalPackageView","requestBody":{"content":{"application/x-www-form-urlencoded":{"schema":{"$ref":"#/components/schemas/CustomerPortalPackageRequest"}}},"description":"Customer portal token form request.","required":true},"responses":{"200":{"content":{"text/html":{"schema":{"type":"string"}}},"description":"Scoped customer package review HTML."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"summary":"Customer portal package review page","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true}}},"/v1/customer-portal/package/view/download":{"post":{"description":"Public HTML-form package ZIP download endpoint backed by the customer portal token exchange. Tokens are accepted only as form body fields.","operationId":"downloadCustomerPortalPackageView","requestBody":{"content":{"application/x-www-form-urlencoded":{"schema":{"$ref":"#/components/schemas/CustomerPortalPackageRequest"}}},"description":"Customer portal token form request.","required":true},"responses":{"200":{"content":{"application/zip":{"schema":{"format":"binary","type":"string"}}},"description":"Customer security package ZIP archive."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"summary":"Download customer portal package ZIP from review form","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true}}},"/v1/deployments":{"get":{"description":"Lists tenant-scoped deployment events by optional release and environment filters.","operationId":"listDeployments","parameters":[{"description":"Deployment environment id.","in":"query","name":"environment_id","required":false,"schema":{"type":"string"}},{"description":"Release id.","in":"query","name":"release_id","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeploymentEventListEnvelope"}}},"description":"Deployment event list envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"List deployments","tags":["evydence"],"x-scopes":["deployment:read"]},"post":{"description":"Records append-only deployment evidence for a release/environment/artifact set.","operationId":"recordDeployment","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RecordDeploymentRequest"}}},"description":"Deployment event creation request.","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeploymentEventEnvelope"}}},"description":"Created deployment event envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Record deployment","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["deployment:write"]}},"/v1/deployments/{id}":{"get":{"description":"Returns a tenant-scoped deployment event by id.","operationId":"getDeployment","parameters":[{"description":"Deployment event id.","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeploymentEventEnvelope"}}},"description":"Deployment event envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Get deployment","tags":["evydence"],"x-scopes":["deployment:read"]}},"/v1/dsse-trust-roots":{"post":{"description":"Creates a tenant-scoped DSSE trust root using public verification key material only.","operationId":"createDSSETrustRoot","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateDSSETrustRootRequest"}}},"description":"DSSE trust-root creation request.","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DSSETrustRootEnvelope"}}},"description":"Created DSSE trust root envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Create DSSE trust root","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["keys:admin"]}},"/v1/environments":{"get":{"description":"Lists tenant-scoped deployment environments, optionally filtered by product.","operationId":"listDeploymentEnvironments","parameters":[{"description":"Product id.","in":"query","name":"product_id","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeploymentEnvironmentListEnvelope"}}},"description":"Deployment environment list envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"List deployment environments","tags":["evydence"],"x-scopes":["deployment:read"]},"post":{"description":"Creates a tenant-scoped deployment environment for release deployment evidence.","operationId":"createDeploymentEnvironment","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateDeploymentEnvironmentRequest"}}},"description":"Deployment environment creation request.","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeploymentEnvironmentEnvelope"}}},"description":"Created deployment environment envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Create deployment environment","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["deployment:write"]}},"/v1/evidence":{"get":{"description":"Lists tenant-scoped evidence by optional release and evidence type filters.","operationId":"listEvidence","parameters":[{"description":"Filter by release id.","in":"query","name":"release_id","required":false,"schema":{"type":"string"}},{"description":"Filter by evidence type.","in":"query","name":"type","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvidenceItemListEnvelope"}}},"description":"Evidence item list envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"List evidence","tags":["evydence"],"x-scopes":["evidence:read"]},"post":{"description":"Creates immutable evidence metadata and optional raw payload evidence. Evidence core fields are append-only after creation.","operationId":"createEvidence","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateEvidenceRequest"}}},"description":"Evidence creation request.","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvidenceItemEnvelope"}}},"description":"Created evidence item envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Create evidence","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["evidence:write"]}},"/v1/evidence-bundles":{"post":{"description":"Exports a portable evidence bundle manifest with hashes, signatures, and verification text.","operationId":"exportEvidenceBundle","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ExportEvidenceBundleRequest"}}},"description":"Evidence bundle export request.","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvidenceBundleEnvelope"}}},"description":"Created evidence bundle envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Export evidence bundle","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["bundle:read"]}},"/v1/evidence-bundles/import":{"post":{"description":"Imports a portable evidence bundle manifest and records deterministic import metadata.","operationId":"importEvidenceBundle","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvidenceBundle"}}},"description":"Evidence bundle import request.","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvidenceBundleImportEnvelope"}}},"description":"Evidence bundle import result envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Import evidence bundle","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["bundle:write"]}},"/v1/evidence-graph-snapshots":{"post":{"description":"Creates a deterministic product/release evidence adjacency snapshot from stored tenant-scoped evidence records.","operationId":"createGraphSnapshot","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateGraphSnapshotRequest"}}},"description":"Evidence graph snapshot creation request.","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvidenceGraphSnapshotEnvelope"}}},"description":"Created evidence graph snapshot envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Create evidence graph snapshot","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["evidence:read"]}},"/v1/evidence-summaries":{"post":{"description":"Creates an evidence-backed summary with citations, assumptions, and limitations.","operationId":"createEvidenceSummary","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateEvidenceSummaryRequest"}}},"description":"Evidence summary creation request.","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvidenceSummaryEnvelope"}}},"description":"Created evidence summary envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Create evidence-backed summary","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["report:read"]}},"/v1/evidence/search":{"get":{"description":"Searches tenant-scoped evidence with deterministic filters and cursor-style pagination.","operationId":"searchEvidence","parameters":[{"description":"Opaque pagination cursor.","in":"query","name":"cursor","required":false,"schema":{"type":"string"}},{"description":"Maximum returned records.","in":"query","name":"limit","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Filter by product id.","in":"query","name":"product_id","required":false,"schema":{"type":"string"}},{"description":"Filter by project id.","in":"query","name":"project_id","required":false,"schema":{"type":"string"}},{"description":"Filter by release id.","in":"query","name":"release_id","required":false,"schema":{"type":"string"}},{"description":"Filter by evidence source.","in":"query","name":"source","required":false,"schema":{"type":"string"}},{"description":"Filter by a single evidence tag.","in":"query","name":"tag","required":false,"schema":{"type":"string"}},{"description":"Filter by evidence type.","in":"query","name":"type","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvidenceSearchEnvelope"}}},"description":"Evidence search result envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Search evidence","tags":["evydence"],"x-scopes":["evidence:read"]}},"/v1/evidence/{id}":{"get":{"description":"Returns a tenant-scoped immutable evidence item by id.","operationId":"getEvidence","parameters":[{"description":"Evidence item id.","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvidenceItemEnvelope"}}},"description":"Evidence item envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Get evidence","tags":["evydence"],"x-scopes":["evidence:read"]}},"/v1/evidence/{id}/lifecycle-events":{"get":{"description":"Lists append-only lifecycle events for a tenant-scoped evidence item.","operationId":"listEvidenceLifecycleEvents","parameters":[{"description":"Evidence item id.","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvidenceLifecycleEventListEnvelope"}}},"description":"Evidence lifecycle event list envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"List evidence lifecycle events","tags":["evydence"],"x-scopes":["evidence:read"]},"post":{"description":"Appends an evidence lifecycle event such as amendment, redaction marker, tombstone, or retention marker.","operationId":"recordEvidenceLifecycleEvent","parameters":[{"description":"Evidence item id.","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RecordEvidenceLifecycleEventRequest"}}},"description":"Evidence lifecycle event request.","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvidenceLifecycleEventEnvelope"}}},"description":"Created evidence lifecycle event envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Record evidence lifecycle event","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["evidence:write"]}},"/v1/evidence/{id}/link":{"post":{"description":"Creates an append-only relationship from evidence to another tenant-scoped subject.","operationId":"linkEvidence","parameters":[{"description":"Evidence item id.","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LinkEvidenceRequest"}}},"description":"Evidence link request.","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvidenceItemEnvelope"}}},"description":"Linked evidence item envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Link evidence","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["evidence:write"]}},"/v1/evidence/{id}/supersede":{"post":{"description":"Supersedes immutable evidence by linking it to replacement evidence and appending lifecycle metadata.","operationId":"supersedeEvidence","parameters":[{"description":"Evidence item id.","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SupersedeEvidenceRequest"}}},"description":"Evidence supersession request.","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvidenceItemEnvelope"}}},"description":"Superseded evidence item envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Supersede evidence","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["evidence:write"]}},"/v1/exceptions":{"get":{"description":"Lists tenant-scoped exceptions, optionally filtered by release.","operationId":"listExceptions","parameters":[{"description":"Release id.","in":"query","name":"release_id","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ExceptionListEnvelope"}}},"description":"Exception list envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"List exceptions","tags":["evydence"],"x-scopes":["verify:read"]},"post":{"description":"Creates a scoped, expiring release/finding/control exception that is inactive until approved.","operationId":"createException","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateExceptionRequest"}}},"description":"Exception creation request.","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ExceptionEnvelope"}}},"description":"Created exception envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Create exception","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["release:write"]}},"/v1/exceptions/{id}/approve":{"post":{"description":"Approves an unexpired exception as an audited append-only transition.","operationId":"approveException","parameters":[{"description":"Exception id.","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EmptyObject"}}},"description":"Empty JSON object.","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ExceptionEnvelope"}}},"description":"Approved exception envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Approve exception","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["release:write"]}},"/v1/health":{"get":{"description":"Returns low-detail liveness status without touching tenant evidence or secret material.","operationId":"health","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HealthStatusEnvelope"}}},"description":"Liveness status envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"summary":"Health","tags":["evydence"]}},"/v1/incident-webhooks/{receiver_id}":{"post":{"description":"Public signed webhook endpoint for incident timeline events. It verifies Ed25519 signature, event id replay, and timestamp before parsing payload fields.","operationId":"receiveIncidentWebhook","parameters":[{"description":"Provider event id used for replay detection.","in":"header","name":"X-Evydence-Webhook-Event-ID","required":true,"schema":{"type":"string"}},{"description":"ed25519=\u003cbase64 signature\u003e over timestamp, event id, and raw body.","in":"header","name":"X-Evydence-Webhook-Signature","required":true,"schema":{"type":"string"}},{"description":"RFC3339 timestamp included in the signed payload.","in":"header","name":"X-Evydence-Webhook-Timestamp","required":true,"schema":{"type":"string"}},{"description":"Incident webhook receiver id.","in":"path","name":"receiver_id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SignedIncidentWebhookPayload"}}},"description":"Signed incident timeline event payload.","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/IncidentWebhookDeliveryEnvelope"}}},"description":"Accepted webhook event and timeline event envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"summary":"Receive signed incident webhook event","tags":["evydence"]}},"/v1/incidents":{"post":{"description":"Creates an append-only incident record linked to tenant-scoped product and optional release evidence.","operationId":"createIncident","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateIncidentRequest"}}},"description":"Incident creation request.","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/IncidentEnvelope"}}},"description":"Created incident envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Create incident","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["incident:write"]}},"/v1/incidents/{id}/timeline":{"post":{"description":"Appends an incident timeline event and optional evidence reference.","operationId":"recordIncidentTimeline","parameters":[{"description":"Incident id.","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RecordIncidentTimelineRequest"}}},"description":"Incident timeline event request.","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/IncidentTimelineEventEnvelope"}}},"description":"Created incident timeline event envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Record incident timeline event","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["incident:write"]}},"/v1/incidents/{id}/webhook-receivers":{"post":{"description":"Creates an incident-scoped webhook receiver with an Ed25519 public key. The matching private key stays with the external incident tool.","operationId":"createIncidentWebhookReceiver","parameters":[{"description":"Incident id.","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateIncidentWebhookReceiverRequest"}}},"description":"Incident webhook receiver creation request.","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/IncidentWebhookReceiverEnvelope"}}},"description":"Created incident webhook receiver envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Create signed incident webhook receiver","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["incident:write"]}},"/v1/legal-holds":{"post":{"description":"Creates an append-only legal-hold marker for a tenant-scoped retention subject.","operationId":"createLegalHold","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateLegalHoldRequest"}}},"description":"Legal hold creation request.","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LegalHoldEnvelope"}}},"description":"Created legal hold envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Create legal hold","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["admin"]}},"/v1/marketplace-collectors":{"get":{"description":"Lists tenant-scoped marketplace collector package metadata.","operationId":"listMarketplaceCollectors","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MarketplaceCollectorListEnvelope"}}},"description":"Marketplace collector list envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"List marketplace collector records","tags":["evydence"],"x-scopes":["collector:read"]},"post":{"description":"Creates tenant-scoped marketplace collector package metadata and evidence references.","operationId":"createMarketplaceCollector","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateMarketplaceCollectorRequest"}}},"description":"Marketplace collector creation request.","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MarketplaceCollectorEnvelope"}}},"description":"Created marketplace collector envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Create marketplace collector record","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["collector:admin"]}},"/v1/marketplace-collectors/{id}/health":{"get":{"description":"Returns marketplace collector package health from recorded signature, SBOM, and scan evidence.","operationId":"marketplaceCollectorHealth","parameters":[{"description":"Marketplace collector id.","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MarketplaceCollectorHealthReportEnvelope"}}},"description":"Marketplace collector health report envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Marketplace collector health report","tags":["evydence"],"x-scopes":["collector:read"]}},"/v1/merkle-batches":{"post":{"description":"Creates a Merkle batch over tenant audit-chain entries for checkpoint export or transparency anchoring.","operationId":"createMerkleBatch","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateMerkleBatchRequest"}}},"description":"Merkle batch creation request.","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MerkleBatchEnvelope"}}},"description":"Created Merkle batch envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Create Merkle checkpoint batch","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["keys:admin"]}},"/v1/merkle-batches/{id}/verify":{"get":{"description":"Verifies a tenant-scoped Merkle batch root and leaf set against stored audit-chain entries.","operationId":"verifyMerkleBatch","parameters":[{"description":"Merkle batch id.","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/VerificationResultEnvelope"}}},"description":"Merkle batch verification envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Verify Merkle checkpoint batch","tags":["evydence"],"x-scopes":["verify:read"]}},"/v1/metrics":{"get":{"description":"Returns safe tenant-scoped resource metrics for admin actors. A Prometheus text response is also available when requested with Accept: text/plain.","operationId":"metrics","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MetricsSnapshotEnvelope"}},"text/plain":{"schema":{"type":"string"}}},"description":"Tenant metrics envelope or Prometheus text metrics."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Safe tenant metrics","tags":["evydence"],"x-scopes":["admin"]}},"/v1/object-retention-policies":{"post":{"description":"Creates an object retention policy record for storage immutability verification.","operationId":"createObjectRetentionPolicy","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateObjectRetentionPolicyRequest"}}},"description":"Object retention policy creation request.","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ObjectRetentionPolicyEnvelope"}}},"description":"Created object retention policy envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Create object retention policy record","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["admin"]}},"/v1/object-retention-policies/{id}/verify":{"post":{"description":"Records verification metadata for a tenant object retention policy.","operationId":"verifyObjectRetentionPolicy","parameters":[{"description":"Object retention policy id.","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EmptyObject"}}},"description":"Empty JSON object.","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ObjectRetentionPolicyEnvelope"}}},"description":"Verified object retention policy envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Verify object retention policy record","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["verify:read"]}},"/v1/openapi-contracts":{"post":{"description":"Uploads an OpenAPI 3.1 contract, stores raw bytes as evidence, and records normalized operation metadata.","operationId":"uploadOpenAPIContract","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UploadOpenAPIContractRequest"}}},"description":"OpenAPI contract upload request.","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OpenAPIContractEnvelope"}}},"description":"Created OpenAPI contract envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Upload OpenAPI contract","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["evidence:write"]}},"/v1/openapi-contracts/{id}":{"get":{"description":"Returns a tenant-scoped OpenAPI contract metadata record by id.","operationId":"getOpenAPIContract","parameters":[{"description":"OpenAPI contract id.","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OpenAPIContractEnvelope"}}},"description":"OpenAPI contract envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Get OpenAPI contract","tags":["evydence"],"x-scopes":["evidence:read"]}},"/v1/openapi-diffs":{"post":{"description":"Creates a deterministic OpenAPI contract diff for release contract checks.","operationId":"createOpenAPIDiff","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateOpenAPIDiffRequest"}}},"description":"OpenAPI contract diff request.","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ContractDiffEnvelope"}}},"description":"Created OpenAPI contract diff envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Create OpenAPI contract diff","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["evidence:read"]}},"/v1/openapi.json":{"get":{"description":"Returns the generated OpenAPI 3.1 document served by this process.","operationId":"openapi","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OpenAPIDocument"}}},"description":"OpenAPI document."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"summary":"OpenAPI","tags":["evydence"]}},"/v1/organizations":{"post":{"description":"Creates a tenant-scoped organization record for human identity grouping.","operationId":"createOrganization","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateOrganizationRequest"}}},"description":"Organization creation request.","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OrganizationEnvelope"}}},"description":"Created organization envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Create organization","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["identity:admin"]}},"/v1/policies/evaluate":{"post":{"description":"Evaluates built-in deterministic release policy checks for a release.","operationId":"evaluatePolicy","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluatePolicyRequest"}}},"description":"Policy evaluation request.","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PolicyEvaluationEnvelope"}}},"description":"Policy evaluation envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Evaluate release policy","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["verify:read"]}},"/v1/products":{"get":{"description":"Lists tenant-scoped products visible to the authenticated actor.","operationId":"listProducts","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProductListEnvelope"}}},"description":"Product list envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"List products","tags":["evydence"],"x-scopes":["product:read"]},"post":{"description":"Creates a tenant-scoped product. Product slugs must be unique per tenant.","operationId":"createProduct","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateProductRequest"}}},"description":"Product creation request.","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProductEnvelope"}}},"description":"Created product envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Create product","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["product:write"]}},"/v1/products/{id}":{"get":{"description":"Returns a tenant-scoped product by id.","operationId":"getProduct","parameters":[{"description":"Product id.","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProductEnvelope"}}},"description":"Product envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Get product","tags":["evydence"],"x-scopes":["product:read"]}},"/v1/projects":{"post":{"description":"Creates a tenant-scoped project under a product.","operationId":"createProject","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateProjectRequest"}}},"description":"Project creation request.","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProjectEnvelope"}}},"description":"Created project envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Create project","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["project:write"]}},"/v1/projects/{id}":{"get":{"description":"Returns a tenant-scoped project by id.","operationId":"getProject","parameters":[{"description":"Project id.","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProjectEnvelope"}}},"description":"Project envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Get project","tags":["evydence"],"x-scopes":["project:read"]}},"/v1/provider-verifications":{"post":{"description":"Verifies stored provider identity metadata and, when supplied, locally verifies OIDC ID-token or SAML assertion issuer, audience, subject, time bounds, and signature against configured tenant trust material.","operationId":"verifyProviderIdentity","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/VerifyProviderIdentityRequest"}}},"description":"Provider identity verification request.","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProviderVerificationEnvelope"}}},"description":"Provider verification envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Verify stored provider identity metadata","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["identity:admin"]}},"/v1/public-transparency-log-entries":{"post":{"description":"Records publication metadata for a checkpoint submitted to a configured public transparency log.","operationId":"publishPublicTransparencyLogEntry","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublishPublicTransparencyLogEntryRequest"}}},"description":"Public transparency log entry publication request.","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicTransparencyLogEntryEnvelope"}}},"description":"Created public transparency log entry envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Publish public transparency log entry record","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["keys:admin"]}},"/v1/public-transparency-log-entries/{id}/fetch-proof":{"post":{"description":"Fetches public transparency inclusion proof material from the configured log endpoint or transparency proof gateway and verifies it locally. Endpoint trust and provider semantics remain deployment responsibilities.","operationId":"fetchPublicTransparencyLogEntryProof","parameters":[{"description":"Public transparency log entry id.","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EmptyObject"}}},"description":"Empty JSON object.","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicTransparencyLogEntryEnvelope"}}},"description":"Fetched and verified public transparency log entry envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Fetch and verify public transparency log inclusion proof","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["keys:admin"]}},"/v1/public-transparency-log-entries/{id}/verify":{"post":{"description":"Verifies operator-supplied RFC6962-style public transparency inclusion proof material for a published entry.","operationId":"verifyPublicTransparencyLogEntry","parameters":[{"description":"Public transparency log entry id.","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/VerifyPublicTransparencyLogEntryRequest"}}},"description":"Public transparency log inclusion proof verification request.","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicTransparencyLogEntryEnvelope"}}},"description":"Verified public transparency log entry envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Verify public transparency log inclusion proof","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["keys:admin"]}},"/v1/public-transparency-logs":{"post":{"description":"Creates tenant metadata for an optional public transparency log trust root.","operationId":"createPublicTransparencyLog","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreatePublicTransparencyLogRequest"}}},"description":"Public transparency log creation request.","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicTransparencyLogEnvelope"}}},"description":"Created public transparency log envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Create public transparency log record","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["keys:admin"]}},"/v1/questionnaire-answer-library":{"get":{"description":"Lists tenant-scoped questionnaire answer library entries with optional question, product, and release filters.","operationId":"listQuestionnaireAnswerLibrary","parameters":[{"description":"Filter by product id.","in":"query","name":"product_id","required":false,"schema":{"type":"string"}},{"description":"Filter by questionnaire question id.","in":"query","name":"question_id","required":false,"schema":{"type":"string"}},{"description":"Filter by release id.","in":"query","name":"release_id","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/QuestionnaireAnswerLibraryEntryListEnvelope"}}},"description":"Questionnaire answer library entry list envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"List questionnaire answer library entries","tags":["evydence"],"x-scopes":["package:read"]},"post":{"description":"Creates a tenant-scoped reusable questionnaire answer draft linked to optional evidence, product, release, or control scope.","operationId":"createQuestionnaireAnswerLibraryEntry","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateQuestionnaireAnswerLibraryEntryRequest"}}},"description":"Questionnaire answer library entry creation request.","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/QuestionnaireAnswerLibraryEntryEnvelope"}}},"description":"Created questionnaire answer library entry envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Create questionnaire answer library entry","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["package:write"]}},"/v1/questionnaire-drafts":{"post":{"description":"Creates an evidence-backed questionnaire draft with limitations.","operationId":"createQuestionnaireDraft","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateQuestionnaireDraftRequest"}}},"description":"Questionnaire draft creation request.","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/QuestionnaireDraftEnvelope"}}},"description":"Created questionnaire draft envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Create evidence-backed questionnaire draft","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["package:read"]}},"/v1/questionnaire-packages":{"post":{"description":"Creates a questionnaire response package from a template and scoped evidence package.","operationId":"createQuestionnairePackage","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateQuestionnairePackageRequest"}}},"description":"Questionnaire package creation request.","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/QuestionnairePackageEnvelope"}}},"description":"Created questionnaire package envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Create questionnaire package","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["package:write"]}},"/v1/questionnaire-templates":{"post":{"description":"Creates a tenant questionnaire template with explicit evidence/control mapping fields.","operationId":"createQuestionnaireTemplate","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateQuestionnaireTemplateRequest"}}},"description":"Questionnaire template creation request.","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/QuestionnaireTemplateEnvelope"}}},"description":"Created questionnaire template envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Create questionnaire template","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["package:write"]}},"/v1/ready":{"get":{"description":"Returns low-detail process readiness without tenant evidence or secret material.","operationId":"ready","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReadinessStatusEnvelope"}}},"description":"Readiness status envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"summary":"Readiness","tags":["evydence"]}},"/v1/redaction-profiles":{"post":{"description":"Creates an explicit redaction profile for customer and report package generation.","operationId":"createRedactionProfile","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateRedactionProfileRequest"}}},"description":"Redaction profile creation request.","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RedactionProfileEnvelope"}}},"description":"Created redaction profile envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Create redaction profile","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["package:write"]}},"/v1/release-bundles":{"post":{"description":"Creates an immutable signed release bundle for a release.","operationId":"createReleaseBundle","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateReleaseBundleRequest"}}},"description":"Release bundle creation request.","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReleaseBundleEnvelope"}}},"description":"Created release bundle envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Create release bundle","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["bundle:write"]}},"/v1/release-bundles/{id}":{"get":{"description":"Returns a tenant-scoped immutable release bundle by id.","operationId":"getReleaseBundle","parameters":[{"description":"Release bundle id.","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReleaseBundleEnvelope"}}},"description":"Release bundle envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Get release bundle","tags":["evydence"],"x-scopes":["bundle:read"]}},"/v1/release-bundles/{id}/manifest":{"get":{"description":"Returns the deterministic release bundle manifest by bundle id.","operationId":"getReleaseBundleManifest","parameters":[{"description":"Release bundle id.","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReleaseBundleManifestEnvelope"}}},"description":"Release bundle manifest envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Get release bundle manifest","tags":["evydence"],"x-scopes":["bundle:read"]}},"/v1/release-bundles/{id}/verify":{"get":{"description":"Verifies a tenant-scoped release bundle and returns a deterministic verification result.","operationId":"verifyReleaseBundle","parameters":[{"description":"Release bundle id.","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/VerificationResultEnvelope"}}},"description":"Release bundle verification envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Verify release bundle","tags":["evydence"],"x-scopes":["verify:read"]}},"/v1/release-candidates":{"get":{"description":"Lists tenant-scoped release candidates, optionally filtered by release.","operationId":"listReleaseCandidates","parameters":[{"description":"Release id.","in":"query","name":"release_id","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReleaseCandidateListEnvelope"}}},"description":"Release candidate list envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"List release candidates","tags":["evydence"],"x-scopes":["release:read"]},"post":{"description":"Creates an immutable release-candidate snapshot of selected release evidence references.","operationId":"createReleaseCandidate","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateReleaseCandidateRequest"}}},"description":"Release candidate creation request.","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReleaseCandidateEnvelope"}}},"description":"Created release candidate envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Create release candidate","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["release:write"]}},"/v1/release-candidates/{id}":{"get":{"description":"Returns a tenant-scoped release candidate by id.","operationId":"getReleaseCandidate","parameters":[{"description":"Release candidate id.","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReleaseCandidateEnvelope"}}},"description":"Release candidate envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Get release candidate","tags":["evydence"],"x-scopes":["release:read"]}},"/v1/release-candidates/{id}/promote":{"post":{"description":"Records a release-candidate lifecycle transition without mutating the original snapshot.","operationId":"promoteReleaseCandidate","parameters":[{"description":"Release candidate id.","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReleaseCandidateTransitionRequest"}}},"description":"Release candidate transition request.","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReleaseCandidateEnvelope"}}},"description":"Transitioned release candidate envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Promote release candidate","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["release:write"]}},"/v1/release-candidates/{id}/reject":{"post":{"description":"Records a release-candidate lifecycle transition without mutating the original snapshot.","operationId":"rejectReleaseCandidate","parameters":[{"description":"Release candidate id.","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReleaseCandidateTransitionRequest"}}},"description":"Release candidate transition request.","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReleaseCandidateEnvelope"}}},"description":"Transitioned release candidate envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Reject release candidate","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["release:write"]}},"/v1/releases":{"post":{"description":"Creates an append-only release record under a product and optional project.","operationId":"createRelease","requestBody":{"content":{"application/json":{"examples":{"release-candidate":{"summary":"Create a release for evidence collection","value":{"product_id":"prod_20260527120000","project_id":"proj_20260527120000","version":"1.0.0-rc.1"}}},"schema":{"$ref":"#/components/schemas/CreateReleaseRequest"}}},"description":"Release creation request.","required":true},"responses":{"201":{"content":{"application/json":{"examples":{"created-release":{"summary":"Created release response","value":{"data":{"created_at":"2026-05-27T12:00:00Z","id":"rel_20260527120000","product_id":"prod_20260527120000","project_id":"proj_20260527120000","schema_version":"release.v1.0.0","status":"draft","tenant_id":"ten_20260527120000","version":"1.0.0-rc.1"},"meta":{"api_version":"v1"}}}},"schema":{"$ref":"#/components/schemas/ReleaseEnvelope"}}},"description":"Created release envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Create release","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["release:write"]}},"/v1/releases/{id}":{"get":{"description":"Returns a tenant-scoped release by id.","operationId":"getRelease","parameters":[{"description":"Release id.","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReleaseEnvelope"}}},"description":"Release envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Get release","tags":["evydence"],"x-scopes":["release:read"]}},"/v1/releases/{id}/approve":{"post":{"description":"Approves a release as an append-only transition.","operationId":"approveRelease","parameters":[{"description":"Release id.","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EmptyObject"}}},"description":"Empty JSON object.","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReleaseEnvelope"}}},"description":"Approved release envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Approve release","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["release:write"]}},"/v1/releases/{id}/evidence-flow/start":{"post":{"description":"Returns a high-level release evidence workflow plan, current evidence counts, required scopes, assumptions, and limitations. This read-only convenience operation does not create evidence.","operationId":"startReleaseEvidenceFlow","parameters":[{"description":"Release id.","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReleaseEvidenceFlowEnvelope"}}},"description":"Release evidence flow envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Start release evidence flow","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["release:read"]}},"/v1/releases/{id}/freeze":{"post":{"description":"Freezes a release as an append-only transition.","operationId":"freezeRelease","parameters":[{"description":"Release id.","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EmptyObject"}}},"description":"Empty JSON object.","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReleaseEnvelope"}}},"description":"Frozen release envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Freeze release","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["release:write"]}},"/v1/releases/{id}/security-summary":{"get":{"description":"Returns a tenant-scoped release security summary for review surfaces without raw evidence payload bytes.","operationId":"releaseSecuritySummary","parameters":[{"description":"Release id.","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReleaseSecuritySummaryEnvelope"}}},"description":"Release security summary envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Release security summary","tags":["evydence"],"x-scopes":["report:read"]}},"/v1/remediation-tasks":{"post":{"description":"Creates an incident or release remediation task linked to optional evidence.","operationId":"createRemediationTask","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateRemediationTaskRequest"}}},"description":"Remediation task creation request.","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RemediationTaskEnvelope"}}},"description":"Created remediation task envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Create remediation task","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["incident:write"]}},"/v1/report-templates":{"post":{"description":"Creates a tenant-defined deterministic report template with an explicit allowed-field list.","operationId":"createReportTemplate","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateReportTemplateRequest"}}},"description":"Report template creation request.","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CustomReportTemplateEnvelope"}}},"description":"Created report template envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Create report template","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["report:read"]}},"/v1/report-templates/{id}/render":{"post":{"description":"Renders a tenant report template for a scoped subject using allowed fields only.","operationId":"renderReportTemplate","parameters":[{"description":"Report template id.","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RenderReportTemplateRequest"}}},"description":"Report template render request.","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RenderedCustomReportEnvelope"}}},"description":"Rendered report envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Render report template","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["report:read"]}},"/v1/reports/anomaly":{"post":{"description":"Creates a deterministic anomaly report over existing tenant evidence and metrics with assumptions and limitations.","operationId":"generateAnomalyReport","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateAnomalyReportRequest"}}},"description":"Anomaly report creation request.","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AnomalyReportEnvelope"}}},"description":"Created anomaly report envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Generate deterministic anomaly report","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["report:read"]}},"/v1/reports/control-coverage":{"get":{"description":"Returns deterministic control coverage with linked evidence, missing evidence, assumptions, and limitations.","operationId":"controlCoverageReport","parameters":[{"description":"Control framework id.","in":"query","name":"framework_id","required":false,"schema":{"type":"string"}},{"description":"Product id.","in":"query","name":"product_id","required":false,"schema":{"type":"string"}},{"description":"Release id.","in":"query","name":"release_id","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReadinessReportEnvelope"}}},"description":"Control coverage report envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Control coverage report","tags":["evydence"],"x-scopes":["report:read"]}},"/v1/reports/cra-readiness":{"get":{"description":"Returns a CRA-oriented readiness report without legal compliance or certification conclusions.","operationId":"craReadinessReport","parameters":[{"description":"Product id.","in":"query","name":"product_id","required":false,"schema":{"type":"string"}},{"description":"Release id.","in":"query","name":"release_id","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReadinessReportEnvelope"}}},"description":"CRA readiness report envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"CRA readiness report","tags":["evydence"],"x-scopes":["report:read"]}},"/v1/reports/cra-readiness-html":{"get":{"description":"Creates a deterministic CRA-readiness HTML package without legal compliance or certification conclusions.","operationId":"craReadinessHTMLPackage","parameters":[{"description":"Product id.","in":"query","name":"product_id","required":false,"schema":{"type":"string"}},{"description":"Release id.","in":"query","name":"release_id","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTMLReportPackageEnvelope"}}},"description":"CRA readiness HTML package envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"CRA readiness HTML package","tags":["evydence"],"x-scopes":["report:read"]}},"/v1/reports/cra-vulnerability-handling":{"get":{"description":"Returns a CRA-oriented vulnerability handling evidence report without legal compliance, certification, scanner-authority, or release-security conclusions.","operationId":"craVulnerabilityHandlingReport","parameters":[{"description":"Product id.","in":"query","name":"product_id","required":false,"schema":{"type":"string"}},{"description":"Release id.","in":"query","name":"release_id","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CRAVulnerabilityHandlingReportEnvelope"}}},"description":"CRA vulnerability handling report envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"CRA vulnerability handling report","tags":["evydence"],"x-scopes":["report:read"]}},"/v1/reports/custody-review":{"get":{"description":"Returns tenant signing-provider and object-lock verification metadata for deployment custody review. It is evidence metadata only, not legal compliance proof, certification, HSM custody proof, or a secure-deployment guarantee.","operationId":"signingCustodyReviewReport","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SigningCustodyReviewReportEnvelope"}}},"description":"Signing custody review report envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Signing custody review report","tags":["evydence"],"x-scopes":["keys:admin"]}},"/v1/reports/incident-package":{"get":{"description":"Returns a deterministic incident package report with timeline, remediation tasks, linked evidence, assumptions, and limitations.","operationId":"incidentReport","parameters":[{"description":"Incident id.","in":"query","name":"incident_id","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/IncidentReportEnvelope"}}},"description":"Incident package report envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Incident package report","tags":["evydence"],"x-scopes":["incident:read"]}},"/v1/reports/missing-evidence":{"get":{"description":"Returns a deterministic missing-evidence report for a release with assumptions and limitations.","operationId":"missingEvidenceReport","parameters":[{"description":"Release id.","in":"query","name":"release_id","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MissingEvidenceReportEnvelope"}}},"description":"Missing evidence report envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Missing evidence report","tags":["evydence"],"x-scopes":["verify:read"]}},"/v1/reports/pdf":{"post":{"description":"Creates a deterministic PDF report package record and payload metadata.","operationId":"createPDFReportPackage","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreatePDFReportPackageRequest"}}},"description":"PDF report package creation request.","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PDFReportPackageEnvelope"}}},"description":"Created PDF report package envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Create reproducible PDF report package","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["report:read"]}},"/v1/reports/release-readiness":{"get":{"description":"Returns a deterministic release-readiness report with gaps, assumptions, and limitations.","operationId":"releaseReadinessReport","parameters":[{"description":"Release id.","in":"query","name":"release_id","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"examples":{"failed-readiness-with-gap":{"summary":"Readiness report with a vulnerability decision gap","value":{"data":{"accepted_exceptions":[],"assumptions":["Readiness is based on tenant evidence recorded in Evydence."],"blocking_findings":[{"component":"pkg:apk/openssl@3.1.0-r0","decision_state":"missing","finding_id":"scan_20260527120000:finding:1","release_id":"rel_20260527120000","scan_id":"scan_20260527120000","severity":"critical","state":"open","vulnerability":"CVE-2026-0099"}],"generated_at":"2026-05-27T12:00:00Z","limitations":["Readiness supports technical review and does not prove legal compliance, certification, complete vulnerability coverage, or release security."],"missing_evidence":["vulnerability_decision"],"policy_set":"policy-set.v1.0.0","product_id":"prod_20260527120000","release_id":"rel_20260527120000","report_type":"release_readiness","result":"failed","schema_version":"release-readiness.v1.0.0","summary":{"headline":"Release evidence has one blocking vulnerability decision gap.","human_summary":"Required artifact, SBOM, scan, build, and bundle evidence is present, but one critical finding still needs a valid decision or approved exception.","policy_set":"policy-set.v1.0.0","result":"failed"},"template_version":"release-readiness.v1.0.0"},"meta":{"api_version":"v1"}}}},"schema":{"$ref":"#/components/schemas/ReadinessReportEnvelope"}}},"description":"Release readiness report envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Release readiness report","tags":["evydence"],"x-scopes":["verify:read"]}},"/v1/reports/retention":{"get":{"description":"Returns a retention report for tenant-scoped holds and overrides with storage verification limitations.","operationId":"retentionReport","parameters":[{"description":"Optional retention scope id.","in":"query","name":"scope_id","required":false,"schema":{"type":"string"}},{"description":"Optional retention scope type.","in":"query","name":"scope_type","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RetentionReportEnvelope"}}},"description":"Retention report envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Retention report","tags":["evydence"],"x-scopes":["admin"]}},"/v1/reports/security-review-package":{"get":{"description":"Returns a redaction-aware security-review package report with assumptions and limitations.","operationId":"securityReviewPackageReport","parameters":[{"description":"Customer package id.","in":"query","name":"package_id","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SecurityReviewPackageReportEnvelope"}}},"description":"Security review package report envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Security review package report","tags":["evydence"],"x-scopes":["package:read"]}},"/v1/reports/security-update-evidence":{"get":{"description":"Returns a security update evidence report for release-scoped fixed decisions, incidents, remediation tasks, and linked evidence without legal or security conclusions.","operationId":"securityUpdateEvidenceReport","parameters":[{"description":"Product id.","in":"query","name":"product_id","required":false,"schema":{"type":"string"}},{"description":"Release id.","in":"query","name":"release_id","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SecurityUpdateEvidenceReportEnvelope"}}},"description":"Security update evidence report envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Security update evidence report","tags":["evydence"],"x-scopes":["report:read"]}},"/v1/reports/vulnerability-decision-summary":{"get":{"description":"Returns customer-safe active vulnerability decision summaries for a release with assumptions and limitations. Raw payloads and internal notes are excluded.","operationId":"vulnerabilityDecisionSummaryReport","parameters":[{"description":"Release id.","in":"query","name":"release_id","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/VulnerabilityDecisionSummaryReportEnvelope"}}},"description":"Vulnerability decision summary report envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Customer-safe vulnerability decision summary","tags":["evydence"],"x-scopes":["report:read"]}},"/v1/reports/vulnerability-posture":{"get":{"description":"Returns a vulnerability posture report derived from stored scan, decision, VEX, exception, and workflow records.","operationId":"vulnerabilityPostureReport","parameters":[{"description":"Release id.","in":"query","name":"release_id","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/VulnerabilityPostureReportEnvelope"}}},"description":"Vulnerability posture report envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Vulnerability posture report","tags":["evydence"],"x-scopes":["security:read"]}},"/v1/retention-overrides":{"post":{"description":"Creates an append-only retention override for a tenant-scoped retention subject.","operationId":"createRetentionOverride","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateRetentionOverrideRequest"}}},"description":"Retention override creation request.","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RetentionOverrideEnvelope"}}},"description":"Created retention override envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Create retention override","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["admin"]}},"/v1/role-bindings":{"get":{"description":"Lists tenant-scoped role bindings visible to the identity administrator.","operationId":"listRoleBindings","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RoleBindingListEnvelope"}}},"description":"Role binding list envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"List role bindings","tags":["evydence"],"x-scopes":["identity:admin"]},"post":{"description":"Creates a tenant-scoped role binding for a user or collector subject.","operationId":"createRoleBinding","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateRoleBindingRequest"}}},"description":"Role binding creation request.","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RoleBindingEnvelope"}}},"description":"Created role binding envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Create role binding","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["identity:admin"]}},"/v1/saas/profiles":{"post":{"description":"Creates a SaaS edition profile record for future hosted deployment planning; it is not a production readiness claim.","operationId":"createSaaSEditionProfile","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateSaaSEditionProfileRequest"}}},"description":"SaaS edition profile creation request.","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SaaSEditionProfileEnvelope"}}},"description":"Created SaaS edition profile envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Create SaaS edition profile","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["instance:admin"]}},"/v1/sbom-components":{"get":{"description":"Lists tenant-scoped SBOM components by SBOM, release, artifact, name/version/PURL query, or exact PURL.","operationId":"listSBOMComponents","parameters":[{"description":"Filter by artifact id.","in":"query","name":"artifact_id","required":false,"schema":{"type":"string"}},{"description":"Maximum returned component records.","in":"query","name":"limit","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Exact package URL filter.","in":"query","name":"purl","required":false,"schema":{"type":"string"}},{"description":"Case-insensitive component name, version, or PURL search.","in":"query","name":"query","required":false,"schema":{"type":"string"}},{"description":"Filter by release id.","in":"query","name":"release_id","required":false,"schema":{"type":"string"}},{"description":"Filter by SBOM id.","in":"query","name":"sbom_id","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SBOMComponentRecordListEnvelope"}}},"description":"SBOM component result envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"List SBOM components","tags":["evydence"],"x-scopes":["evidence:read"]}},"/v1/sbom-diffs":{"post":{"description":"Creates a deterministic SBOM diff between two tenant-scoped SBOM records.","operationId":"createSBOMDiff","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateSBOMDiffRequest"}}},"description":"SBOM diff creation request.","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SBOMDiffEnvelope"}}},"description":"Created SBOM diff envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Create SBOM diff","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["evidence:read"]}},"/v1/sboms":{"post":{"description":"Uploads a CycloneDX SBOM payload, stores raw bytes in object storage, and records normalized SBOM metadata.","operationId":"uploadSBOM","requestBody":{"content":{"application/json":{"examples":{"cyclonedx-release-sbom":{"summary":"Upload a CycloneDX SBOM linked to the release artifact","value":{"artifact_id":"art_20260527120000","payload":{"bomFormat":"CycloneDX","components":[{"name":"payments-api","purl":"pkg:oci/payments-api@sha256-ca978112","version":"1.0.0-rc.1"},{"name":"openssl","purl":"pkg:apk/openssl@3.1.0-r0","version":"3.1.0-r0"}],"specVersion":"1.6"},"release_id":"rel_20260527120000"}}},"schema":{"$ref":"#/components/schemas/EvidenceUploadRequest"}}},"description":"CycloneDX SBOM upload request.","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SBOMEnvelope"}}},"description":"Created SBOM envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Upload CycloneDX SBOM","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["evidence:write"]}},"/v1/sboms/spdx":{"post":{"description":"Uploads an SPDX JSON SBOM payload, stores raw bytes as evidence, and records normalized SBOM metadata.","operationId":"uploadSPDXSBOM","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UploadSPDXSBOMRequest"}}},"description":"SPDX SBOM upload request.","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SBOMEnvelope"}}},"description":"Created SBOM envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Upload SPDX SBOM","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["evidence:write"]}},"/v1/sboms/{id}":{"get":{"description":"Returns a tenant-scoped SBOM metadata record by id.","operationId":"getSBOM","parameters":[{"description":"SBOM id.","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SBOMEnvelope"}}},"description":"SBOM envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Get SBOM","tags":["evydence"],"x-scopes":["evidence:read"]}},"/v1/security-documents":{"post":{"description":"Uploads sensitive manual security evidence such as threat model, security review, or penetration-test report metadata and raw payload reference.","operationId":"uploadManualSecurityDocument","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UploadManualSecurityDocumentRequest"}}},"description":"Manual security document upload request.","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ManualSecurityDocumentEnvelope"}}},"description":"Created manual security document envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Upload manual security document","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["security:write"]}},"/v1/security-scans":{"post":{"description":"Uploads SAST, DAST, secret, license, or API security scan metadata and raw JSON payload evidence without exposing raw payload bytes in responses.","operationId":"uploadSecurityScan","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UploadSecurityScanRequest"}}},"description":"Security scan upload request.","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SecurityScanEnvelope"}}},"description":"Created security scan envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Upload security scan","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["security:write"]}},"/v1/signing-keys":{"get":{"description":"Lists tenant signing public-key metadata without private key material.","operationId":"listSigningKeys","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SigningKeyListEnvelope"}}},"description":"Signing key list envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"List signing keys","tags":["evydence"],"x-scopes":["verify:read"]}},"/v1/signing-keys/rotate":{"post":{"description":"Rotates the active tenant signing key and returns public-key metadata only.","operationId":"rotateSigningKey","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SigningKeyTransitionRequest"}}},"description":"Signing key rotation request.","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SigningKeyEnvelope"}}},"description":"Rotated signing key envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Rotate signing key","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["keys:admin"]}},"/v1/signing-keys/{id}/revoke":{"post":{"description":"Revokes a tenant signing key as an audited lifecycle transition.","operationId":"revokeSigningKey","parameters":[{"description":"Signing key id.","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SigningKeyTransitionRequest"}}},"description":"Signing key revocation request.","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SigningKeyEnvelope"}}},"description":"Revoked signing key envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Revoke signing key","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["keys:admin"]}},"/v1/signing-operations":{"post":{"description":"Records an external signing operation receipt and checks payload/signature metadata without logging secrets. When the API is configured with a signing executor, external_signature may be omitted and the executor signs the payload hash.","operationId":"createSigningOperation","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateSigningOperationRequest"}}},"description":"Signing operation creation request.","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SigningOperationEnvelope"}}},"description":"Created signing operation envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Create signing provider operation receipt","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["keys:admin"]}},"/v1/signing-providers":{"post":{"description":"Creates signing provider metadata for external signing operations. Production private key material must not be supplied.","operationId":"createSigningProvider","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateSigningProviderRequest"}}},"description":"Signing provider creation request.","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SigningProviderEnvelope"}}},"description":"Created signing provider envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Create signing provider record","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["keys:admin"]}},"/v1/source/branches":{"post":{"description":"Records or updates source branch metadata and protected-branch snapshot hash.","operationId":"upsertSourceBranch","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpsertSourceBranchRequest"}}},"description":"Source branch upsert request.","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SourceBranchEnvelope"}}},"description":"Source branch envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Record source branch","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["source:write"]}},"/v1/source/commits":{"post":{"description":"Records immutable source commit metadata and stores only a hash of the commit message.","operationId":"recordSourceCommit","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RecordSourceCommitRequest"}}},"description":"Source commit creation request.","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SourceCommitEnvelope"}}},"description":"Created source commit envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Record source commit","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["source:write"]}},"/v1/source/pull-requests":{"post":{"description":"Records pull-request review metadata linked to source repository evidence.","operationId":"recordPullRequest","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RecordPullRequestRequest"}}},"description":"Pull request record request.","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PullRequestEnvelope"}}},"description":"Created pull request envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Record pull request","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["source:write"]}},"/v1/source/repositories":{"get":{"description":"Lists tenant-scoped source repositories, optionally filtered by project.","operationId":"listSourceRepositories","parameters":[{"description":"Project id.","in":"query","name":"project_id","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SourceRepositoryListEnvelope"}}},"description":"Source repository list envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"List source repositories","tags":["evydence"],"x-scopes":["source:read"]},"post":{"description":"Creates a tenant-scoped source repository record.","operationId":"createSourceRepository","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateSourceRepositoryRequest"}}},"description":"Source repository creation request.","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SourceRepositoryEnvelope"}}},"description":"Created source repository envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Create source repository","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["source:write"]}},"/v1/sso/identity-links":{"post":{"description":"Links a verified provider subject to a tenant-scoped human user.","operationId":"linkSSOIdentity","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LinkSSOIdentityRequest"}}},"description":"SSO identity link request.","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserIdentityLinkEnvelope"}}},"description":"Created SSO identity link envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Link SSO identity","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["identity:admin"]}},"/v1/sso/logout":{"post":{"description":"Revokes the currently authenticated SSO session without requiring identity administrator privileges. API keys and collector keys cannot use this route.","operationId":"logoutSSOSession","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EmptyObject"}}},"description":"Empty JSON object.","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SSOSessionEnvelope"}}},"description":"Revoked current SSO session envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Logout SSO session","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true}}},"/v1/sso/providers":{"post":{"description":"Records tenant SSO provider metadata. Optional static JWKS public keys and SAML signing certificates can be supplied for local token/assertion verification without live provider calls.","operationId":"createSSOProvider","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateSSOProviderRequest"}}},"description":"SSO provider creation request.","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SSOProviderEnvelope"}}},"description":"Created SSO provider envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Create SSO provider","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["identity:admin"]}},"/v1/sso/providers/{id}/discover-oidc":{"post":{"description":"Fetches the OIDC discovery document and public JWKS for the tenant provider issuer, then stores refreshed public trust material. This does not authenticate users, store provider secrets, or synchronize groups.","operationId":"refreshSSOProviderOIDCTrustMaterial","parameters":[{"description":"SSO provider id.","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EmptyObject"}}},"description":"Empty JSON object.","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SSOProviderEnvelope"}}},"description":"Refreshed SSO provider envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Refresh SSO provider OIDC trust material","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["identity:admin"]}},"/v1/sso/providers/{id}/trust-material":{"post":{"description":"Rotates tenant SSO provider public trust material for local OIDC ID-token or SAML assertion verification without storing provider secrets.","operationId":"updateSSOProviderTrustMaterial","parameters":[{"description":"SSO provider id.","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateSSOProviderTrustMaterialRequest"}}},"description":"SSO provider trust material update request.","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SSOProviderEnvelope"}}},"description":"Updated SSO provider envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Update SSO provider trust material","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["identity:admin"]}},"/v1/sso/session-exchanges":{"post":{"description":"Exchanges a locally verified OIDC ID token or SAML assertion for an SSO session and HttpOnly browser cookie using configured tenant trust material and verified identity links. No live provider API or group synchronization call is made.","operationId":"exchangeSSOCredential","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ExchangeSSOCredentialRequest"}}},"description":"SSO credential exchange request.","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SSOCredentialExchangeEnvelope"}}},"description":"Created SSO session, verification receipt, and one-time secret envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"summary":"Exchange SSO credential","tags":["evydence"]}},"/v1/sso/sessions":{"post":{"description":"Creates an admin-managed human SSO session record and returns a one-time bearer secret.","operationId":"createSSOSession","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateSSOSessionRequest"}}},"description":"SSO session creation request.","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SSOSessionCreateEnvelope"}}},"description":"Created SSO session and one-time secret envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Create SSO session","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["identity:admin"]}},"/v1/sso/sessions/{id}/revoke":{"post":{"description":"Revokes a tenant-scoped SSO session as an audited lifecycle transition.","operationId":"revokeSSOSession","parameters":[{"description":"SSO session id.","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EmptyObject"}}},"description":"Empty JSON object.","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SSOSessionEnvelope"}}},"description":"Revoked SSO session envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Revoke SSO session","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["identity:admin"]}},"/v1/transparency-checkpoints":{"post":{"description":"Records an external transparency or timestamp checkpoint reference for a Merkle batch.","operationId":"createTransparencyCheckpoint","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateTransparencyCheckpointRequest"}}},"description":"Transparency checkpoint creation request.","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TransparencyCheckpointEnvelope"}}},"description":"Created transparency checkpoint envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Record external transparency checkpoint","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["keys:admin"]}},"/v1/users":{"post":{"description":"Creates a tenant-scoped human user metadata record. Authentication is still controlled by API keys or configured SSO/session flows.","operationId":"createUser","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateUserRequest"}}},"description":"Human user creation request.","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HumanUserEnvelope"}}},"description":"Created human user envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Create user","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["identity:admin"]}},"/v1/users/{id}/deactivate":{"post":{"description":"Deactivates a tenant-scoped human user as an audited lifecycle transition.","operationId":"deactivateUser","parameters":[{"description":"Human user id.","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EmptyObject"}}},"description":"Empty JSON object.","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HumanUserEnvelope"}}},"description":"Deactivated human user envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Deactivate user","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["identity:admin"]}},"/v1/verify":{"post":{"description":"Verifies a supported tenant-scoped subject such as evidence, audit chain, release bundle, artifact signature, or related verification target.","operationId":"verify","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/VerifySubjectRequest"}}},"description":"Subject verification request.","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/VerificationResultEnvelope"}}},"description":"Subject verification envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Verify subject","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["verify:read"]}},"/v1/version":{"get":{"description":"Returns the API process version string.","operationId":"version","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/VersionInfoEnvelope"}}},"description":"Version information envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"summary":"Version","tags":["evydence"]}},"/v1/vex":{"post":{"description":"Uploads VEX payload bytes, stores raw evidence in object storage, and records normalized VEX metadata and decisions where applicable.","operationId":"uploadVEX","requestBody":{"content":{"application/json":{"examples":{"openvex-fixed-decision":{"summary":"Imported OpenVEX fixed decision","value":{"artifact_id":"art_20260527120000","payload":{"@context":"https://openvex.dev/ns/v0.2.0","@id":"https://example.test/vex/payments-api/1.0.0","author":"security@example.test","statements":[{"action_statement":"Ship the fixed artifact after operator review.","impact_statement":"Patched before this release candidate.","justification":"fixed_in_release_candidate","products":[{"@id":"pkg:apk/openssl@3.1.0"}],"status":"fixed","vulnerability":{"name":"CVE-2026-0002"}}],"timestamp":"2026-05-27T12:00:00Z","version":1},"release_id":"rel_20260527120000"}}},"schema":{"$ref":"#/components/schemas/EvidenceUploadRequest"}}},"description":"OpenVEX upload request.","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/VEXDocumentEnvelope"}}},"description":"Created VEX document envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Upload OpenVEX document","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["evidence:write"]}},"/v1/vex/cyclonedx":{"post":{"description":"Uploads VEX payload bytes, stores raw evidence in object storage, and records normalized VEX metadata and decisions where applicable.","operationId":"uploadCycloneDXVEX","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvidenceUploadRequest"}}},"description":"CycloneDX VEX upload request.","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/VEXDocumentEnvelope"}}},"description":"Created VEX document envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Upload CycloneDX VEX document","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["evidence:write"]}},"/v1/vex/cyclonedx/preview":{"post":{"description":"Validates a CycloneDX VEX payload and returns advisory mapping counts without storing raw payloads, creating evidence, creating decisions, or enqueueing parser jobs.","operationId":"previewCycloneDXVEXImport","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvidenceUploadRequest"}}},"description":"CycloneDX VEX import preview request.","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/VEXImportPreviewEnvelope"}}},"description":"Advisory VEX import preview envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Preview CycloneDX VEX import","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":false},"x-scopes":["evidence:read"]}},"/v1/vex/preview":{"post":{"description":"Validates an OpenVEX payload and returns advisory mapping counts without storing raw payloads, creating evidence, creating decisions, or enqueueing parser jobs.","operationId":"previewVEXImport","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvidenceUploadRequest"}}},"description":"OpenVEX import preview request.","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/VEXImportPreviewEnvelope"}}},"description":"Advisory VEX import preview envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Preview OpenVEX import","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":false},"x-scopes":["evidence:read"]}},"/v1/vex/{id}":{"get":{"description":"Returns a tenant-scoped VEX document metadata record by id.","operationId":"getVEX","parameters":[{"description":"VEX document id.","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/VEXDocumentEnvelope"}}},"description":"VEX document envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Get VEX document","tags":["evydence"],"x-scopes":["evidence:read"]}},"/v1/vex/{id}/import-report":{"get":{"description":"Returns the persisted parser report for a tenant-scoped VEX import, including counts, warnings, and mapping failures without raw payload bytes.","operationId":"getVEXImportReport","parameters":[{"description":"VEX document id.","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/VEXImportReportEnvelope"}}},"description":"VEX import report envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Get VEX import report","tags":["evydence"],"x-scopes":["evidence:read"]}},"/v1/vulnerability-decisions":{"get":{"description":"Lists append-only vulnerability decisions over time with tenant-scoped product, release, vulnerability, component, status, and active filters.","operationId":"listVulnerabilityDecisions","parameters":[{"description":"When true returns active decisions; when false returns superseded decisions.","in":"query","name":"active","required":false,"schema":{"type":"boolean"}},{"description":"Filter by affected component.","in":"query","name":"component","required":false,"schema":{"type":"string"}},{"description":"Filter by product id.","in":"query","name":"product_id","required":false,"schema":{"type":"string"}},{"description":"Filter by release id.","in":"query","name":"release_id","required":false,"schema":{"type":"string"}},{"description":"Filter by decision status.","in":"query","name":"status","required":false,"schema":{"type":"string"}},{"description":"Filter by vulnerability identifier.","in":"query","name":"vulnerability","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/VulnerabilityDecisionListEnvelope"}}},"description":"Vulnerability decision list envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"List vulnerability decisions","tags":["evydence"],"x-scopes":["evidence:read"]}},"/v1/vulnerability-findings/{id}/decisions":{"post":{"description":"Creates an append-only vulnerability decision for a tenant-scoped scan finding.","operationId":"createVulnerabilityDecision","parameters":[{"description":"Vulnerability finding id.","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"examples":{"not-affected-decision":{"summary":"Record a customer-visible not-affected decision","value":{"customer_visible":true,"evidence_ids":["ev_20260527120000"],"impact_statement":"The shipped artifact does not include the affected runtime path.","justification":"The vulnerable code path is not reachable in this release.","status":"not_affected","supporting_refs":[{"id":"exc_20260527120000","type":"exception"}]}}},"schema":{"$ref":"#/components/schemas/CreateVulnerabilityDecisionRequest"}}},"description":"Vulnerability decision creation request.","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/VulnerabilityDecisionEnvelope"}}},"description":"Created vulnerability decision envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Create vulnerability decision","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["evidence:write"]}},"/v1/vulnerability-findings/{id}/workflow":{"post":{"description":"Records an append-only vulnerability workflow event for a tenant-scoped finding.","operationId":"recordVulnerabilityWorkflow","parameters":[{"description":"Vulnerability finding id.","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RecordVulnerabilityWorkflowRequest"}}},"description":"Vulnerability workflow event request.","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/VulnerabilityWorkflowRecordEnvelope"}}},"description":"Created vulnerability workflow record envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Record vulnerability workflow event","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["security:write"]}},"/v1/vulnerability-scans":{"post":{"description":"Uploads a generic vulnerability scan JSON payload and records normalized findings.","operationId":"uploadVulnerabilityScan","requestBody":{"content":{"application/json":{"examples":{"generic-critical-finding":{"summary":"Upload a generic scanner finding for release triage","value":{"findings":[{"component":"pkg:apk/openssl@3.1.0-r0","severity":"critical","state":"open","vulnerability":"CVE-2026-0099"}],"release_id":"rel_20260527120000","scanner":"generic-json","target_ref":"pkg:oci/payments-api@sha256-ca978112"}}},"schema":{"$ref":"#/components/schemas/UploadVulnerabilityScanRequest"}}},"description":"Vulnerability scan upload payload.","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/VulnerabilityScanEnvelope"}}},"description":"Created vulnerability scan envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Upload vulnerability scan","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["evidence:write"]}},"/v1/vulnerability-scans/{id}":{"get":{"description":"Returns a tenant-scoped vulnerability scan by id.","operationId":"getVulnerabilityScan","parameters":[{"description":"Vulnerability scan id.","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/VulnerabilityScanEnvelope"}}},"description":"Vulnerability scan envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Get vulnerability scan","tags":["evydence"],"x-scopes":["evidence:read"]}},"/v1/waivers":{"post":{"description":"Creates a first-class scoped waiver for controls or policies. Approval is a separate audited transition.","operationId":"createWaiver","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateWaiverRequest"}}},"description":"Waiver creation request.","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WaiverEnvelope"}}},"description":"Created waiver envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Create waiver","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["policy:write"]}},"/v1/waivers/{id}/approve":{"post":{"description":"Approves an unexpired waiver as an audited transition.","operationId":"approveWaiver","parameters":[{"description":"Waiver id.","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EmptyObject"}}},"description":"Empty JSON object.","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WaiverEnvelope"}}},"description":"Approved waiver envelope."},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Bad Request"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Unprocessable Entity"}},"security":[{"BearerAuth":[]}],"summary":"Approve waiver","tags":["evydence"],"x-idempotency-key":{"header":"Idempotency-Key","required":true},"x-scopes":["policy:write"]}}}} diff --git a/release/current.json b/release/current.json new file mode 100644 index 0000000..3385d51 --- /dev/null +++ b/release/current.json @@ -0,0 +1,34 @@ +{ + "schema": "evydence-current-release.v1", + "repository": "aatuh/evydence", + "current_public_release": { + "tag": "v0.1.0-rc.7", + "name": "Evydence v0.1.0-rc.7", + "status": "controlled self-hosted production candidate", + "published_at": "2026-06-01T17:00:46Z", + "release_date": "2026-06-01", + "release_url": "https://github.com/aatuh/evydence/releases/tag/v0.1.0-rc.7", + "source_commit": "2f759100e723554e4e68e3ada712c923e391a17a", + "release_artifacts_workflow_run": "26769039974", + "production_check_workflow_run": "26767778491", + "codeql_workflow_run": "26767778487", + "evidence_dir": "dist/v0.1.0-rc.7", + "release_notes_path": "dist/v0.1.0-rc.7/release-notes.md", + "openapi_checksum_path": "dist/v0.1.0-rc.7/openapi.sha256", + "migrations_checksum_path": "dist/v0.1.0-rc.7/migrations.sha256", + "release_manifest_path": "dist/v0.1.0-rc.7/evydence-release-manifest.json", + "release_manifest_signature_path": "dist/v0.1.0-rc.7/evydence-release-manifest.sig.json" + }, + "last_verified_project_image": { + "tag": "v0.1.0-rc.5", + "digest": "sha256:38188044a3e5ded3c6094564ab39ce989185f65e22cf4296985ec19ba0eb1888", + "workflow_run": "26752805538", + "source_commit": "d5098c635ee7b171dac3a449ba7ca2e30d8b0c16", + "status": "last verified project-owned image evidence for the release-candidate line" + }, + "notes": [ + "GitHub Releases remains the external source of truth for published tags and assets.", + "The current public release candidate may differ from the last verified project-owned container image because image publication is a separate workflow.", + "Release evidence is not legal compliance proof, certification, complete SBOM proof, authoritative vulnerability coverage, or a secure-release guarantee." + ] +} diff --git a/schemas/upload-manifest.v1.schema.json b/schemas/upload-manifest.v1.schema.json new file mode 100644 index 0000000..b0c2712 --- /dev/null +++ b/schemas/upload-manifest.v1.schema.json @@ -0,0 +1,78 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://evydence.local/schemas/upload-manifest.v1.schema.json", + "title": "Evydence Upload Manifest v1", + "type": "object", + "additionalProperties": false, + "properties": { + "schema_version": { + "type": "string", + "const": "evydence-upload-manifest.v1.0.0" + }, + "requests": { + "type": "array", + "minItems": 1, + "maxItems": 100, + "items": { + "$ref": "#/$defs/request" + } + } + }, + "required": ["requests"], + "$defs": { + "request": { + "type": "object", + "additionalProperties": false, + "properties": { + "kind": { + "type": "string", + "enum": [ + "artifact", + "sbom", + "scan", + "vulnerability_scan", + "vex", + "provenance", + "build", + "build_attestation", + "approval", + "package_export", + "customer_package", + "release_bundle", + "evidence" + ] + }, + "path": { + "type": "string", + "pattern": "^/v1/" + }, + "idempotency_key": { + "type": "string", + "minLength": 1 + }, + "payload": { + "type": "object" + }, + "payload_file": { + "type": "string", + "minLength": 1 + } + }, + "required": ["path", "idempotency_key"], + "oneOf": [ + { + "required": ["payload"], + "not": { + "required": ["payload_file"] + } + }, + { + "required": ["payload_file"], + "not": { + "required": ["payload"] + } + } + ] + } + } +} diff --git a/scripts/black_box_demo_check.sh b/scripts/black_box_demo_check.sh new file mode 100755 index 0000000..ebc2098 --- /dev/null +++ b/scripts/black_box_demo_check.sh @@ -0,0 +1,178 @@ +#!/usr/bin/env sh +set -eu + +repo_root="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" +cd "$repo_root" + +if [ -z "${EVYDENCE_TEST_DATABASE_URL:-}" ]; then + printf '%s\n' "black-box-demo-check: EVYDENCE_TEST_DATABASE_URL is required" >&2 + exit 2 +fi + +for tool in curl go jq psql python3; do + if ! command -v "$tool" >/dev/null 2>&1; then + printf '%s\n' "black-box-demo-check: missing required tool: $tool" >&2 + exit 2 + fi +done + +workdir="${EVYDENCE_BLACK_BOX_WORKDIR:-tmp/black-box-demo-check}" +schema="demo_schema_$(date +%s)_$$" +api_pid="" +worker_pid="" + +cleanup() { + if [ -n "$api_pid" ] && kill -0 "$api_pid" >/dev/null 2>&1; then + kill "$api_pid" >/dev/null 2>&1 || true + wait "$api_pid" >/dev/null 2>&1 || true + fi + if [ -n "$worker_pid" ] && kill -0 "$worker_pid" >/dev/null 2>&1; then + kill "$worker_pid" >/dev/null 2>&1 || true + wait "$worker_pid" >/dev/null 2>&1 || true + fi + if [ -n "$schema" ]; then + psql "$EVYDENCE_TEST_DATABASE_URL" -v ON_ERROR_STOP=1 -q -c "DROP SCHEMA IF EXISTS $schema CASCADE" >/dev/null 2>&1 || true + fi + if [ "${EVYDENCE_BLACK_BOX_KEEP_ARTIFACTS:-}" != "1" ]; then + rm -rf "$workdir" + fi +} +trap cleanup EXIT INT TERM + +rm -rf "$workdir" +mkdir -p "$workdir"/objects "$workdir"/demo + +printf '%s\n' "black-box-demo-check: preparing disposable schema $schema" +psql "$EVYDENCE_TEST_DATABASE_URL" -v ON_ERROR_STOP=1 -q -c "CREATE SCHEMA $schema" >/dev/null + +database_url="$(python3 - "$EVYDENCE_TEST_DATABASE_URL" "$schema" <<'PY' +import sys +from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit + +base_url, schema = sys.argv[1], sys.argv[2] +parts = urlsplit(base_url) +query = dict(parse_qsl(parts.query, keep_blank_values=True)) +query["search_path"] = schema +print(urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(query), parts.fragment))) +PY +)" + +port="$(python3 - <<'PY' +import socket + +with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(("127.0.0.1", 0)) + print(sock.getsockname()[1]) +PY +)" +api_url="http://127.0.0.1:$port" + +prepare_binary() { + env_name="$1" + fallback_pkg="$2" + output="$3" + case "$env_name" in + EVYDENCE_BLACK_BOX_API_BIN) configured="${EVYDENCE_BLACK_BOX_API_BIN:-}" ;; + EVYDENCE_BLACK_BOX_WORKER_BIN) configured="${EVYDENCE_BLACK_BOX_WORKER_BIN:-}" ;; + *) + printf '%s\n' "black-box-demo-check: unsupported binary env selector" >&2 + exit 2 + ;; + esac + if [ -n "$configured" ]; then + if [ ! -x "$configured" ]; then + printf '%s\n' "black-box-demo-check: $env_name must point to an executable file" >&2 + exit 2 + fi + cp "$configured" "$output" + else + go build -o "$output" "$fallback_pkg" + fi +} + +prepare_binary EVYDENCE_BLACK_BOX_API_BIN ./cmd/evydence-api "$workdir/evydence-api" +prepare_binary EVYDENCE_BLACK_BOX_WORKER_BIN ./cmd/evydence-worker "$workdir/evydence-worker" + +start_api() { + label="$1" + print_secret="$2" + stdout="$workdir/api-$label.stdout" + stderr="$workdir/api-$label.stderr" + EVYDENCE_ADDR="127.0.0.1:$port" \ + EVYDENCE_DATABASE_URL="$database_url" \ + EVYDENCE_POSTGRES_LOAD_MODE=relational_only \ + EVYDENCE_API_KEY_PEPPER="black-box-demo-pepper" \ + EVYDENCE_OBJECT_STORE=filesystem \ + EVYDENCE_OBJECT_DIR="$workdir/objects" \ + EVYDENCE_BOOTSTRAP_TENANT="Black Box Demo Tenant" \ + EVYDENCE_PRINT_BOOTSTRAP_SECRET="$print_secret" \ + "$workdir/evydence-api" >"$stdout" 2>"$stderr" & + api_pid="$!" + for _ in $(seq 1 80); do + if curl -fsS "$api_url/v1/ready" >/dev/null 2>&1; then + return 0 + fi + if ! kill -0 "$api_pid" >/dev/null 2>&1; then + printf '%s\n' "black-box-demo-check: API process exited during $label startup" >&2 + sed -n '1,120p' "$stderr" >&2 || true + return 1 + fi + sleep 0.25 + done + printf '%s\n' "black-box-demo-check: API did not become ready during $label startup" >&2 + sed -n '1,120p' "$stderr" >&2 || true + return 1 +} + +start_worker() { + EVYDENCE_DATABASE_URL="$database_url" \ + EVYDENCE_POSTGRES_LOAD_MODE=relational_only \ + EVYDENCE_SKIP_MIGRATIONS=true \ + EVYDENCE_OBJECT_STORE=filesystem \ + EVYDENCE_OBJECT_DIR="$workdir/objects" \ + EVYDENCE_WORKER_POLL_INTERVAL=100ms \ + "$workdir/evydence-worker" >"$workdir/worker.stdout" 2>"$workdir/worker.stderr" & + worker_pid="$!" + sleep 0.5 + if ! kill -0 "$worker_pid" >/dev/null 2>&1; then + printf '%s\n' "black-box-demo-check: worker process exited during startup" >&2 + sed -n '1,120p' "$workdir/worker.stderr" >&2 || true + return 1 + fi +} + +start_api first true +if ! api_key="$(jq -er '.secret' "$workdir/api-first.stdout")"; then + : >"$workdir/api-first.stdout" + printf '%s\n' "black-box-demo-check: bootstrap secret output was not parseable" >&2 + exit 1 +fi +: >"$workdir/api-first.stdout" +start_worker + +EVYDENCE_URL="$api_url" \ +EVYDENCE_API_KEY="$api_key" \ +EVYDENCE_DEMO_OUTDIR="$workdir/demo" \ +examples/end-to-end-release-evidence/run-local-demo.sh >"$workdir/demo.stdout" + +release_id="$(jq -er '.data.id' "$workdir/demo/release.json")" +jq -e --arg release_id "$release_id" '.data.release_id == $release_id' "$workdir/demo/release-readiness.json" >/dev/null +jq -e '.data.result == "passed"' "$workdir/demo/audit-chain-verification.json" >/dev/null + +kill "$api_pid" >/dev/null 2>&1 || true +wait "$api_pid" >/dev/null 2>&1 || true +api_pid="" + +start_api restart false +curl -fsS "$api_url/v1/reports/release-readiness?release_id=$release_id" \ + -H "Authorization: Bearer $api_key" \ + >"$workdir/restarted-readiness.json" +jq -e --arg release_id "$release_id" '.data.release_id == $release_id' "$workdir/restarted-readiness.json" >/dev/null + +pending_jobs="$(psql "$EVYDENCE_TEST_DATABASE_URL" -v ON_ERROR_STOP=1 -qAt -c "SELECT count(*) FROM $schema.outbox_jobs WHERE status IN ('queued', 'retrying', 'running')")" +if [ "$pending_jobs" != "0" ]; then + printf '%s\n' "black-box-demo-check: pending outbox jobs remain after demo: $pending_jobs" >&2 + exit 1 +fi + +printf '%s\n' "black-box-demo-check: API, worker, PostgreSQL persistence, restart, readiness, package, and audit-chain flow passed" diff --git a/scripts/black_box_release_artifact_check.sh b/scripts/black_box_release_artifact_check.sh new file mode 100755 index 0000000..949dff6 --- /dev/null +++ b/scripts/black_box_release_artifact_check.sh @@ -0,0 +1,155 @@ +#!/usr/bin/env sh +set -eu + +repo_root="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" +cd "$repo_root" + +fail() { + printf '%s\n' "black-box-release-artifact-check: $*" >&2 + exit 2 +} + +if [ -z "${EVYDENCE_TEST_DATABASE_URL:-}" ]; then + fail "EVYDENCE_TEST_DATABASE_URL is required" +fi + +for tool in curl go jq psql python3 sha256sum; do + if ! command -v "$tool" >/dev/null 2>&1; then + fail "missing required tool: $tool" + fi +done + +workdir="${EVYDENCE_BLACK_BOX_RELEASE_WORKDIR:-tmp/black-box-release-artifact}" +case "$workdir" in + tmp/*) ;; + *) fail "EVYDENCE_BLACK_BOX_RELEASE_WORKDIR must stay under tmp/" ;; +esac +case "$workdir" in + *..*|*/.*|.*) fail "EVYDENCE_BLACK_BOX_RELEASE_WORKDIR must not contain dot segments" ;; +esac + +rm -rf "$workdir" +release_dir="$workdir/release/evydence_linux_amd64" +run_dir="$workdir/run" +mkdir -p "$release_dir" "$run_dir" + +go build -trimpath -o "$release_dir/evydence" ./cmd/evydence +go build -trimpath -o "$release_dir/evydence-api" ./cmd/evydence-api +go build -trimpath -o "$release_dir/evydence-worker" ./cmd/evydence-worker +go build -trimpath -o "$release_dir/evydence-migrate" ./cmd/evydence-migrate +(cd "$release_dir" && sha256sum evydence evydence-api evydence-worker evydence-migrate > SHA256SUMS) + +EVYDENCE_BLACK_BOX_WORKDIR="$run_dir" \ +EVYDENCE_BLACK_BOX_KEEP_ARTIFACTS=1 \ +EVYDENCE_BLACK_BOX_API_BIN="$release_dir/evydence-api" \ +EVYDENCE_BLACK_BOX_WORKER_BIN="$release_dir/evydence-worker" \ +scripts/black_box_demo_check.sh >"$workdir/black-box.stdout" 2>"$workdir/black-box.stderr" + +python3 - "$workdir" "$release_dir" "$run_dir" <<'PY' +import hashlib +import json +import subprocess +import sys +from datetime import datetime, timezone +from pathlib import Path + +workdir = Path(sys.argv[1]) +release_dir = Path(sys.argv[2]) +run_dir = Path(sys.argv[3]) +demo_dir = run_dir / "demo" +object_root = run_dir / "objects" + +def sha256(path: Path) -> str: + h = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + h.update(chunk) + return "sha256:" + h.hexdigest() + +def count_files(root: Path): + if not root.exists(): + return 0, 0 + files = [path for path in root.rglob("*") if path.is_file()] + return len(files), sum(path.stat().st_size for path in files) + +try: + commit = subprocess.check_output(["git", "rev-parse", "--short=12", "HEAD"], text=True).strip() +except Exception: + commit = "unknown" + +binaries = [] +for name in ["evydence", "evydence-api", "evydence-worker", "evydence-migrate"]: + path = release_dir / name + binaries.append( + { + "name": name, + "path": str(path), + "size": path.stat().st_size, + "sha256": sha256(path), + } + ) + +object_files, object_bytes = count_files(object_root) +demo_json_files = len(list(demo_dir.glob("*.json"))) if demo_dir.exists() else 0 + +summary = { + "schema_version": "evydence-black-box-release-artifact.v1.0.0", + "generated_at": datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z"), + "commit": commit, + "artifact_shape": "release-style linux_amd64 binary directory", + "binaries": binaries, + "scenario": { + "source": "scripts/black_box_demo_check.sh", + "database": "disposable PostgreSQL schema from EVYDENCE_TEST_DATABASE_URL", + "object_store": "filesystem object store under controlled tmp workspace", + "api_restart": True, + "worker_started": True, + "release_readiness_verified": True, + "customer_package_created": True, + "audit_chain_verified": True, + }, + "outputs": { + "summary_path": str(workdir / "black-box-release-artifact-summary.json"), + "checksums_path": str(release_dir / "SHA256SUMS"), + "black_box_stdout": str(workdir / "black-box.stdout"), + "black_box_stderr": str(workdir / "black-box.stderr"), + "demo_json_files": demo_json_files, + "object_files": object_files, + "object_bytes": object_bytes, + }, + "limitations": [ + "This check runs release-style local binaries, not a published GitHub Release asset.", + "The object store is local filesystem storage, not live S3 or MinIO network storage.", + "The check avoids live external providers and does not prove KMS, HSM, SSO, registry, or transparency-provider behavior.", + "The summary intentionally excludes API keys, database URLs, bootstrap secrets, bearer tokens, and raw evidence payloads.", + ], +} + +summary_path = workdir / "black-box-release-artifact-summary.json" +summary_path.write_text(json.dumps(summary, indent=2, sort_keys=True) + "\n") + +text_path = workdir / "black-box-release-artifact-summary.txt" +text_path.write_text( + "\n".join( + [ + "black-box-release-artifact-check: passed", + f"commit={commit}", + f"binaries={len(binaries)}", + f"demo_json_files={demo_json_files}", + f"object_files={object_files}", + f"object_bytes={object_bytes}", + "claim=release-style binary smoke test only", + "", + ] + ) +) +print(text_path.read_text(), end="") +PY + +jq -e '.schema_version == "evydence-black-box-release-artifact.v1.0.0" and (.binaries | length == 4) and .outputs.object_files > 0 and (.limitations | length >= 3)' \ + "$workdir/black-box-release-artifact-summary.json" >/dev/null + +if find "$workdir" -type f \( -name '*.json' -o -name '*.txt' -o -name '*.stdout' -o -name '*.stderr' \) -exec grep -I -l 'evy_' {} + | grep -q .; then + printf '%s\n' "black-box-release-artifact-check: generated artifacts leaked an API key-like secret" >&2 + exit 1 +fi diff --git a/scripts/capture_package_viewer_screenshots.sh b/scripts/capture_package_viewer_screenshots.sh new file mode 100755 index 0000000..1ae9d77 --- /dev/null +++ b/scripts/capture_package_viewer_screenshots.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +browser="${CHROMIUM_BIN:-}" + +if [[ -z "${browser}" ]]; then + browser="$(command -v chromium || true)" +fi + +if [[ -z "${browser}" ]]; then + echo "chromium is required to capture package viewer screenshots" >&2 + exit 1 +fi + +viewer="file://${root}/site/package-viewer/index.html" + +"${browser}" \ + --headless \ + --disable-gpu \ + --hide-scrollbars \ + --window-size=1440,1100 \ + --screenshot="${root}/docs/assets/package-viewer-desktop.png" \ + "${viewer}" + +"${browser}" \ + --headless \ + --disable-gpu \ + --hide-scrollbars \ + --window-size=390,1200 \ + --screenshot="${root}/docs/assets/package-viewer-mobile.png" \ + "${viewer}" + +echo "captured package viewer screenshots" diff --git a/scripts/check_release_truth.py b/scripts/check_release_truth.py new file mode 100755 index 0000000..44ffa5e --- /dev/null +++ b/scripts/check_release_truth.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python3 +"""Validate public docs and helper defaults against release/current.json.""" + +from __future__ import annotations + +import json +import re +import sys +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +METADATA_PATH = ROOT / "release/current.json" + + +def read(path: str) -> str: + return (ROOT / path).read_text(encoding="utf-8") + + +def require(condition: bool, message: str, failures: list[str]) -> None: + if not condition: + failures.append(message) + + +def require_text(path: str, text: str, failures: list[str]) -> None: + content = read(path) + require(text in content, f"{path} missing release-truth text: {text}", failures) + + +def reject_current_stale(path: str, stale_tags: set[str], failures: list[str]) -> None: + content = read(path) + found = sorted(tag for tag in stale_tags if tag in content) + require(not found, f"{path} contains stale current-release tag(s): {', '.join(found)}", failures) + + +def main() -> int: + metadata = json.loads(METADATA_PATH.read_text(encoding="utf-8")) + current = metadata["current_public_release"] + image = metadata["last_verified_project_image"] + tag = current["tag"] + repo = metadata["repository"] + release_date = current["release_date"] + release_url = current["release_url"] + commit = current["source_commit"] + release_run = current["release_artifacts_workflow_run"] + ci_run = current["production_check_workflow_run"] + codeql_run = current["codeql_workflow_run"] + image_tag = image["tag"] + image_digest = image["digest"] + + failures: list[str] = [] + require(metadata.get("schema") == "evydence-current-release.v1", "release/current.json has unexpected schema", failures) + require(re.fullmatch(r"v\d+\.\d+\.\d+-rc\.\d+", tag) is not None, f"invalid current release tag: {tag}", failures) + require(re.fullmatch(r"\d{4}-\d{2}-\d{2}", release_date) is not None, f"invalid release date: {release_date}", failures) + require(re.fullmatch(r"[0-9a-f]{40}", commit) is not None, f"invalid source commit: {commit}", failures) + + current_command = f"make public-release-verify TAG={tag}" + release_link = f"[`{tag}`]({release_url})" + + required_current_refs = { + "README.md": [ + "Current Status", + "Best for: evaluation, pilots, and controlled internal self-hosted use after operator review.", + release_link, + current_command, + ], + "docs/tutorials/evaluate-in-10-minutes.md": [current_command], + "docs/tutorials/getting-started.md": [release_link], + "docs/how-to/install-and-operate.md": [ + release_link, + f"mkdir -p dist/{tag}", + f"gh release download {tag} --repo {repo} --dir dist/{tag}", + current_command, + ], + "docs/reference/release-evidence-index.md": [ + release_link, + f"tag `{tag}` at commit", + commit, + release_run, + ci_run, + codeql_run, + f"gh release download {tag} --repo {repo} --dir dist/{tag}", + current_command, + ], + "docs/reference/release-candidate.md": [release_link], + "docs/reference/production-exit-review.md": [tag, current_command], + "docs/reference/production-gate-troubleshooting.md": [current_command], + "examples/end-to-end-release-evidence/README.md": [current_command], + ".github/workflows/container-image.yml": [ + f"such as {tag}", + f'default: "{tag}"', + ], + "scripts/public_release_verify.sh": [ + f'TAG:-{tag}', + f"release candidate tag must look like {tag}", + ], + "CHANGELOG.md": [f"## {tag} - {release_date}"], + } + + for path, texts in required_current_refs.items(): + for text in texts: + require_text(path, text, failures) + + stale_tags = {"v0.1.0-rc.1", "v0.1.0-rc.2", "v0.1.0-rc.3", "v0.1.0-rc.4", "v0.1.0-rc.5", "v0.1.0-rc.6"} - {tag} + for path in [ + "README.md", + "docs/tutorials/evaluate-in-10-minutes.md", + "docs/tutorials/getting-started.md", + "docs/how-to/install-and-operate.md", + "docs/reference/production-exit-review.md", + "docs/reference/production-gate-troubleshooting.md", + "examples/end-to-end-release-evidence/README.md", + ".github/workflows/container-image.yml", + "scripts/public_release_verify.sh", + ]: + reject_current_stale(path, stale_tags, failures) + + require_text("docs/reference/release-evidence-index.md", image_tag, failures) + require_text("docs/reference/release-evidence-index.md", image_digest, failures) + require_text("deploy/airgap/manifest.yaml", image_tag, failures) + require_text("deploy/airgap/manifest.yaml", image_digest, failures) + + if failures: + for failure in failures: + print(f"release-truth-check: {failure}", file=sys.stderr) + return 1 + print(f"release-truth-check: passed ({tag})") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/coverage_check.sh b/scripts/coverage_check.sh new file mode 100755 index 0000000..902ab42 --- /dev/null +++ b/scripts/coverage_check.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env sh +set -eu + +repo_root="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" +cd "$repo_root" + +threshold="${EVYDENCE_COVERAGE_THRESHOLD:-80.0}" +profile="${EVYDENCE_COVERAGE_PROFILE:-coverage.out}" + +if [ -z "${EVYDENCE_TEST_DATABASE_URL:-}" ]; then + printf '%s\n' "coverage-check: EVYDENCE_TEST_DATABASE_URL is required for the production coverage gate" >&2 + printf '%s\n' "coverage-check: use make coverage for local no-PostgreSQL coverage, or run make release-check-local-postgres" >&2 + exit 2 +fi + +go test ./... -coverprofile="$profile" + +total="$(go tool cover -func="$profile" | awk '/^total:/ { gsub("%", "", $3); print $3 }')" +if [ -z "$total" ]; then + printf '%s\n' "coverage-check: could not read total coverage" >&2 + exit 2 +fi + +awk -v got="$total" -v want="$threshold" 'BEGIN { + if (got + 0 < want + 0) { + printf "coverage-check: total coverage %.1f%% is below required %.1f%%\n", got, want > "/dev/stderr" + exit 1 + } + printf "coverage-check: total coverage %.1f%% meets required %.1f%%\n", got, want +}' diff --git a/scripts/generate_sdk_route_catalog.py b/scripts/generate_sdk_route_catalog.py new file mode 100755 index 0000000..43270e8 --- /dev/null +++ b/scripts/generate_sdk_route_catalog.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +"""Generate a deterministic SDK route catalog from committed OpenAPI.""" + +from __future__ import annotations + +import json +import pathlib + + +ROOT = pathlib.Path(__file__).resolve().parents[1] + + +def success_statuses(operation: dict) -> list[str]: + statuses: list[str] = [] + for status in sorted(operation.get("responses", {})): + if status.isdigit() and 200 <= int(status) < 300: + statuses.append(status) + return statuses + + +def main() -> None: + spec = json.loads((ROOT / "openapi.yaml").read_text(encoding="utf-8")) + routes: list[dict] = [] + for path, methods in sorted(spec.get("paths", {}).items()): + for method, operation in sorted(methods.items()): + if method.lower() not in {"get", "post", "put", "patch", "delete"}: + continue + routes.append( + { + "operation_id": operation.get("operationId", ""), + "method": method.upper(), + "path": path, + "scopes": operation.get("x-scopes", []), + "idempotency_key_required": bool(operation.get("x-idempotency-key", {}).get("required")), + "request_schema": operation.get("requestBody", {}) + .get("content", {}) + .get("application/json", {}) + .get("schema", {}) + .get("$ref", ""), + "success_statuses": success_statuses(operation), + "response_schemas": sorted( + { + media.get("schema", {}).get("$ref", "") + for response in operation.get("responses", {}).values() + for media in response.get("content", {}).values() + if media.get("schema", {}).get("$ref") + } + ), + } + ) + output = { + "source": "openapi.yaml", + "route_count": len(routes), + "routes": routes, + } + print(json.dumps(output, indent=2, sort_keys=True)) + + +if __name__ == "__main__": + main() diff --git a/scripts/github_release_evidence_manifest.py b/scripts/github_release_evidence_manifest.py new file mode 100755 index 0000000..f7ee98e --- /dev/null +++ b/scripts/github_release_evidence_manifest.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + + +def load_json(path: str) -> Any: + try: + return json.loads(Path(path).read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + raise SystemExit(f"{path}: not valid JSON: {exc}") from exc + + +def write_json(path: Path, value: Any) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + +def severity(value: Any) -> str: + text = str(value or "unknown").strip().lower() + return text if text else "unknown" + + +def grype_findings(doc: dict[str, Any]) -> list[dict[str, str]]: + findings: list[dict[str, str]] = [] + for match in doc.get("matches", []): + vulnerability = match.get("vulnerability", {}) if isinstance(match, dict) else {} + artifact = match.get("artifact", {}) if isinstance(match, dict) else {} + vuln_id = str(vulnerability.get("id", "")).strip() + if not vuln_id: + continue + component = str(artifact.get("purl") or artifact.get("name") or "").strip() + findings.append( + { + "vulnerability": vuln_id, + "component": component, + "severity": severity(vulnerability.get("severity")), + "state": "open", + } + ) + return findings + + +def trivy_findings(doc: dict[str, Any]) -> list[dict[str, str]]: + findings: list[dict[str, str]] = [] + for result in doc.get("Results", []): + if not isinstance(result, dict): + continue + for vuln in result.get("Vulnerabilities", []) or []: + vuln_id = str(vuln.get("VulnerabilityID", "")).strip() + if not vuln_id: + continue + component = str(vuln.get("PkgIdentifier", {}).get("PURL") or vuln.get("PkgName") or "").strip() + findings.append( + { + "vulnerability": vuln_id, + "component": component, + "severity": severity(vuln.get("Severity")), + "state": "open", + } + ) + return findings + + +def request(path: str, idempotency_key: str, payload_file: str, kind: str) -> dict[str, str]: + return {"kind": kind, "path": path, "idempotency_key": idempotency_key, "payload_file": payload_file} + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Build an Evydence GitHub release evidence upload manifest.") + parser.add_argument("--out", default=".evydence/upload-manifest.json") + parser.add_argument("--release-id", required=True) + parser.add_argument("--artifact-id", default="") + parser.add_argument("--target-ref", required=True) + parser.add_argument("--idempotency-prefix", required=True) + parser.add_argument("--cyclonedx-sbom") + parser.add_argument("--grype-json") + parser.add_argument("--trivy-json") + parser.add_argument("--openvex-json") + parser.add_argument("--include-release-bundle", action="store_true") + parser.add_argument("--customer-package-product-id", default="") + parser.add_argument("--customer-package-redaction-profile-id", default="") + parser.add_argument("--customer-package-title", default="") + parser.add_argument("--customer-package-expires-at", default="") + return parser.parse_args() + + +def main() -> None: + args = parse_args() + out = Path(args.out) + base = out.parent + requests: list[dict[str, str]] = [] + + if args.cyclonedx_sbom: + payload = {"release_id": args.release_id, "artifact_id": args.artifact_id, "payload": load_json(args.cyclonedx_sbom)} + payload_path = base / "sbom-cyclonedx-upload.json" + write_json(payload_path, payload) + requests.append(request("/v1/sboms", f"{args.idempotency_prefix}-sbom-cyclonedx", payload_path.name, "sbom")) + + if args.grype_json: + payload = { + "scanner": "grype", + "target_ref": args.target_ref, + "release_id": args.release_id, + "findings": grype_findings(load_json(args.grype_json)), + } + payload_path = base / "scan-grype-upload.json" + write_json(payload_path, payload) + requests.append(request("/v1/vulnerability-scans", f"{args.idempotency_prefix}-scan-grype", payload_path.name, "scan")) + + if args.trivy_json: + payload = { + "scanner": "trivy", + "target_ref": args.target_ref, + "release_id": args.release_id, + "findings": trivy_findings(load_json(args.trivy_json)), + } + payload_path = base / "scan-trivy-upload.json" + write_json(payload_path, payload) + requests.append(request("/v1/vulnerability-scans", f"{args.idempotency_prefix}-scan-trivy", payload_path.name, "scan")) + + if args.openvex_json: + payload = {"release_id": args.release_id, "artifact_id": args.artifact_id, "payload": load_json(args.openvex_json)} + payload_path = base / "vex-openvex-upload.json" + write_json(payload_path, payload) + requests.append(request("/v1/vex", f"{args.idempotency_prefix}-vex-openvex", payload_path.name, "vex")) + + if args.include_release_bundle: + payload_path = base / "release-bundle-upload.json" + write_json(payload_path, {"release_id": args.release_id}) + requests.append(request("/v1/release-bundles", f"{args.idempotency_prefix}-release-bundle", payload_path.name, "release_bundle")) + + package_fields = [ + args.customer_package_product_id, + args.customer_package_redaction_profile_id, + args.customer_package_title, + args.customer_package_expires_at, + ] + if any(package_fields): + if not all(package_fields): + raise SystemExit("customer package fields must be supplied together") + try: + datetime.fromisoformat(args.customer_package_expires_at.replace("Z", "+00:00")).astimezone(timezone.utc) + except ValueError as exc: + raise SystemExit("customer package expires_at must be an RFC3339 timestamp") from exc + payload_path = base / "customer-package-upload.json" + write_json( + payload_path, + { + "product_id": args.customer_package_product_id, + "release_id": args.release_id, + "redaction_profile_id": args.customer_package_redaction_profile_id, + "title": args.customer_package_title, + "expires_at": args.customer_package_expires_at, + }, + ) + requests.append(request("/v1/customer-packages", f"{args.idempotency_prefix}-customer-package", payload_path.name, "package_export")) + + if not requests: + raise SystemExit("no evidence inputs were supplied") + write_json(out, {"schema_version": "evydence-upload-manifest.v1.0.0", "requests": requests}) + + +if __name__ == "__main__": + main() diff --git a/scripts/local_ci_simulation_check.sh b/scripts/local_ci_simulation_check.sh new file mode 100755 index 0000000..c5f6a0f --- /dev/null +++ b/scripts/local_ci_simulation_check.sh @@ -0,0 +1,235 @@ +#!/usr/bin/env sh +set -eu + +repo_root="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" +cd "$repo_root" + +for tool in curl go jq python3; do + if ! command -v "$tool" >/dev/null 2>&1; then + printf '%s\n' "local-ci-simulation-check: missing required tool: $tool" >&2 + exit 2 + fi +done + +workdir="${EVYDENCE_LOCAL_CI_SIMULATION_DIR:-tmp/local-ci-simulation}" +api_pid="" + +cleanup() { + if [ -n "$api_pid" ] && kill -0 "$api_pid" >/dev/null 2>&1; then + kill "$api_pid" >/dev/null 2>&1 || true + wait "$api_pid" >/dev/null 2>&1 || true + fi + if [ "${EVYDENCE_LOCAL_CI_KEEP_ARTIFACTS:-}" != "1" ]; then + rm -rf "$workdir" + fi +} +trap cleanup EXIT INT TERM + +rm -rf "$workdir" +mkdir -p "$workdir/.evydence" + +port="$(python3 - <<'PY' +import socket + +with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(("127.0.0.1", 0)) + print(sock.getsockname()[1]) +PY +)" +api_url="http://127.0.0.1:$port" + +go build -o "$workdir/evydence-api" ./cmd/evydence-api +go build -o "$workdir/evydence" ./cmd/evydence + +EVYDENCE_ADDR="127.0.0.1:$port" \ +EVYDENCE_API_KEY_PEPPER="local-ci-simulation-pepper" \ +EVYDENCE_BOOTSTRAP_TENANT="Local CI Simulation Tenant" \ +EVYDENCE_PRINT_BOOTSTRAP_SECRET=true \ +"$workdir/evydence-api" >"$workdir/api.stdout" 2>"$workdir/api.stderr" & +api_pid="$!" + +for _ in $(seq 1 80); do + if curl -fsS "$api_url/v1/ready" >/dev/null 2>&1; then + break + fi + if ! kill -0 "$api_pid" >/dev/null 2>&1; then + printf '%s\n' "local-ci-simulation-check: API process exited during startup" >&2 + sed -n '1,120p' "$workdir/api.stderr" >&2 || true + exit 1 + fi + sleep 0.25 +done + +if ! curl -fsS "$api_url/v1/ready" >/dev/null 2>&1; then + printf '%s\n' "local-ci-simulation-check: API did not become ready" >&2 + sed -n '1,120p' "$workdir/api.stderr" >&2 || true + exit 1 +fi + +if ! api_key="$(jq -er '.secret' "$workdir/api.stdout")"; then + : >"$workdir/api.stdout" + printf '%s\n' "local-ci-simulation-check: bootstrap secret output was not parseable" >&2 + exit 1 +fi +: >"$workdir/api.stdout" + +api() { + method="$1" + path="$2" + idem="$3" + body="$4" + if [ "$method" = "GET" ]; then + curl -fsS "${api_url}${path}" \ + -H "Authorization: Bearer ${api_key}" + else + curl -fsS -X "$method" "${api_url}${path}" \ + -H "Authorization: Bearer ${api_key}" \ + -H "Idempotency-Key: ${idem}" \ + -H "Content-Type: application/json" \ + --data "$body" + fi +} + +product="$(api POST /v1/products local-ci-product '{"name":"Local CI Product","slug":"local-ci-product"}')" +printf '%s\n' "$product" >"$workdir/product.json" +product_id="$(printf '%s' "$product" | jq -er '.data.id')" + +project_payload="$(jq -cn --arg product_id "$product_id" '{product_id:$product_id,name:"api"}')" +project="$(api POST /v1/projects local-ci-project "$project_payload")" +printf '%s\n' "$project" >"$workdir/project.json" +project_id="$(printf '%s' "$project" | jq -er '.data.id')" + +release_payload="$(jq -cn --arg product_id "$product_id" '{product_id:$product_id,version:"1.0.0-local-ci"}')" +release="$(api POST /v1/releases local-ci-release "$release_payload")" +printf '%s\n' "$release" >"$workdir/release.json" +release_id="$(printf '%s' "$release" | jq -er '.data.id')" + +artifact_digest="$("$workdir/evydence" hash "$workdir/evydence")" +artifact_size="$(wc -c <"$workdir/evydence" | tr -d ' ')" +artifact_payload="$(jq -cn --arg digest "$artifact_digest" --argjson size "$artifact_size" '{name:"evydence-cli",media_type:"application/octet-stream",digest:$digest,size:$size}')" +artifact="$(api POST /v1/artifacts local-ci-artifact "$artifact_payload")" +printf '%s\n' "$artifact" >"$workdir/artifact.json" +artifact_id="$(printf '%s' "$artifact" | jq -er '.data.id')" + +cat >"$workdir/.evydence/sbom.cdx.json" <"$workdir/.evydence/grype.json" <"$workdir/upload-build.stdout" + +python3 scripts/github_release_evidence_manifest.py \ + --out "$workdir/.evydence/upload-manifest.json" \ + --release-id "$release_id" \ + --artifact-id "$artifact_id" \ + --target-ref "pkg:github/aatuh/evydence@0123456789abcdef0123456789abcdef01234567" \ + --idempotency-prefix "local-ci-1001-1" \ + --cyclonedx-sbom "$workdir/.evydence/sbom.cdx.json" \ + --grype-json "$workdir/.evydence/grype.json" \ + --include-release-bundle + +"$workdir/evydence" upload validate-manifest \ + --manifest "$workdir/.evydence/upload-manifest.json" \ + >"$workdir/validate-manifest.stdout" + +"$workdir/evydence" ci preflight \ + --url "$api_url" \ + --api-key "$api_key" \ + --product-id "$product_id" \ + --project-id "$project_id" \ + --release-id "$release_id" \ + --artifact-id "$artifact_id" \ + --manifest "$workdir/.evydence/upload-manifest.json" \ + >"$workdir/preflight.stdout" + +"$workdir/evydence" upload manifest \ + --url "$api_url" \ + --api-key "$api_key" \ + --manifest "$workdir/.evydence/upload-manifest.json" \ + >"$workdir/upload-manifest.stdout" + +profile_payload='{"preset":"customer_safe"}' +profile="$(api POST /v1/redaction-profiles local-ci-redaction-profile "$profile_payload")" +printf '%s\n' "$profile" >"$workdir/redaction-profile.json" +profile_id="$(printf '%s' "$profile" | jq -er '.data.id')" + +package_payload="$(jq -cn --arg product_id "$product_id" --arg release_id "$release_id" --arg profile_id "$profile_id" '{product_id:$product_id,release_id:$release_id,redaction_profile_id:$profile_id,title:"Local CI release evidence",expires_at:"2026-06-30T00:00:00Z"}')" +package="$(api POST /v1/customer-packages local-ci-customer-package "$package_payload")" +printf '%s\n' "$package" >"$workdir/customer-package.json" +jq -e '.data.id and .data.manifest.limitations and .data.manifest.non_claims' "$workdir/customer-package.json" >/dev/null + +readiness="$(api GET "/v1/reports/release-readiness?release_id=${release_id}" "" "")" +printf '%s\n' "$readiness" >"$workdir/release-readiness.json" +jq -e --arg release_id "$release_id" '.data.release_id == $release_id and .data.result == "passed"' "$workdir/release-readiness.json" >/dev/null + +audit="$(api GET /v1/audit-chain/verify "" "")" +printf '%s\n' "$audit" >"$workdir/audit-chain-verification.json" +jq -e '.data.result == "passed"' "$workdir/audit-chain-verification.json" >/dev/null + +if find "$workdir" -type f \( -name '*.json' -o -name '*.stdout' -o -name '*.stderr' \) -exec grep -I -l 'evy_' {} + | grep -q .; then + printf '%s\n' "local-ci-simulation-check: generated artifacts leaked an API key-like secret" >&2 + exit 1 +fi + +printf '%s\n' "local-ci-simulation-check: local CI preflight, upload, readiness, package, and audit-chain flow passed" diff --git a/scripts/openapi_contract_matrix.py b/scripts/openapi_contract_matrix.py new file mode 100755 index 0000000..2b60eb4 --- /dev/null +++ b/scripts/openapi_contract_matrix.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 +"""Generate a deterministic public API contract precision matrix.""" + +from __future__ import annotations + +import json +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +OPENAPI = ROOT / "openapi.yaml" + + +def schema_label(media: dict) -> str: + schema = media.get("schema") or {} + ref = schema.get("$ref") + if ref: + return ref.rsplit("/", 1)[-1] + typ = schema.get("type") + fmt = schema.get("format") + if typ and fmt: + return f"{typ}/{fmt}" + if typ: + return str(typ) + return "generic" + + +def request_schema(operation: dict) -> str: + body = operation.get("requestBody") + if not body: + return "-" + content = body.get("content") or {} + if not content: + return "unspecified" + return ", ".join(f"{kind}:{schema_label(media)}" for kind, media in sorted(content.items())) + + +def response_schemas(operation: dict) -> str: + labels: list[str] = [] + for status, response in sorted((operation.get("responses") or {}).items()): + if not status.startswith("2"): + continue + content = response.get("content") or {} + if not content: + labels.append(f"{status}:unspecified") + continue + labels.append(f"{status}:" + ",".join(f"{kind}:{schema_label(media)}" for kind, media in sorted(content.items()))) + return "
".join(labels) if labels else "-" + + +def parameter_names(operation: dict) -> str: + params = operation.get("parameters") or [] + if not params: + return "-" + grouped = [f"{p.get('in', '?')}:{p.get('name', '?')}" for p in params] + return ", ".join(grouped) + + +def auth_label(operation: dict) -> str: + if "security" in operation and not operation.get("security"): + return "public" + if operation.get("security"): + return "Bearer" + return "public" + + +def idempotency_label(method: str, operation: dict) -> str: + if method.lower() != "post": + return "-" + policy = operation.get("x-idempotency-key") + if isinstance(policy, dict) and policy.get("required") is False: + return "not required" + if auth_label(operation) == "public": + return "not required" + return "required" + + +def scopes_label(operation: dict) -> str: + scopes = operation.get("x-scopes") or [] + return ", ".join(scopes) if scopes else "-" + + +def precision_label(operation: dict) -> str: + request = request_schema(operation) + response = response_schemas(operation) + broad_tokens = ("DataEnvelope", "generic", "unspecified") + if any(token in request or token in response for token in broad_tokens): + return "broad" + return "precise" + + +def esc(value: str) -> str: + return value.replace("|", "\\|") + + +def main() -> int: + spec = json.loads(OPENAPI.read_text()) + rows: list[tuple[str, str, str, str, str, str, str, str, str]] = [] + for path, path_item in sorted(spec["paths"].items()): + for method, operation in sorted(path_item.items()): + if method.lower() not in {"get", "post", "put", "patch", "delete"}: + continue + rows.append( + ( + method.upper(), + path, + operation.get("operationId", "-"), + auth_label(operation), + scopes_label(operation), + idempotency_label(method, operation), + parameter_names(operation), + request_schema(operation), + response_schemas(operation), + precision_label(operation), + ) + ) + + precise = sum(1 for row in rows if row[-1] == "precise") + broad = len(rows) - precise + lines = [ + "# API Contract Matrix", + "", + "This generated reference inventories Evydence `/v1` route contract precision from `openapi.yaml`.", + "It is a planning aid for production contract hardening; `broad` means the route still uses a shared envelope, unspecified body, or generic schema where an endpoint-specific contract should be considered.", + "", + f"Generated from {len(rows)} operations: {precise} precise, {broad} broad.", + "", + "| Method | Path | Operation | Auth | Scopes | Idempotency | Params | Request | 2xx Response | Precision |", + "| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |", + ] + for row in rows: + lines.append("| " + " | ".join(esc(value) for value in row) + " |") + print("\n".join(lines) + "\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/openapi_precision_check.py b/scripts/openapi_precision_check.py new file mode 100755 index 0000000..fb5ccda --- /dev/null +++ b/scripts/openapi_precision_check.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +"""Fail when committed OpenAPI contract precision regresses.""" + +from __future__ import annotations + +import json +import os +import pathlib +import sys + +import openapi_contract_matrix + + +ROOT = pathlib.Path(__file__).resolve().parents[1] + + +def int_env(name: str, fallback: int) -> int: + value = os.environ.get(name, "").strip() + if not value: + return fallback + try: + parsed = int(value) + except ValueError: + print(f"openapi-precision-check: {name} must be an integer", file=sys.stderr) + raise SystemExit(2) + if parsed < 0: + print(f"openapi-precision-check: {name} must be non-negative", file=sys.stderr) + raise SystemExit(2) + return parsed + + +def main() -> int: + spec = json.loads((ROOT / "openapi.yaml").read_text(encoding="utf-8")) + precise = 0 + broad = 0 + for path_item in spec["paths"].values(): + for method, operation in path_item.items(): + if method.lower() not in {"get", "post", "put", "patch", "delete"}: + continue + if openapi_contract_matrix.precision_label(operation) == "precise": + precise += 1 + else: + broad += 1 + + min_precise = int_env("EVYDENCE_OPENAPI_MIN_PRECISE", 160) + max_broad = int_env("EVYDENCE_OPENAPI_MAX_BROAD", 0) + if precise < min_precise: + print( + f"openapi-precision-check: precise operations {precise} below required {min_precise}", + file=sys.stderr, + ) + return 1 + if broad > max_broad: + print( + f"openapi-precision-check: broad operations {broad} above allowed {max_broad}", + file=sys.stderr, + ) + return 1 + print( + f"openapi-precision-check: {precise} precise operations, {broad} broad operations " + f"(minimum precise {min_precise}, maximum broad {max_broad})" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/persistence_decomposition_inventory.py b/scripts/persistence_decomposition_inventory.py new file mode 100755 index 0000000..8ceee58 --- /dev/null +++ b/scripts/persistence_decomposition_inventory.py @@ -0,0 +1,168 @@ +#!/usr/bin/env python3 +"""Generate and validate Evydence persistence decomposition inventory.""" + +from __future__ import annotations + +import argparse +import difflib +import re +import sys +from dataclasses import dataclass +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[1] +APP_ROOT = REPO_ROOT / "internal" / "app" +OUTPUT = REPO_ROOT / "docs" / "reference" / "persistence-decomposition.md" + +FUNC_RE = re.compile(r"^func\s+(?:\([^)]*\)\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*\(") + + +@dataclass(frozen=True) +class PersistCall: + file: str + function: str + call: str + + +FAMILY_BY_FILE = { + "builds.go": "build provenance", + "controls.go": "controls", + "enterprise.go": "enterprise identity and retention", + "future_extensions.go": "future extensions and generated reports", + "governance_packages.go": "governance, packages, and package reports", + "identity_service.go": "identity", + "implementation_increments.go": "release extensions, source, and deployment", + "integrity_runtime.go": "integrity and operations", + "ledger.go": "release ledger and signing", + "risk_workflows.go": "risk and security workflows", + "state.go": "persistence helpers", + "vex.go": "VEX and vulnerability decisions", +} + + +def collect_calls() -> list[PersistCall]: + calls: list[PersistCall] = [] + for path in sorted(APP_ROOT.glob("*.go")): + if path.name.endswith("_test.go") or path.name == "state.go": + continue + current = "package-scope" + for line in path.read_text(encoding="utf-8").splitlines(): + match = FUNC_RE.match(line) + if match: + current = match.group(1) + continue + for call in ( + "persistCriticalLocked", + "persistReleaseLedgerLocked", + "persistReleaseLedgerWithOutboxLocked", + "persistLocked", + ): + if f".{call}(ctx" in line or f" {call}(ctx" in line: + calls.append(PersistCall(path.name, current, call)) + return calls + + +def render_table(calls: list[PersistCall]) -> list[str]: + if not calls: + return ["| Family | File | Function | Call |", "| --- | --- | --- | --- |", "| none | none | none | none |"] + rows = ["| Family | File | Function | Call |", "| --- | --- | --- | --- |"] + for call in sorted(calls, key=lambda c: (FAMILY_BY_FILE.get(c.file, c.file), c.file, c.function, c.call)): + rows.append( + "| " + + " | ".join( + [ + FAMILY_BY_FILE.get(call.file, "unclassified"), + f"`internal/app/{call.file}`", + f"`{call.function}`", + f"`{call.call}`", + ] + ) + + " |" + ) + return rows + + +def render(calls: list[PersistCall]) -> str: + critical = [c for c in calls if c.call == "persistCriticalLocked"] + release = [c for c in calls if c.call in {"persistReleaseLedgerLocked", "persistReleaseLedgerWithOutboxLocked"}] + broad = [c for c in calls if c.call == "persistLocked"] + lines: list[str] = [ + "# Persistence Decomposition Inventory", + "", + "This reference is generated by `scripts/persistence_decomposition_inventory.py`.", + "Update it with `scripts/persistence_decomposition_inventory.py --write` after persistence call sites change.", + "", + "Purpose: keep the production persistence story inspectable while Evydence continues splitting the large application ledger into focused repository paths.", + "", + "## Current Production Contract", + "", + "- Production PostgreSQL startup uses relational-only reconstruction.", + "- Production writes do not create or update the compatibility `ledger_state` snapshot row.", + "- `persistCriticalLocked` writes high-risk identity, key, idempotency, signing, verification, bundle, provider-verification, decision, audit-chain, and outbox state through focused PostgreSQL transactions when the store supports them.", + "- `persistReleaseLedgerLocked` and `persistReleaseLedgerWithOutboxLocked` write release-ledger and evidence-core state through focused PostgreSQL transactions when the store supports them.", + "- Remaining direct `persistLocked` call sites use `SaveRelationalState` with the PostgreSQL store, not `SaveState`; compatibility `SaveState` remains for memory/local/demo/import paths.", + "- One API writer replica remains the supported production candidate topology until every write family has focused repository paths or a reviewed optimistic-concurrency design.", + "", + "## Focused Critical Mutations", + "", + *render_table(critical), + "", + "## Focused Release And Evidence Mutations", + "", + *render_table(release), + "", + "## Remaining Broad Relational-State Mutations", + "", + *render_table(broad), + "", + "## Next Decomposition Order", + "", + "1. Package and report writes: customer package access counters, generated HTML/PDF/report rows, evidence bundles, questionnaire outputs, and customer-facing access records.", + "2. Controls and governance writes: frameworks, controls, control evidence, waivers, approvals, and custom policies.", + "3. Build/source/deployment writes: build runs, attestations, source snapshots, release candidates, image/signature records, and deployment events.", + "4. Integrity and operations writes: signing providers, signing operations, Merkle batches, transparency checkpoints, retention policies, backup manifests, and verification receipts.", + "5. Future-extension rows: marketplace collectors, public transparency items, graph snapshots, SaaS profile records, PDF/anomaly reports, and AI/questionnaire draft artifacts.", + "", + "## Regression Checks", + "", + "- `go test ./internal/app -run TestRelationalStateStore` proves representative broad relational-state families avoid aggregate `SaveState` when `SaveRelationalState` is available.", + "- `go test ./internal/adapters/postgres -run 'TestApply(Critical|ReleaseLedger)Mutation'` proves focused mutations write relational tables and avoid the compatibility snapshot row.", + "- `make persistence-decomposition-check` regenerates this inventory and fails when the documented call-site map drifts.", + "", + "## Limitations", + "", + "This inventory is a repository-local decomposition map. It is not evidence of legal compliance, certification, complete operational hardening, or multi-writer API safety.", + "", + ] + return "\n".join(lines) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--write", action="store_true", help="write generated inventory to docs/reference/persistence-decomposition.md") + parser.add_argument("--check", action="store_true", help="fail if the generated inventory differs from the committed file") + args = parser.parse_args() + body = render(collect_calls()) + if args.write: + OUTPUT.write_text(body, encoding="utf-8") + return 0 + if args.check: + current = OUTPUT.read_text(encoding="utf-8") if OUTPUT.exists() else "" + if current != body: + diff = difflib.unified_diff( + current.splitlines(), + body.splitlines(), + fromfile=str(OUTPUT), + tofile="generated", + lineterm="", + ) + print("\n".join(diff), file=sys.stderr) + return 1 + return 0 + print(body) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/production_benchmark_check.sh b/scripts/production_benchmark_check.sh new file mode 100755 index 0000000..ea7aa92 --- /dev/null +++ b/scripts/production_benchmark_check.sh @@ -0,0 +1,158 @@ +#!/usr/bin/env sh +set -eu + +repo_root="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" +cd "$repo_root" + +fail() { + printf '%s\n' "production-benchmark-check: $*" >&2 + exit 2 +} + +if [ -z "${EVYDENCE_TEST_DATABASE_URL:-}" ]; then + fail "EVYDENCE_TEST_DATABASE_URL is required" +fi + +for tool in go jq psql python3 curl; do + if ! command -v "$tool" >/dev/null 2>&1; then + fail "missing required tool: $tool" + fi +done + +workdir="${EVYDENCE_PRODUCTION_BENCHMARK_WORKDIR:-tmp/production-benchmark}" +case "$workdir" in + tmp/*) ;; + *) fail "EVYDENCE_PRODUCTION_BENCHMARK_WORKDIR must stay under tmp/" ;; +esac +case "$workdir" in + *..*|*/.*|.*) fail "EVYDENCE_PRODUCTION_BENCHMARK_WORKDIR must not contain dot segments" ;; +esac + +rm -rf "$workdir" +mkdir -p "$workdir" + +now_ns() { + python3 - <<'PY' +import time +print(time.monotonic_ns()) +PY +} + +started_ns="$(now_ns)" +EVYDENCE_BLACK_BOX_WORKDIR="$workdir/black-box" \ +EVYDENCE_BLACK_BOX_KEEP_ARTIFACTS=1 \ +scripts/black_box_demo_check.sh >"$workdir/black-box.stdout" 2>"$workdir/black-box.stderr" +ended_ns="$(now_ns)" + +python3 - "$workdir" "$started_ns" "$ended_ns" <<'PY' +import json +import os +import subprocess +import sys +from datetime import datetime, timezone +from pathlib import Path + +workdir = Path(sys.argv[1]) +started_ns = int(sys.argv[2]) +ended_ns = int(sys.argv[3]) +duration_ms = round((ended_ns - started_ns) / 1_000_000, 3) +duration_seconds = max((ended_ns - started_ns) / 1_000_000_000, 0.001) + +demo_dir = workdir / "black-box" / "demo" +object_root = workdir / "black-box" / "objects" + +def count_files(root: Path): + if not root.exists(): + return 0, 0 + files = [path for path in root.rglob("*") if path.is_file()] + return len(files), sum(path.stat().st_size for path in files) + +object_files, object_bytes = count_files(object_root) +demo_json_files = len(list(demo_dir.glob("*.json"))) if demo_dir.exists() else 0 + +release_id = "" +release_file = demo_dir / "release.json" +if release_file.exists(): + try: + release_id = json.loads(release_file.read_text()).get("data", {}).get("id", "") + except json.JSONDecodeError: + release_id = "" + +try: + commit = subprocess.check_output(["git", "rev-parse", "--short=12", "HEAD"], text=True).strip() +except Exception: + commit = "unknown" + +scenario_operations = 12 +summary = { + "schema_version": "evydence-production-benchmark.v1.0.0", + "benchmark": "golden_release_evidence_black_box", + "generated_at": datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z"), + "commit": commit, + "scenario": { + "source": "examples/end-to-end-release-evidence/run-local-demo.sh", + "api_writer_replicas": 1, + "worker_replicas": 1, + "database": "disposable PostgreSQL schema from EVYDENCE_TEST_DATABASE_URL", + "object_store": "filesystem object store under controlled tmp workspace", + "signing": "tenant signing path exercised by release bundle generation", + "paths": [ + "HTTP API create/list/report calls", + "PostgreSQL migrations and persistence", + "filesystem object payload storage", + "worker startup and outbox drain check", + "release-readiness report", + "customer package creation", + "audit-chain verification", + "API restart persistence check", + ], + "operation_count_estimate": scenario_operations, + }, + "timings": { + "black_box_total_ms": duration_ms, + "estimated_operations_per_second": round(scenario_operations / duration_seconds, 3), + "estimated_average_operation_ms": round(duration_ms / scenario_operations, 3), + }, + "outputs": { + "summary_path": str(workdir / "production-benchmark-summary.json"), + "black_box_stdout": str(workdir / "black-box.stdout"), + "black_box_stderr": str(workdir / "black-box.stderr"), + "demo_json_files": demo_json_files, + "object_files": object_files, + "object_bytes": object_bytes, + "release_id": release_id, + }, + "limitations": [ + "This is a local regression benchmark, not a production capacity claim.", + "Filesystem object storage is used so the run does not prove S3 or MinIO network latency.", + "The run uses one API writer and one worker; it does not prove multi-writer API safety.", + "The operation rate is estimated from the checked scenario wall clock and is not per-route latency.", + "Provider-side KMS, HSM, identity provider, transparency log, and registry controls are not exercised.", + "Database URL, API key secret, and bootstrap secret values are intentionally omitted from the summary.", + ], +} + +summary_path = workdir / "production-benchmark-summary.json" +summary_path.write_text(json.dumps(summary, indent=2, sort_keys=True) + "\n") + +text_path = workdir / "production-benchmark-summary.txt" +text_path.write_text( + "\n".join( + [ + "production-benchmark-check: passed", + f"commit={commit}", + f"black_box_total_ms={duration_ms}", + f"estimated_operations_per_second={summary['timings']['estimated_operations_per_second']}", + f"demo_json_files={demo_json_files}", + f"object_files={object_files}", + f"object_bytes={object_bytes}", + "claim=local regression benchmark only", + "", + ] + ) +) +print(text_path.read_text(), end="") +PY + +jq -e '.schema_version == "evydence-production-benchmark.v1.0.0" and .outputs.object_files > 0 and (.limitations | length >= 3)' \ + "$workdir/production-benchmark-summary.json" >/dev/null diff --git a/scripts/production_check.sh b/scripts/production_check.sh new file mode 100755 index 0000000..aad5ecc --- /dev/null +++ b/scripts/production_check.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env sh +set -eu + +repo_root="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" +cd "$repo_root" + +if [ -z "${EVYDENCE_TEST_DATABASE_URL:-}" ]; then + printf '%s\n' "production-check: EVYDENCE_TEST_DATABASE_URL is required" >&2 + exit 2 +fi + +printf '%s\n' "Running Evydence production readiness checks" + +make release-check +make coverage-check +make migration-compatibility-check +make benchmark-check +make black-box-demo-check +make black-box-release-artifact-check + +workdir="tmp/production-check" +rm -rf "$workdir" +mkdir -p "$workdir" + +go build -o "$workdir/evydence" ./cmd/evydence +cp openapi.yaml "$workdir/openapi.yaml" + +"$workdir/evydence" release manifest \ + --out "$workdir/evydence-release-manifest.json" \ + "$workdir/evydence" \ + "$workdir/openapi.yaml" + +"$workdir/evydence" release keygen \ + --private-out "$workdir/release-private.key" \ + --public-out "$workdir/release-public.key" >/dev/null + +"$workdir/evydence" release sign \ + --manifest "$workdir/evydence-release-manifest.json" \ + --private-key "$workdir/release-private.key" \ + --out "$workdir/evydence-release-manifest.sig.json" + +"$workdir/evydence" release verify \ + --manifest "$workdir/evydence-release-manifest.json" \ + --signature "$workdir/evydence-release-manifest.sig.json" + +printf '%s\n' "production-check: release signing smoke test passed" +printf '%s\n' "Evydence production readiness checks passed" diff --git a/scripts/public_release_verify.sh b/scripts/public_release_verify.sh new file mode 100755 index 0000000..a46df7d --- /dev/null +++ b/scripts/public_release_verify.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo="${EVYDENCE_RELEASE_REPO:-aatuh/evydence}" +tag="${1:-${TAG:-v0.1.0-rc.7}}" + +if [[ ! "$repo" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]]; then + echo "EVYDENCE_RELEASE_REPO must look like owner/name" >&2 + exit 2 +fi +if [[ ! "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$ ]]; then + echo "release candidate tag must look like v0.1.0-rc.7" >&2 + exit 2 +fi +if ! command -v gh >/dev/null 2>&1; then + echo "gh is required to download public release assets" >&2 + exit 2 +fi + +workdir="$(mktemp -d "${TMPDIR:-/tmp}/evydence-release-verify.XXXXXX")" +cleanup() { + rm -rf "$workdir" +} +trap cleanup EXIT + +echo "Downloading ${repo} ${tag} into ${workdir}" +gh release download "$tag" --repo "$repo" --dir "$workdir" + +(cd "$workdir" && sha256sum -c SHA256SUMS) +(cd "$workdir" && sha256sum -c openapi.sha256) +sha256sum -c "$workdir/migrations.sha256" + +archive="$workdir/evydence_${tag}_linux_amd64.tar.gz" +if [[ ! -f "$archive" ]]; then + echo "linux amd64 release archive missing: ${archive}" >&2 + exit 2 +fi +tar -C "$workdir" -xzf "$archive" +cli="$workdir/evydence_${tag}_linux_amd64/evydence" +"$cli" release verify \ + --manifest "$workdir/evydence-release-manifest.json" \ + --signature "$workdir/evydence-release-manifest.sig.json" + +if [[ -f "$workdir/evydence-release-manifest.sig" ]]; then + cmp -s "$workdir/evydence-release-manifest.sig.json" "$workdir/evydence-release-manifest.sig" +fi +python3 - "$workdir/evydence-release-provenance.intoto.jsonl" <<'PY' +import json +import sys +from pathlib import Path + +path = Path(sys.argv[1]) +line = path.read_text(encoding="utf-8").strip() +statement = json.loads(line) +if statement.get("_type") != "https://in-toto.io/Statement/v0.1": + raise SystemExit("release provenance is not an in-toto statement") +if "not a SLSA level claim" not in json.dumps(statement, sort_keys=True): + raise SystemExit("release provenance limitations are missing") +PY + +echo "public release verified: ${repo} ${tag}" diff --git a/scripts/release_acceptance.sh b/scripts/release_acceptance.sh new file mode 100755 index 0000000..e74ad74 --- /dev/null +++ b/scripts/release_acceptance.sh @@ -0,0 +1,161 @@ +#!/usr/bin/env sh +set -eu + +repo_root="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" +cd "$repo_root" + +require_file() { + if [ ! -f "$1" ]; then + printf '%s\n' "release-acceptance: missing required file: $1" >&2 + exit 2 + fi +} + +require_text() { + file="$1" + text="$2" + if ! grep -F -- "$text" "$file" >/dev/null; then + printf '%s\n' "release-acceptance: $file missing text: $text" >&2 + exit 2 + fi +} + +reject_text() { + text="$1" + if grep -R -i -- "$text" README.md docs COMMERCIAL.md GOVERNANCE.md CONTRIBUTING.md SECURITY.md SUPPORT.md TRADEMARKS.md RELEASE_EVIDENCE.md CHANGELOG.md >/dev/null; then + printf '%s\n' "release-acceptance: forbidden claim found: $text" >&2 + exit 2 + fi +} + +printf '%s\n' "Running Evydence release acceptance checks" + +make fast-check + +for file in \ + LICENSE \ + COMMERCIAL.md \ + GOVERNANCE.md \ + CONTRIBUTING.md \ + SECURITY.md \ + SUPPORT.md \ + TRADEMARKS.md \ + RELEASE_EVIDENCE.md \ + CHANGELOG.md \ + CODEOWNERS \ + .dockerignore \ + .github/ISSUE_TEMPLATE.md \ + .github/pull_request_template.md \ + .github/workflows/codeql.yml \ + .github/workflows/container-image.yml \ + release/current.json \ + scripts/check_release_truth.py \ + README.md \ + docs/README.md \ + docs/reference/release-candidate.md \ + docs/reference/release-evidence-index.md \ + docs/reference/maintainer-review-policy.md \ + docs/reference/roadmap.md \ + docs/reference/release-validation.md; do + require_file "$file" +done + +require_text LICENSE "GNU AFFERO GENERAL PUBLIC LICENSE" +require_text COMMERCIAL.md "AGPL-3.0-only" +require_text COMMERCIAL.md "Commercial license exceptions" +require_text COMMERCIAL.md "compliance readiness" +require_text GOVERNANCE.md "tenant-scoped" +require_text GOVERNANCE.md "compliance and legal language stays conservative" +require_text CONTRIBUTING.md "contributor license agreement" +require_text CONTRIBUTING.md "EVYDENCE_TEST_DATABASE_URL" +require_text SECURITY.md "raw evidence payloads" +require_text SECURITY.md "tenant isolation" +require_text SECURITY.md "private security intake" +require_text SECURITY.md "Supported Versions And Scope" +require_text .github/ISSUE_TEMPLATE.md "private vulnerability reporting" +require_text .github/ISSUE_TEMPLATE.md "raw evidence payloads" +require_text .github/pull_request_template.md "tenant isolation" +require_text .github/pull_request_template.md "Sensitive Data Check" +require_text CODEOWNERS "internal/app/" +require_text CODEOWNERS "docs/reference/release-evidence-index.md" +require_text .github/workflows/codeql.yml "github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa" +require_text .github/workflows/codeql.yml "security-and-quality" +require_text .github/workflows/ci.yml "actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd" +require_text .github/workflows/release-artifacts.yml "contents: read" +require_text .github/workflows/release-artifacts.yml "EVYDENCE_RELEASE_PUBLISH_TOKEN" +require_text .github/workflows/release-artifacts.yml "actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c" +require_text .github/workflows/release-artifacts.yml "--repo \"\${repo}\"" +if grep -F "contents: write" .github/workflows/release-artifacts.yml >/dev/null; then + printf '%s\n' "release-acceptance: release artifact workflow must not grant GITHUB_TOKEN contents: write" >&2 + exit 2 +fi +require_text .github/workflows/container-image.yml "ghcr.io/\${{ github.repository }}" +require_text .github/workflows/container-image.yml "EVYDENCE_GHCR_PUBLISH_TOKEN" +if grep -F "packages: write" .github/workflows/container-image.yml >/dev/null; then + printf '%s\n' "release-acceptance: container image workflow must not grant GITHUB_TOKEN packages: write" >&2 + exit 2 +fi +require_text .github/workflows/container-image.yml "cosign sign --yes" +require_text .github/workflows/container-image.yml "cosign verify" +require_text .github/workflows/container-image.yml "evydence-container-image-manifest.json" +require_text .github/workflows/scorecard.yml "ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a" +require_text .github/workflows/scorecard.yml "publish_results: true" +require_text .github/workflows/scorecard-sarif.yml "publish_results: false" +require_text .github/workflows/scorecard-sarif.yml "github/codeql-action/upload-sarif@7211b7c8077ea37d8641b6271f6a365a22a5fbfa" +require_text SUPPORT.md "sanitized logs" +require_text SUPPORT.md "release evidence artifacts" +require_text TRADEMARKS.md "Evydence fork" +require_text RELEASE_EVIDENCE.md "Release evidence is not a certification" +require_text RELEASE_EVIDENCE.md "make release-check" +require_text RELEASE_EVIDENCE.md "release-candidate" +require_text CHANGELOG.md "Unreleased" +require_text docs/reference/release-candidate.md "Controlled self-hosted production candidate" +require_text docs/reference/release-candidate.md "Use one API writer replica" +require_text docs/reference/release-candidate.md "OpenAPI checksum" +require_text docs/reference/release-candidate.md "migration checksum" +require_text docs/reference/release-candidate.md "Release evidence index" +require_text docs/reference/release-evidence-index.md "evydence-release-manifest.sig.json" +require_text docs/reference/release-evidence-index.md "evydence-release-manifest.sig" +require_text docs/reference/release-evidence-index.md "evydence-release-provenance.intoto.jsonl" +require_text docs/reference/release-evidence-index.md "evydence-container-image-manifest.json" +require_text docs/reference/release-evidence-index.md "sha256:38188044a3e5ded3c6094564ab39ce989185f65e22cf4296985ec19ba0eb1888" +require_text docs/reference/release-evidence-index.md "not legal compliance proof" +require_text docs/reference/maintainer-review-policy.md "CODEOWNERS" +require_text docs/reference/maintainer-review-policy.md "tenant-scoped resources cannot cross tenant boundaries" +require_text docs/reference/maintainer-review-policy.md "OpenSSF Scorecard and Scorecard SARIF remain" +require_text docs/reference/roadmap.md "one API writer replica" +require_text docs/reference/roadmap.md "Release candidates" + +for pattern in \ + ".refs" \ + ".env.*" \ + ".api.env.*" \ + ".test.env.*" \ + "*.pem" \ + "*.key" \ + "release-evidence" \ + "backups" \ + "coverage.out" \ + "bin/" \ + "dist/" \ + "tmp/" \ + ".terraform" \ + "*.tfstate"; do + require_text .dockerignore "$pattern" +done + +require_text README.md "License, Security, Support, And Governance" +require_text README.md "AGPL-3.0-only" +require_text docs/README.md "Security policy" +require_text docs/README.md "Release evidence" +scripts/check_release_truth.py + +reject_text "automatically compliant" +reject_text "certified secure" +reject_text "legally sufficient" +reject_text "SBOM is complete" +reject_text "all vulnerabilities detected" +reject_text "scanner findings are authoritative" +reject_text "regulator-ready without review" + +printf '%s\n' "Evydence release acceptance checks passed" diff --git a/scripts/release_asset_smoke_check.sh b/scripts/release_asset_smoke_check.sh new file mode 100755 index 0000000..ed1ac01 --- /dev/null +++ b/scripts/release_asset_smoke_check.sh @@ -0,0 +1,137 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" +cd "$repo_root" + +workdir="tmp/release-asset-smoke" +distdir="${workdir}/dist" +rm -rf "$workdir" +install -m 755 -d "$distdir" + +fail() { + printf '%s\n' "release-asset-smoke-check: $*" >&2 + exit 2 +} + +check_required() { + local dir="$1" + local required=( + "SHA256SUMS" + "openapi.yaml" + "openapi.sha256" + "migrations.sha256" + "release-notes.md" + "evydence-release-manifest.json" + "evydence-release-manifest.sig.json" + "evydence_v0.0.0-rc.0_linux_amd64.tar.gz" + ) + local file + for file in "${required[@]}"; do + [[ -f "${dir}/${file}" ]] || return 1 + done +} + +printf '%s\n' "synthetic release archive for local smoke verification" \ + > "${distdir}/evydence_v0.0.0-rc.0_linux_amd64.tar.gz" +cp openapi.yaml "${distdir}/openapi.yaml" +cat > "${distdir}/release-notes.md" <<'EOF' +# v0.0.0-rc.0 + +Controlled self-hosted production candidate smoke fixture. + +This fixture is not legal compliance proof, not a certification, not complete +SBOM proof, not authoritative vulnerability coverage, and not a secure-release +guarantee. +EOF + +(cd "$distdir" && sha256sum openapi.yaml > openapi.sha256) +find migrations -type f -print0 | LC_ALL=C sort -z | xargs -0 sha256sum > "${distdir}/migrations.sha256" +(cd "$distdir" && sha256sum \ + evydence_v0.0.0-rc.0_linux_amd64.tar.gz \ + openapi.yaml \ + openapi.sha256 \ + migrations.sha256 \ + release-notes.md > SHA256SUMS) + +(cd "$distdir" && sha256sum -c SHA256SUMS >/dev/null) +(cd "$distdir" && sha256sum -c openapi.sha256 >/dev/null) +sha256sum -c "${distdir}/migrations.sha256" >/dev/null + +private_key="${workdir}/private.key" +public_key="${workdir}/public.key" +go run ./cmd/evydence release keygen \ + --private-out "$private_key" \ + --public-out "$public_key" >/dev/null +go run ./cmd/evydence release manifest \ + --out "${distdir}/evydence-release-manifest.json" \ + "${distdir}/evydence_v0.0.0-rc.0_linux_amd64.tar.gz" \ + "${distdir}/openapi.yaml" \ + "${distdir}/openapi.sha256" \ + "${distdir}/migrations.sha256" \ + "${distdir}/release-notes.md" \ + "${distdir}/SHA256SUMS" >/dev/null +go run ./cmd/evydence release sign \ + --manifest "${distdir}/evydence-release-manifest.json" \ + --private-key "$private_key" \ + --out "${distdir}/evydence-release-manifest.sig.json" >/dev/null +rm -f "$private_key" +check_required "$distdir" || fail "synthetic release asset set is incomplete" +go run ./cmd/evydence release verify \ + --manifest "${distdir}/evydence-release-manifest.json" \ + --signature "${distdir}/evydence-release-manifest.sig.json" >/dev/null + +package_out="${workdir}/package-verify.txt" +go run ./cmd/evydence package verify \ + --archive examples/end-to-end-release-evidence/sample-customer-package.zip \ + --expected-package-id csp_example \ + --expected-product-id prod_example \ + --expected-release-id rel_example > "$package_out" +grep -F "customer package verified" "$package_out" >/dev/null + +bad_checksum="${workdir}/bad-checksum" +cp -R "$distdir" "$bad_checksum" +printf '%s\n' "tampered" >> "${bad_checksum}/openapi.yaml" +if (cd "$bad_checksum" && sha256sum -c SHA256SUMS >/dev/null 2>&1); then + fail "tampered checksum unexpectedly verified" +fi + +missing_asset="${workdir}/missing-asset" +cp -R "$distdir" "$missing_asset" +rm -f "${missing_asset}/openapi.sha256" +if check_required "$missing_asset"; then + fail "missing asset set unexpectedly passed required-file check" +fi + +bad_manifest="${workdir}/bad-manifest.json" +python3 - "${distdir}/evydence-release-manifest.json" "$bad_manifest" <<'PY' +import json +import sys +from pathlib import Path + +source = Path(sys.argv[1]) +target = Path(sys.argv[2]) +doc = json.loads(source.read_text(encoding="utf-8")) +doc["tampered"] = True +target.write_text(json.dumps(doc, sort_keys=True) + "\n", encoding="utf-8") +PY +if go run ./cmd/evydence release verify \ + --manifest "$bad_manifest" \ + --signature "${distdir}/evydence-release-manifest.sig.json" >/dev/null 2>&1; then + fail "tampered release manifest unexpectedly verified" +fi + +if go run ./cmd/evydence package verify \ + --archive examples/end-to-end-release-evidence/sample-customer-package.zip \ + --expected-package-id csp_wrong >/dev/null 2>&1; then + fail "wrong expected package id unexpectedly verified" +fi + +cat > "${workdir}/summary.txt" <<'EOF' +release_asset_smoke=passed +checksums=passed +signed_manifest=passed +package_verify=passed +failure_cases=passed +EOF +printf '%s\n' "release-asset-smoke-check: passed" diff --git a/scripts/release_candidate_package.sh b/scripts/release_candidate_package.sh new file mode 100755 index 0000000..74c1d38 --- /dev/null +++ b/scripts/release_candidate_package.sh @@ -0,0 +1,150 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" +cd "$repo_root" + +tag="${1:-}" +if [[ -z "$tag" ]]; then + echo "usage: scripts/release_candidate_package.sh " >&2 + exit 2 +fi +if [[ ! "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$ ]]; then + echo "release candidate tag must look like v0.1.0-rc.1" >&2 + exit 2 +fi +if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then + echo "release candidate packaging must run inside a git checkout" >&2 + exit 2 +fi +if [[ -n "$(git status --porcelain --untracked-files=all)" ]]; then + echo "release candidate packaging requires a clean worktree" >&2 + exit 2 +fi +if git rev-parse -q --verify "refs/tags/${tag}" >/dev/null; then + if [[ "${EVYDENCE_RELEASE_ALLOW_EXISTING_TAG:-}" != "1" ]]; then + echo "release candidate tag already exists locally: ${tag}" >&2 + exit 2 + fi +fi +if [[ -z "${EVYDENCE_TEST_DATABASE_URL:-}" ]]; then + echo "EVYDENCE_TEST_DATABASE_URL is required for release candidate packaging" >&2 + exit 2 +fi + +signing_key_b64="${EVYDENCE_RELEASE_SIGNING_PRIVATE_KEY_B64:-${RELEASE_SIGNING_PRIVATE_KEY_B64:-}}" +if [[ -z "$signing_key_b64" ]]; then + echo "EVYDENCE_RELEASE_SIGNING_PRIVATE_KEY_B64 or RELEASE_SIGNING_PRIVATE_KEY_B64 is required" >&2 + exit 2 +fi +RELEASE_CANDIDATE_SIGNING_KEY_B64="$signing_key_b64" python3 - <<'PY' +import base64 +import os +import sys + +raw = os.environ.get("RELEASE_CANDIDATE_SIGNING_KEY_B64", "").strip() +try: + decoded = base64.b64decode(raw, validate=True) +except Exception: + print("release signing key must be base64", file=sys.stderr) + sys.exit(2) +if len(decoded) != 64: + print("release signing key must decode to a 64-byte Ed25519 private key", file=sys.stderr) + sys.exit(2) +PY + +echo "Packaging Evydence release candidate ${tag}" +make production-check + +distdir="dist/${tag}" +workdir="tmp/release-candidate/${tag}" +rm -rf "$distdir" "$workdir" +install -m 755 -d "$distdir" "$workdir" + +commands=(evydence evydence-api evydence-worker evydence-migrate) +targets=(linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64) + +for target in "${targets[@]}"; do + goos="${target%/*}" + goarch="${target#*/}" + outdir="${workdir}/evydence_${tag}_${goos}_${goarch}" + mkdir -p "$outdir" + for command in "${commands[@]}"; do + suffix="" + if [[ "$goos" == "windows" ]]; then + suffix=".exe" + fi + GOOS="$goos" GOARCH="$goarch" CGO_ENABLED=0 \ + go build -trimpath -ldflags "-s -w" \ + -o "${outdir}/${command}${suffix}" "./cmd/${command}" + done + cp LICENSE README.md CHANGELOG.md "$outdir/" + if [[ "$goos" == "windows" ]]; then + (cd "$workdir" && zip -qr "${repo_root}/${distdir}/evydence_${tag}_${goos}_${goarch}.zip" "evydence_${tag}_${goos}_${goarch}") + else + tar -C "$workdir" -czf "${distdir}/evydence_${tag}_${goos}_${goarch}.tar.gz" "evydence_${tag}_${goos}_${goarch}" + fi +done + +cp openapi.yaml "$distdir/openapi.yaml" +cp coverage.out "$distdir/coverage.out" +cp tmp/release-check-summary.txt "$distdir/release-check-summary.txt" +notes_source="docs/reference/release-notes-${tag}.md" +if [[ ! -f "$notes_source" ]]; then + notes_source="docs/reference/release-notes-template.md" +fi +cp "$notes_source" "$distdir/release-notes.md" +sed -i.bak "s/{{TAG}}/${tag}/g; s/v0.1.0-rc.1/${tag}/g" "$distdir/release-notes.md" +rm -f "$distdir/release-notes.md.bak" + +(cd "$distdir" && sha256sum openapi.yaml > openapi.sha256) +(cd "$repo_root" && find migrations -type f -print | LC_ALL=C sort | xargs sha256sum > "$distdir/migrations.sha256") +python3 scripts/release_evidence_metadata.py "$tag" "$distdir" + +(cd "$distdir" && sha256sum \ + evydence_${tag}_*.tar.gz \ + evydence_${tag}_*.zip \ + openapi.yaml \ + openapi.sha256 \ + evydence-release-sbom.cdx.json \ + evydence-release-provenance.json \ + evydence-release-provenance.intoto.jsonl \ + coverage.out \ + release-check-summary.txt \ + migrations.sha256 \ + release-notes.md > SHA256SUMS) + +signing_dir="${workdir}/signing" +install -m 700 -d "$signing_dir" +signing_key="${signing_dir}/private.key" +umask 077 +printf '%s' "$signing_key_b64" > "$signing_key" + +tar -xzf "${distdir}/evydence_${tag}_linux_amd64.tar.gz" -C "$signing_dir" +cli="${signing_dir}/evydence_${tag}_linux_amd64/evydence" +"$cli" release manifest \ + --out "$distdir/evydence-release-manifest.json" \ + "$distdir"/evydence_${tag}_*.tar.gz \ + "$distdir"/evydence_${tag}_*.zip \ + "$distdir/openapi.yaml" \ + "$distdir/openapi.sha256" \ + "$distdir/evydence-release-sbom.cdx.json" \ + "$distdir/evydence-release-provenance.json" \ + "$distdir/evydence-release-provenance.intoto.jsonl" \ + "$distdir/coverage.out" \ + "$distdir/release-check-summary.txt" \ + "$distdir/migrations.sha256" \ + "$distdir/release-notes.md" \ + "$distdir/SHA256SUMS" +"$cli" release sign \ + --manifest "$distdir/evydence-release-manifest.json" \ + --private-key "$signing_key" \ + --out "$distdir/evydence-release-manifest.sig.json" +rm -f "$signing_key" +cp "$distdir/evydence-release-manifest.sig.json" "$distdir/evydence-release-manifest.sig" +"$cli" release verify \ + --manifest "$distdir/evydence-release-manifest.json" \ + --signature "$distdir/evydence-release-manifest.sig.json" + +scripts/release_candidate_validate.sh "$tag" +echo "release candidate package written: ${distdir}" diff --git a/scripts/release_candidate_validate.sh b/scripts/release_candidate_validate.sh new file mode 100755 index 0000000..b938a9f --- /dev/null +++ b/scripts/release_candidate_validate.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" +cd "$repo_root" + +tag="${1:-}" +if [[ -z "$tag" ]]; then + echo "usage: scripts/release_candidate_validate.sh " >&2 + exit 2 +fi +if [[ ! "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$ ]]; then + echo "release candidate tag must look like v0.1.0-rc.1" >&2 + exit 2 +fi + +distdir="dist/${tag}" +if [[ ! -d "$distdir" ]]; then + echo "release candidate dist directory missing: $distdir" >&2 + exit 2 +fi + +required=( + "SHA256SUMS" + "openapi.yaml" + "openapi.sha256" + "evydence-release-sbom.cdx.json" + "evydence-release-provenance.json" + "evydence-release-provenance.intoto.jsonl" + "migrations.sha256" + "coverage.out" + "release-check-summary.txt" + "release-notes.md" + "evydence-release-manifest.json" + "evydence-release-manifest.sig.json" + "evydence-release-manifest.sig" + "evydence_${tag}_linux_amd64.tar.gz" + "evydence_${tag}_linux_arm64.tar.gz" + "evydence_${tag}_darwin_amd64.tar.gz" + "evydence_${tag}_darwin_arm64.tar.gz" + "evydence_${tag}_windows_amd64.zip" +) +for file in "${required[@]}"; do + if [[ ! -f "$distdir/$file" ]]; then + echo "release candidate artifact missing: $distdir/$file" >&2 + exit 2 + fi +done + +(cd "$distdir" && sha256sum -c SHA256SUMS >/dev/null) +(cd "$distdir" && sha256sum -c openapi.sha256 >/dev/null) +sha256sum -c "$distdir/migrations.sha256" >/dev/null + +go run ./cmd/evydence release verify \ + --manifest "$distdir/evydence-release-manifest.json" \ + --signature "$distdir/evydence-release-manifest.sig.json" >/dev/null +cmp -s "$distdir/evydence-release-manifest.sig.json" "$distdir/evydence-release-manifest.sig" + +grep -Fi "Controlled self-hosted production candidate" "$distdir/release-notes.md" >/dev/null +grep -Fi "not legal compliance proof" "$distdir/release-notes.md" >/dev/null +grep -Fi "not a certification" "$distdir/release-notes.md" >/dev/null +grep -Fi "not a secure-release guarantee" "$distdir/release-notes.md" >/dev/null +grep -Fi "complete SBOM proof" "$distdir/release-notes.md" >/dev/null +grep -Fi "authoritative vulnerability coverage" "$distdir/release-notes.md" >/dev/null +grep -Fi "single API writer replica" "$distdir/release-notes.md" >/dev/null +grep -Fi "full repository decomposition" "$distdir/release-notes.md" >/dev/null +grep -Fi "complete SBOM proof" "$distdir/evydence-release-sbom.cdx.json" >/dev/null +grep -Fi "not a SLSA level claim" "$distdir/evydence-release-provenance.json" >/dev/null +grep -Fi "not a SLSA level claim" "$distdir/evydence-release-provenance.intoto.jsonl" >/dev/null + +if grep -R -i "automatically compliant\|certified secure\|legally sufficient\|SBOM is complete\|all vulnerabilities detected\|scanner findings are authoritative\|regulator-ready without review" "$distdir/release-notes.md" >/dev/null; then + echo "release notes contain a prohibited product claim" >&2 + exit 2 +fi +if find "$distdir" -type f \( -name "*.key" -o -name "*.pem" -o -name "*.p12" -o -name "*.pfx" \) | grep . >/dev/null; then + echo "release candidate artifact directory contains private key material" >&2 + exit 2 +fi + +echo "release candidate evidence validated: $tag" diff --git a/scripts/release_evidence_metadata.py b/scripts/release_evidence_metadata.py new file mode 100755 index 0000000..c46f986 --- /dev/null +++ b/scripts/release_evidence_metadata.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +"""Generate deterministic release-candidate SBOM and provenance metadata.""" + +from __future__ import annotations + +import hashlib +import json +import os +from pathlib import Path +import subprocess +import sys +import uuid + + +def run(args: list[str]) -> str: + return subprocess.check_output(args, text=True).strip() + + +def sha256(path: Path) -> str: + h = hashlib.sha256() + with path.open("rb") as fh: + for chunk in iter(lambda: fh.read(1024 * 1024), b""): + h.update(chunk) + return h.hexdigest() + + +def go_modules() -> list[dict[str, str]]: + raw = subprocess.check_output(["go", "list", "-m", "-json", "all"], text=True) + decoder = json.JSONDecoder() + modules: list[dict[str, str]] = [] + idx = 0 + while idx < len(raw): + while idx < len(raw) and raw[idx].isspace(): + idx += 1 + if idx >= len(raw): + break + obj, idx = decoder.raw_decode(raw, idx) + path = str(obj.get("Path", "")).strip() + if not path: + continue + version = str(obj.get("Version", "")).strip() + replacement = obj.get("Replace") + if isinstance(replacement, dict): + version = str(replacement.get("Version") or replacement.get("Path") or version).strip() + modules.append({"path": path, "version": version}) + modules.sort(key=lambda item: (item["path"], item["version"])) + return modules + + +def write_json(path: Path, payload: dict) -> None: + path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + +def write_jsonl(path: Path, payload: dict) -> None: + path.write_text(json.dumps(payload, sort_keys=True, separators=(",", ":")) + "\n", encoding="utf-8") + + +def main() -> int: + if len(sys.argv) != 3: + print("usage: scripts/release_evidence_metadata.py ", file=sys.stderr) + return 2 + tag = sys.argv[1].strip() + distdir = Path(sys.argv[2]).resolve() + repo = Path.cwd().resolve() + try: + distdir.relative_to(repo) + except ValueError: + print("distdir must be inside the repository", file=sys.stderr) + return 2 + if not distdir.is_dir(): + print(f"distdir missing: {distdir}", file=sys.stderr) + return 2 + + commit = run(["git", "rev-parse", "HEAD"]) + commit_date = run(["git", "show", "-s", "--format=%cI", "HEAD"]) + go_version = run(["go", "version"]) + modules = go_modules() + + sbom = { + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "serialNumber": "urn:uuid:" + str(uuid.uuid5(uuid.NAMESPACE_URL, "github.com/aatuh/evydence:" + tag)), + "version": 1, + "metadata": { + "component": { + "type": "application", + "name": "evydence", + "version": tag, + "purl": "pkg:github/aatuh/evydence@" + tag, + }, + "properties": [ + {"name": "evydence:commit", "value": commit}, + {"name": "evydence:commit_date", "value": commit_date}, + {"name": "evydence:go_version", "value": go_version}, + {"name": "evydence:limitations", "value": "Release SBOM is generated from Go module metadata and release packaging inputs; it is not a complete SBOM proof."}, + ], + }, + "components": [ + { + "type": "library", + "name": module["path"], + "version": module["version"], + "purl": f"pkg:golang/{module['path']}@{module['version']}" if module["version"] else f"pkg:golang/{module['path']}", + } + for module in modules + if module["path"] != "github.com/aatuh/evydence" + ], + } + + artifact_names = sorted( + path.name + for pattern in ("evydence_*.tar.gz", "evydence_*.zip", "openapi.yaml", "migrations.sha256", "release-notes.md") + for path in distdir.glob(pattern) + ) + provenance = { + "schema": "evydence-release-provenance.v1", + "subject": {"name": "evydence", "version": tag, "commit": commit, "commit_date": commit_date}, + "builder": { + "type": "local-or-github-actions-release-candidate-packager", + "commands": ["make production-check", "scripts/release_candidate_package.sh"], + "go_version": go_version, + }, + "materials": [ + {"path": name, "sha256": sha256(distdir / name)} + for name in artifact_names + if (distdir / name).is_file() + ], + "limitations": [ + "This provenance records repository release-candidate packaging inputs and hashes.", + "It is not a SLSA level claim, legal compliance proof, certification, complete SBOM proof, authoritative vulnerability result, or secure-release guarantee.", + "Public release publication, registry image digest verification, branch protection, and provider account settings require operator verification outside repository files.", + ], + "environment": { + "github_repository": os.environ.get("GITHUB_REPOSITORY", ""), + "github_run_id": os.environ.get("GITHUB_RUN_ID", ""), + "github_ref": os.environ.get("GITHUB_REF", ""), + }, + } + intoto_statement = { + "_type": "https://in-toto.io/Statement/v0.1", + "subject": [ + {"name": material["path"], "digest": {"sha256": material["sha256"]}} + for material in provenance["materials"] + ], + "predicateType": "https://evydence.dev/schemas/release-provenance/v1", + "predicate": { + "schema": "evydence-release-provenance.v1", + "subject": provenance["subject"], + "builder": provenance["builder"], + "environment": provenance["environment"], + "limitations": provenance["limitations"], + }, + } + + write_json(distdir / "evydence-release-sbom.cdx.json", sbom) + write_json(distdir / "evydence-release-provenance.json", provenance) + write_jsonl(distdir / "evydence-release-provenance.intoto.jsonl", intoto_statement) + print(f"wrote {distdir / 'evydence-release-sbom.cdx.json'}") + print(f"wrote {distdir / 'evydence-release-provenance.json'}") + print(f"wrote {distdir / 'evydence-release-provenance.intoto.jsonl'}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/render_openapi_docs.py b/scripts/render_openapi_docs.py new file mode 100755 index 0000000..a979d42 --- /dev/null +++ b/scripts/render_openapi_docs.py @@ -0,0 +1,456 @@ +#!/usr/bin/env python3 +"""Render a deterministic static OpenAPI reference page. + +The renderer intentionally avoids remote assets and does not execute shell +commands. It reads the committed OpenAPI document and writes a static HTML page +that can be reviewed locally or hosted as a static artifact. +""" + +from __future__ import annotations + +import argparse +import html +import json +import sys +from pathlib import Path +from typing import Any + + +ROOT = Path(__file__).resolve().parents[1] +OPENAPI_PATH = ROOT / "openapi.yaml" +OUTPUT_PATHS = ( + ROOT / "docs" / "openapi" / "index.html", + ROOT / "site" / "marketing" / "public" / "api" / "index.html", +) +HTTP_METHODS = ("get", "post", "put", "patch", "delete", "options", "head") + + +def escape(value: Any) -> str: + return html.escape(str(value), quote=True) + + +def load_spec() -> dict[str, Any]: + try: + return json.loads(OPENAPI_PATH.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + raise SystemExit(f"{OPENAPI_PATH} is not valid JSON-compatible OpenAPI: {exc}") from exc + + +def ref_name(ref: str) -> str: + return ref.rsplit("/", 1)[-1] + + +def schema_label(schema: Any) -> str: + if not isinstance(schema, dict): + return "unspecified" + if "$ref" in schema: + return ref_name(schema["$ref"]) + if "oneOf" in schema: + return "oneOf(" + ", ".join(schema_label(item) for item in schema["oneOf"]) + ")" + if "anyOf" in schema: + return "anyOf(" + ", ".join(schema_label(item) for item in schema["anyOf"]) + ")" + if "allOf" in schema: + return "allOf(" + ", ".join(schema_label(item) for item in schema["allOf"]) + ")" + typ = schema.get("type") + if typ == "array": + return f"array[{schema_label(schema.get('items'))}]" + if isinstance(typ, list): + return "|".join(str(item) for item in typ) + return str(typ or "object") + + +def sorted_response_codes(responses: dict[str, Any]) -> list[str]: + def key(code: str) -> tuple[int, str]: + if code.isdigit(): + return (int(code), code) + return (9999, code) + + return sorted(responses, key=key) + + +def operation_security(operation: dict[str, Any]) -> str: + security = operation.get("security") + if security == []: + return "Public" + if not security: + return "BearerAuth" + schemes = [] + for entry in security: + if isinstance(entry, dict): + schemes.extend(entry.keys()) + return ", ".join(sorted(set(schemes))) or "BearerAuth" + + +def operation_scopes(operation: dict[str, Any]) -> str: + scopes = operation.get("x-scopes") or [] + if not scopes: + return "none" + return ", ".join(str(scope) for scope in scopes) + + +def operation_idempotency(operation: dict[str, Any]) -> str: + idempotency = operation.get("x-idempotency-key") + if isinstance(idempotency, dict) and idempotency.get("required"): + return str(idempotency.get("header") or "Idempotency-Key") + return "not required" + + +def request_summary(operation: dict[str, Any]) -> str: + body = operation.get("requestBody") + if not isinstance(body, dict): + return "none" + content = body.get("content") + if not isinstance(content, dict) or not content: + return "present" + pieces = [] + for media_type in sorted(content): + media = content.get(media_type) or {} + pieces.append(f"{media_type}: {schema_label(media.get('schema'))}") + return "; ".join(pieces) + + +def response_summary(operation: dict[str, Any]) -> str: + responses = operation.get("responses") + if not isinstance(responses, dict): + return "none" + pieces = [] + for code in sorted_response_codes(responses): + response = responses.get(code) or {} + content = response.get("content") or {} + schemas = [] + for media_type in sorted(content): + media = content.get(media_type) or {} + schemas.append(f"{media_type}: {schema_label(media.get('schema'))}") + description = response.get("description") or "" + detail = ", ".join(schemas) if schemas else description + pieces.append(f"{code} {detail}".strip()) + return "; ".join(pieces) + + +def collect_operations(spec: dict[str, Any]) -> list[dict[str, str]]: + operations: list[dict[str, str]] = [] + paths = spec.get("paths") + if not isinstance(paths, dict): + raise SystemExit("OpenAPI document is missing a paths object") + + for path in sorted(paths): + item = paths[path] + if not isinstance(item, dict): + continue + for method in HTTP_METHODS: + operation = item.get(method) + if not isinstance(operation, dict): + continue + operations.append( + { + "method": method.upper(), + "path": path, + "summary": str(operation.get("summary") or ""), + "description": str(operation.get("description") or ""), + "operation_id": str(operation.get("operationId") or ""), + "tags": ", ".join(str(tag) for tag in operation.get("tags") or []), + "security": operation_security(operation), + "scopes": operation_scopes(operation), + "idempotency": operation_idempotency(operation), + "request": request_summary(operation), + "responses": response_summary(operation), + "search": " ".join( + str(part) + for part in ( + method, + path, + operation.get("summary") or "", + operation.get("description") or "", + operation.get("operationId") or "", + " ".join(operation.get("tags") or []), + operation_scopes(operation), + ) + ).lower(), + } + ) + return operations + + +def collect_schemas(spec: dict[str, Any]) -> list[dict[str, str]]: + schemas = spec.get("components", {}).get("schemas", {}) + if not isinstance(schemas, dict): + return [] + rows = [] + for name in sorted(schemas): + schema = schemas[name] + required = schema.get("required") if isinstance(schema, dict) else None + rows.append( + { + "name": name, + "type": schema_label(schema), + "required": ", ".join(str(item) for item in required or []) or "none", + } + ) + return rows + + +def render(spec: dict[str, Any]) -> str: + operations = collect_operations(spec) + schemas = collect_schemas(spec) + info = spec.get("info") or {} + problem_schema = spec.get("components", {}).get("schemas", {}).get("Problem", {}) + problem_required = ", ".join(problem_schema.get("required") or []) if isinstance(problem_schema, dict) else "none" + + operation_html = [] + for op in operations: + search = escape(op["search"]) + operation_html.append( + f""" +
+
+ {escape(op['method'])} + {escape(op['path'])} +
+

{escape(op['summary'] or op['operation_id'] or op['path'])}

+

{escape(op['description'] or 'No operation description is registered.')}

+
+
Operation ID
{escape(op['operation_id'] or 'not registered')}
+
Tags
{escape(op['tags'] or 'none')}
+
Auth
{escape(op['security'])}
+
Scopes
{escape(op['scopes'])}
+
Idempotency
{escape(op['idempotency'])}
+
Request
{escape(op['request'])}
+
Responses
{escape(op['responses'])}
+
+
""" + ) + + schema_html = [] + for schema in schemas: + schema_html.append( + f""" + + {escape(schema['name'])} + {escape(schema['type'])} + {escape(schema['required'])} + """ + ) + + return f""" + + + + + {escape(info.get('title') or 'Evydence API')} - Rendered OpenAPI Docs + + + +
+

{escape(info.get('title') or 'Evydence API')}

+

{escape(info.get('description') or 'Rendered OpenAPI reference for Evydence.')}

+

This page is generated from openapi.yaml. It is a human-readable companion to the committed contract and does not replace route-contract tests.

+
+
{escape(spec.get('openapi') or 'unknown')}OpenAPI version
+
{len(spec.get('paths') or {})}registered paths
+
{len(operations)}operations
+
{len(schemas)}schemas
+
+
+
+
+

Runtime secrets, tenant data, raw evidence payloads, private keys, bearer tokens, and customer package contents are not embedded in this generated page. Admin-scoped routes appear only when they are part of the committed public contract.

+

Error responses use RFC 9457-style Problem Details. The registered Problem schema currently requires: {escape(problem_required or 'none')}.

+
+ +
+

Operations

+
+ + +
+
+ {''.join(operation_html)} +
+
+ +
+

Schemas

+
+ + + + + + {''.join(schema_html)} + +
NameShapeRequired fields
+
+
+
+ + + +""" + + +def main() -> int: + parser = argparse.ArgumentParser(description="Render or check static OpenAPI docs") + mode = parser.add_mutually_exclusive_group(required=True) + mode.add_argument("--write", action="store_true", help="write docs/openapi/index.html") + mode.add_argument("--check", action="store_true", help="fail if docs/openapi/index.html is stale") + args = parser.parse_args() + + rendered = render(load_spec()) + if args.write: + for path in OUTPUT_PATHS: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(rendered, encoding="utf-8") + return 0 + + stale = False + try: + for path in OUTPUT_PATHS: + current = path.read_text(encoding="utf-8") + if current != rendered: + print(f"{path} is stale; run scripts/render_openapi_docs.py --write", file=sys.stderr) + stale = True + except FileNotFoundError as exc: + print(f"{exc.filename} is missing; run scripts/render_openapi_docs.py --write", file=sys.stderr) + return 1 + return 1 if stale else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/reviewer_package_workflow_check.sh b/scripts/reviewer_package_workflow_check.sh new file mode 100755 index 0000000..29b2ed7 --- /dev/null +++ b/scripts/reviewer_package_workflow_check.sh @@ -0,0 +1,137 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +fail() { + printf '%s\n' "reviewer-package-workflow-check: $*" >&2 + exit 1 +} + +workdir="${EVYDENCE_REVIEWER_WORKFLOW_DIR:-tmp/reviewer-package-workflow}" +case "$workdir" in + tmp/*) ;; + *) fail "EVYDENCE_REVIEWER_WORKFLOW_DIR must stay under tmp/" ;; +esac + +manifest="examples/end-to-end-release-evidence/sample-customer-package-manifest.json" +manifest_sum="examples/end-to-end-release-evidence/sample-customer-package-manifest.sha256" +archive="examples/end-to-end-release-evidence/sample-customer-package.zip" + +for path in "$manifest" "$manifest_sum" "$archive" "docs/how-to/review-customer-package.md"; do + [[ -f "$path" ]] || fail "missing $path" +done + +rm -rf -- "$workdir" +mkdir -p -- "$workdir" + +( + cd "$(dirname "$manifest")" + sha256sum -c "$(basename "$manifest_sum")" >/dev/null +) + +go run ./cmd/evydence package verify \ + --manifest "$manifest" \ + --expected-package-id csp_example \ + --expected-product-id prod_example \ + --expected-release-id rel_example \ + >"$workdir/manifest-verify.txt" + +go run ./cmd/evydence package verify \ + --archive "$archive" \ + --expected-package-id csp_example \ + --expected-product-id prod_example \ + --expected-release-id rel_example \ + >"$workdir/archive-verify.txt" + +python3 - "$manifest" "$archive" "$workdir" <<'PY' +import json +import pathlib +import sys +import zipfile + +manifest_path = pathlib.Path(sys.argv[1]) +archive_path = pathlib.Path(sys.argv[2]) +workdir = pathlib.Path(sys.argv[3]) +extract_dir = workdir / "extracted" +extract_dir.mkdir(parents=True, exist_ok=True) + +manifest = json.loads(manifest_path.read_text()) +required_manifest = [ + "reviewer_checklist", + "readiness_summary", + "verification_material", + "limitations", + "non_claims", + "vulnerability_decisions", +] +missing = [key for key in required_manifest if key not in manifest] +if missing: + raise SystemExit(f"manifest missing reviewer sections: {', '.join(missing)}") + +checklist_ids = set() +for item in manifest["reviewer_checklist"]: + if not isinstance(item, dict): + raise SystemExit("reviewer_checklist entries must be objects") + checklist_ids.add(item.get("id", "")) +for key in ("package_scope", "included_evidence", "excluded_evidence", "hash_signature_verification", "non_claims", "escalation_path"): + if key not in checklist_ids: + raise SystemExit(f"reviewer_checklist missing {key}") + +for forbidden in ("payload_ref", "object_key", "private_key", "token_hash", "internal note", "raw scanner payload"): + if forbidden in json.dumps(manifest, sort_keys=True).lower(): + raise SystemExit(f"manifest leaked forbidden marker {forbidden}") + +with zipfile.ZipFile(archive_path) as zf: + names = set(zf.namelist()) + required_archive = {"manifest.json", "package.json", "verification.json", "report.html"} + missing_archive = sorted(required_archive - names) + if missing_archive: + raise SystemExit(f"archive missing {', '.join(missing_archive)}") + for info in zf.infolist(): + target = extract_dir / info.filename + resolved = target.resolve() + if not str(resolved).startswith(str(extract_dir.resolve()) + "/") and resolved != extract_dir.resolve(): + raise SystemExit(f"archive path escapes extraction root: {info.filename}") + if info.file_size > 2 * 1024 * 1024: + raise SystemExit(f"archive entry too large for reviewer fixture: {info.filename}") + if info.is_dir(): + continue + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(zf.read(info.filename)) + +report = (extract_dir / "report.html").read_text() +report_lower = report.lower() +for expected in ("limitations", "non-claims", "verification", "vulnerability"): + if expected not in report_lower: + raise SystemExit(f"report.html missing {expected}") +for forbidden in ("payload_ref", "object_key", "private_key", "token_hash", "internal note", "/dev/null +grep -F "customer package verified" "$workdir/archive-verify.txt" >/dev/null +grep -F "reviewer package workflow passed" "$workdir/summary.txt" >/dev/null + +printf '%s\n' "reviewer-package-workflow-check: passed" diff --git a/scripts/sdk_check.py b/scripts/sdk_check.py new file mode 100755 index 0000000..cd5bec3 --- /dev/null +++ b/scripts/sdk_check.py @@ -0,0 +1,167 @@ +#!/usr/bin/env python3 +"""Validate SDK helper and route-catalog coverage against committed OpenAPI.""" + +from __future__ import annotations + +import json +import pathlib +import subprocess +import sys +from dataclasses import dataclass + + +ROOT = pathlib.Path(__file__).resolve().parents[1] + + +@dataclass(frozen=True) +class RequiredHelper: + operation_id: str + method: str + path: str + go_name: str + typescript_name: str + python_name: str + idempotent: bool + + +REQUIRED_HELPERS = ( + RequiredHelper("createProduct", "post", "/v1/products", "CreateProduct", "createProduct", "create_product", True), + RequiredHelper("createRelease", "post", "/v1/releases", "CreateRelease", "createRelease", "create_release", True), + RequiredHelper( + "registerArtifact", + "post", + "/v1/artifacts", + "RegisterArtifact", + "registerArtifact", + "register_artifact", + True, + ), + RequiredHelper("createBuild", "post", "/v1/builds", "CreateBuild", "createBuild", "create_build", True), + RequiredHelper("ready", "get", "/v1/ready", "Readiness", "readiness", "readiness", False), + RequiredHelper( + "releaseReadinessReport", + "get", + "/v1/reports/release-readiness", + "ReleaseReadiness", + "releaseReadiness", + "release_readiness", + False, + ), + RequiredHelper( + "createSSOProvider", + "post", + "/v1/sso/providers", + "CreateSSOProvider", + "createSSOProvider", + "create_sso_provider", + True, + ), + RequiredHelper( + "verifyProviderIdentity", + "post", + "/v1/provider-verifications", + "VerifyProviderIdentity", + "verifyProviderIdentity", + "verify_provider_identity", + True, + ), +) + + +def fail(message: str) -> None: + print(f"sdk-check: {message}", file=sys.stderr) + raise SystemExit(2) + + +def load_openapi() -> dict: + try: + return json.loads((ROOT / "openapi.yaml").read_text(encoding="utf-8")) + except FileNotFoundError: + fail("missing openapi.yaml") + except json.JSONDecodeError as exc: + fail(f"openapi.yaml is not parseable JSON: {exc}") + + +def operation(spec: dict, helper: RequiredHelper) -> dict: + try: + op = spec["paths"][helper.path][helper.method] + except KeyError as exc: + fail(f"missing OpenAPI operation for {helper.method.upper()} {helper.path}: {exc}") + if op.get("operationId") != helper.operation_id: + fail( + f"{helper.method.upper()} {helper.path} operationId is {op.get('operationId')!r}, " + f"expected {helper.operation_id!r}" + ) + if helper.idempotent and not op.get("x-idempotency-key", {}).get("required"): + fail(f"{helper.operation_id} must require Idempotency-Key in OpenAPI") + if helper.idempotent and not op.get("requestBody", {}).get("content", {}).get("application/json", {}).get("schema"): + fail(f"{helper.operation_id} must declare an application/json request schema") + if "200" not in op.get("responses", {}) and "201" not in op.get("responses", {}): + fail(f"{helper.operation_id} must declare a success response") + return op + + +def require_text(source: str, token: str, label: str) -> None: + if token not in source: + fail(f"{label} missing {token!r}") + + +def main() -> None: + spec = load_openapi() + catalog_path = ROOT / "sdk" / "openapi-route-catalog.json" + if not catalog_path.exists(): + fail("missing sdk/openapi-route-catalog.json") + generated_catalog = subprocess.check_output( + [sys.executable, str(ROOT / "scripts" / "generate_sdk_route_catalog.py")], + text=True, + ) + committed_catalog = catalog_path.read_text(encoding="utf-8") + if json.loads(generated_catalog) != json.loads(committed_catalog): + fail("sdk/openapi-route-catalog.json is out of date; run scripts/generate_sdk_route_catalog.py") + catalog = json.loads(committed_catalog) + if catalog.get("route_count") != len( + [ + None + for path_item in spec.get("paths", {}).values() + for method in path_item + if method.lower() in {"get", "post", "put", "patch", "delete"} + ] + ): + fail("SDK route catalog route_count does not match openapi.yaml") + for helper in REQUIRED_HELPERS: + operation(spec, helper) + + go_client = (ROOT / "sdk/go/evydence/client.go").read_text(encoding="utf-8") + typescript_client = (ROOT / "sdk/typescript/client.ts").read_text(encoding="utf-8") + python_client = (ROOT / "sdk/python/evydence_client.py").read_text(encoding="utf-8") + quickstarts = (ROOT / "docs/sdk/quickstarts.md").read_text(encoding="utf-8") + + for helper in REQUIRED_HELPERS: + require_text(go_client, f"func (c Client) {helper.go_name}", "Go SDK") + require_text(typescript_client, f"async {helper.typescript_name}", "TypeScript SDK") + require_text(python_client, f"def {helper.python_name}", "Python SDK") + + require_text(go_client, "strings.HasPrefix(path, \"/v1/\")", "Go SDK path validation") + require_text(typescript_client, "path.startsWith(\"/v1/\")", "TypeScript SDK path validation") + require_text(python_client, "path.startswith(\"/v1/\")", "Python SDK path validation") + + for token in ( + "evydence.Client", + "EvydenceClient", + "create_product", + "Idempotency-Key", + "Problem Details", + "IDEMPOTENCY_KEY_REUSED", + "dist/evydence package verify", + "not an SDK helper", + ): + require_text(quickstarts, token, "SDK quickstarts") + + print( + f"sdk-check: validated {len(REQUIRED_HELPERS)} SDK helpers and " + f"{catalog.get('route_count')} generated route catalog entries against openapi.yaml" + ) + + +if __name__ == "__main__": + main() diff --git a/sdk/go/evydence/client.go b/sdk/go/evydence/client.go index 1f7e282..6204292 100644 --- a/sdk/go/evydence/client.go +++ b/sdk/go/evydence/client.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "net/http" + "net/url" "strings" ) @@ -16,6 +17,62 @@ type Client struct { HTTP *http.Client } +type CreateProductRequest struct { + Name string `json:"name"` + Slug string `json:"slug"` +} + +type CreateReleaseRequest struct { + ProductID string `json:"product_id"` + ProjectID string `json:"project_id,omitempty"` + Version string `json:"version"` +} + +type RegisterArtifactRequest struct { + ReleaseID string `json:"release_id,omitempty"` + Name string `json:"name"` + MediaType string `json:"media_type,omitempty"` + Digest string `json:"digest"` + Size int64 `json:"size,omitempty"` +} + +type BuildOutput struct { + ArtifactID string `json:"artifact_id,omitempty"` + Digest string `json:"digest"` + Name string `json:"name,omitempty"` +} + +type CreateBuildRequest struct { + ProjectID string `json:"project_id"` + ReleaseID string `json:"release_id"` + Provider string `json:"provider"` + CommitSHA string `json:"commit_sha"` + Status string `json:"status"` + StartedAt string `json:"started_at"` + Outputs []BuildOutput `json:"outputs,omitempty"` + GitHub map[string]any `json:"github,omitempty"` +} + +type CreateSSOProviderRequest struct { + Name string `json:"name"` + Type string `json:"type"` + Issuer string `json:"issuer"` + ClientID string `json:"client_id"` + GroupsClaim string `json:"groups_claim,omitempty"` + RoleMapping map[string]string `json:"role_mapping,omitempty"` + JWKS map[string]any `json:"jwks,omitempty"` + SAMLSigningCertificates []string `json:"saml_signing_certificates,omitempty"` +} + +type VerifyProviderIdentityRequest struct { + ProviderType string `json:"provider_type"` + ProviderID string `json:"provider_id"` + Subject string `json:"subject"` + IDToken string `json:"id_token,omitempty"` + SAMLAssertion string `json:"saml_assertion,omitempty"` + AccessToken string `json:"access_token,omitempty"` +} + func (c Client) Post(ctx context.Context, path, idempotencyKey string, payload any, out any) error { if !strings.HasPrefix(path, "/v1/") || strings.TrimSpace(idempotencyKey) == "" { return fmt.Errorf("evydence: invalid path or idempotency key") @@ -52,3 +109,68 @@ func (c Client) Post(ctx context.Context, path, idempotencyKey string, payload a } return json.Unmarshal(responseBody, out) } + +func (c Client) Get(ctx context.Context, path string, out any) error { + if !strings.HasPrefix(path, "/v1/") { + return fmt.Errorf("evydence: invalid path") + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimRight(c.BaseURL, "/")+path, nil) + if err != nil { + return err + } + if strings.TrimSpace(c.APIKey) != "" { + req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(c.APIKey)) + } + httpClient := c.HTTP + if httpClient == nil { + httpClient = http.DefaultClient + } + resp, err := httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + responseBody, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("evydence: request failed with status %d", resp.StatusCode) + } + if out == nil { + return nil + } + return json.Unmarshal(responseBody, out) +} + +func (c Client) CreateProduct(ctx context.Context, idempotencyKey string, payload CreateProductRequest, out any) error { + return c.Post(ctx, "/v1/products", idempotencyKey, payload, out) +} + +func (c Client) CreateRelease(ctx context.Context, idempotencyKey string, payload CreateReleaseRequest, out any) error { + return c.Post(ctx, "/v1/releases", idempotencyKey, payload, out) +} + +func (c Client) RegisterArtifact(ctx context.Context, idempotencyKey string, payload RegisterArtifactRequest, out any) error { + return c.Post(ctx, "/v1/artifacts", idempotencyKey, payload, out) +} + +func (c Client) CreateBuild(ctx context.Context, idempotencyKey string, payload CreateBuildRequest, out any) error { + return c.Post(ctx, "/v1/builds", idempotencyKey, payload, out) +} + +func (c Client) Readiness(ctx context.Context, out any) error { + return c.Get(ctx, "/v1/ready", out) +} + +func (c Client) ReleaseReadiness(ctx context.Context, releaseID string, out any) error { + return c.Get(ctx, "/v1/reports/release-readiness?release_id="+url.QueryEscape(releaseID), out) +} + +func (c Client) CreateSSOProvider(ctx context.Context, idempotencyKey string, payload CreateSSOProviderRequest, out any) error { + return c.Post(ctx, "/v1/sso/providers", idempotencyKey, payload, out) +} + +func (c Client) VerifyProviderIdentity(ctx context.Context, idempotencyKey string, payload VerifyProviderIdentityRequest, out any) error { + return c.Post(ctx, "/v1/provider-verifications", idempotencyKey, payload, out) +} diff --git a/sdk/go/evydence/client_test.go b/sdk/go/evydence/client_test.go new file mode 100644 index 0000000..c63f901 --- /dev/null +++ b/sdk/go/evydence/client_test.go @@ -0,0 +1,301 @@ +package evydence + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestPostSendsAuthenticatedIdempotentRequest(t *testing.T) { + var saw bool + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + saw = true + if r.Method != http.MethodPost || r.URL.Path != "/v1/evidence" { + t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) + } + if got := r.Header.Get("Authorization"); got != "Bearer evy_secret" { + t.Fatalf("authorization header = %q", got) + } + if got := r.Header.Get("Idempotency-Key"); got != "idem-1" { + t.Fatalf("idempotency header = %q", got) + } + if got := r.Header.Get("Content-Type"); got != "application/json" { + t.Fatalf("content-type = %q", got) + } + var body map[string]string + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode body: %v", err) + } + if body["title"] != "Build" { + t.Fatalf("body = %#v", body) + } + _, _ = w.Write([]byte(`{"data":{"id":"ev_1"}}`)) + })) + defer server.Close() + + var out struct { + Data struct { + ID string `json:"id"` + } `json:"data"` + } + client := Client{BaseURL: server.URL + "/", APIKey: " evy_secret ", HTTP: server.Client()} + if err := client.Post(context.Background(), "/v1/evidence", "idem-1", map[string]string{"title": "Build"}, &out); err != nil { + t.Fatalf("post: %v", err) + } + if !saw || out.Data.ID != "ev_1" { + t.Fatalf("saw=%v out=%#v", saw, out) + } +} + +func TestPostRejectsInvalidInputsBeforeNetwork(t *testing.T) { + called := false + server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + called = true + })) + defer server.Close() + + client := Client{BaseURL: server.URL, APIKey: "secret", HTTP: server.Client()} + if err := client.Post(context.Background(), "/evidence", "idem-1", map[string]string{}, nil); err == nil { + t.Fatal("expected invalid path error") + } + if err := client.Post(context.Background(), "/v1/evidence", " ", map[string]string{}, nil); err == nil { + t.Fatal("expected invalid idempotency key error") + } + if called { + t.Fatal("invalid client input should not make a network request") + } +} + +func TestPostReturnsSafeStatusError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "secret body should not be returned", http.StatusForbidden) + })) + defer server.Close() + + err := Client{BaseURL: server.URL, APIKey: "secret", HTTP: server.Client()}. + Post(context.Background(), "/v1/evidence", "idem-1", map[string]string{"title": "Build"}, nil) + if err == nil { + t.Fatal("expected status error") + } + if !strings.Contains(err.Error(), "status 403") || strings.Contains(err.Error(), "secret body") { + t.Fatalf("unsafe or unexpected error: %v", err) + } +} + +func TestReleaseLedgerTypedHelpersUseContractRoutes(t *testing.T) { + seen := map[string]bool{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + route := r.Method + " " + r.URL.Path + seen[route] = true + if got := r.Header.Get("Authorization"); got != "Bearer secret" { + t.Fatalf("authorization header = %q", got) + } + if got := r.Header.Get("Idempotency-Key"); got != "idem-typed" { + t.Fatalf("idempotency header = %q", got) + } + + switch route { + case "POST /v1/products": + var body CreateProductRequest + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode product: %v", err) + } + if body.Name != "API" || body.Slug != "api" { + t.Fatalf("product body = %#v", body) + } + case "POST /v1/releases": + var body CreateReleaseRequest + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode release: %v", err) + } + if body.ProductID != "prod_1" || body.ProjectID != "proj_1" || body.Version != "1.0.0" { + t.Fatalf("release body = %#v", body) + } + case "POST /v1/artifacts": + var body RegisterArtifactRequest + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode artifact: %v", err) + } + if body.ReleaseID != "rel_1" || body.Digest != "sha256:abc" || body.Size != 42 { + t.Fatalf("artifact body = %#v", body) + } + case "POST /v1/builds": + var body CreateBuildRequest + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode build: %v", err) + } + if body.ProjectID != "proj_1" || body.ReleaseID != "rel_1" || body.Provider != "github_actions" || body.CommitSHA != "0123456789abcdef0123456789abcdef01234567" || body.Status != "passed" || len(body.Outputs) != 1 { + t.Fatalf("build body = %#v", body) + } + default: + t.Fatalf("unexpected route %s", route) + } + _, _ = w.Write([]byte(`{"data":{"ok":true}}`)) + })) + defer server.Close() + + client := Client{BaseURL: server.URL, APIKey: "secret", HTTP: server.Client()} + calls := []struct { + name string + run func() error + }{ + { + name: "product", + run: func() error { + return client.CreateProduct(context.Background(), "idem-typed", CreateProductRequest{Name: "API", Slug: "api"}, nil) + }, + }, + { + name: "release", + run: func() error { + return client.CreateRelease(context.Background(), "idem-typed", CreateReleaseRequest{ProductID: "prod_1", ProjectID: "proj_1", Version: "1.0.0"}, nil) + }, + }, + { + name: "artifact", + run: func() error { + return client.RegisterArtifact(context.Background(), "idem-typed", RegisterArtifactRequest{ReleaseID: "rel_1", Digest: "sha256:abc", Size: 42}, nil) + }, + }, + { + name: "build", + run: func() error { + return client.CreateBuild(context.Background(), "idem-typed", CreateBuildRequest{ + ProjectID: "proj_1", + ReleaseID: "rel_1", + Provider: "github_actions", + CommitSHA: "0123456789abcdef0123456789abcdef01234567", + Status: "passed", + StartedAt: "2026-05-28T10:00:00Z", + Outputs: []BuildOutput{{Digest: "sha256:abc"}}, + }, nil) + }, + }, + } + for _, call := range calls { + t.Run(call.name, func(t *testing.T) { + if err := call.run(); err != nil { + t.Fatalf("call failed: %v", err) + } + }) + } + for _, route := range []string{"POST /v1/products", "POST /v1/releases", "POST /v1/artifacts", "POST /v1/builds"} { + if !seen[route] { + t.Fatalf("missing route %s", route) + } + } +} + +func TestReadinessHelpersUseGetAndSafeErrors(t *testing.T) { + const secretBody = "token=secret" + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Fatalf("method = %s", r.Method) + } + switch r.URL.Path { + case "/v1/ready": + if got := r.Header.Get("Authorization"); got != "" { + t.Fatalf("public readiness should not send blank API key authorization header: %q", got) + } + _, _ = w.Write([]byte(`{"status":"ok"}`)) + case "/v1/reports/release-readiness": + if got := r.Header.Get("Authorization"); got != "Bearer secret" { + t.Fatalf("authorization header = %q", got) + } + if got := r.URL.Query().Get("release_id"); got != "rel 1+#" { + t.Fatalf("release_id query = %q", got) + } + http.Error(w, secretBody, http.StatusForbidden) + default: + t.Fatalf("unexpected path %s", r.URL.String()) + } + })) + defer server.Close() + + var ready map[string]string + if err := (Client{BaseURL: server.URL, HTTP: server.Client()}).Readiness(context.Background(), &ready); err != nil { + t.Fatalf("readiness: %v", err) + } + if ready["status"] != "ok" { + t.Fatalf("ready = %#v", ready) + } + + err := (Client{BaseURL: server.URL, APIKey: " secret ", HTTP: server.Client()}). + ReleaseReadiness(context.Background(), "rel 1+#", nil) + if err == nil { + t.Fatal("expected status error") + } + if !strings.Contains(err.Error(), "status 403") || strings.Contains(err.Error(), secretBody) { + t.Fatalf("unsafe or unexpected error: %v", err) + } +} + +func TestCreateSSOProviderUsesTypedRoute(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/v1/sso/providers" { + t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) + } + if got := r.Header.Get("Idempotency-Key"); got != "sso-provider-1" { + t.Fatalf("idempotency header = %q", got) + } + var body CreateSSOProviderRequest + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode body: %v", err) + } + if body.Name != "Okta" || body.Type != "oidc" || body.Issuer != "https://idp.example.test" || body.ClientID != "client" { + t.Fatalf("body = %#v", body) + } + _, _ = w.Write([]byte(`{"data":{"id":"sso_1","name":"Okta"}}`)) + })) + defer server.Close() + + var out map[string]any + err := Client{BaseURL: server.URL, APIKey: "secret", HTTP: server.Client()}. + CreateSSOProvider(context.Background(), "sso-provider-1", CreateSSOProviderRequest{ + Name: "Okta", + Type: "oidc", + Issuer: "https://idp.example.test", + ClientID: "client", + }, &out) + if err != nil { + t.Fatalf("create sso provider: %v", err) + } + if out["data"] == nil { + t.Fatalf("out = %#v", out) + } +} + +func TestVerifyProviderIdentityUsesTypedRouteAndSafeErrors(t *testing.T) { + const secretToken = "header.payload.signature" + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/v1/provider-verifications" { + t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) + } + var body VerifyProviderIdentityRequest + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode body: %v", err) + } + if body.ProviderID != "sso_1" || body.ProviderType != "oidc" || body.Subject != "sub-1" || body.IDToken != secretToken { + t.Fatalf("body = %#v", body) + } + http.Error(w, secretToken, http.StatusUnprocessableEntity) + })) + defer server.Close() + + err := Client{BaseURL: server.URL, APIKey: "secret", HTTP: server.Client()}. + VerifyProviderIdentity(context.Background(), "verify-1", VerifyProviderIdentityRequest{ + ProviderType: "oidc", + ProviderID: "sso_1", + Subject: "sub-1", + IDToken: secretToken, + }, nil) + if err == nil { + t.Fatal("expected verification error") + } + if !strings.Contains(err.Error(), "status 422") || strings.Contains(err.Error(), secretToken) { + t.Fatalf("unsafe or unexpected error: %v", err) + } +} diff --git a/sdk/openapi-route-catalog.json b/sdk/openapi-route-catalog.json new file mode 100644 index 0000000..44567f0 --- /dev/null +++ b/sdk/openapi-route-catalog.json @@ -0,0 +1,3139 @@ +{ + "route_count": 186, + "routes": [ + { + "idempotency_key_required": false, + "method": "GET", + "operation_id": "instanceAdminSnapshot", + "path": "/v1/admin/instance", + "request_schema": "", + "response_schemas": [ + "#/components/schemas/InstanceAdminSnapshotEnvelope", + "#/components/schemas/Problem" + ], + "scopes": [ + "instance:admin" + ], + "success_statuses": [ + "200" + ] + }, + { + "idempotency_key_required": false, + "method": "GET", + "operation_id": "listAPIKeys", + "path": "/v1/api-keys", + "request_schema": "", + "response_schemas": [ + "#/components/schemas/APIKeyListEnvelope", + "#/components/schemas/Problem" + ], + "scopes": [ + "admin" + ], + "success_statuses": [ + "200" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "createAPIKey", + "path": "/v1/api-keys", + "request_schema": "#/components/schemas/CreateAPIKeyRequest", + "response_schemas": [ + "#/components/schemas/APIKeyCreateEnvelope", + "#/components/schemas/Problem" + ], + "scopes": [ + "admin" + ], + "success_statuses": [ + "201" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "uploadAPISecurityScan", + "path": "/v1/api-security-scans", + "request_schema": "#/components/schemas/UploadSecurityScanRequest", + "response_schemas": [ + "#/components/schemas/Problem", + "#/components/schemas/SecurityScanEnvelope" + ], + "scopes": [ + "security:write" + ], + "success_statuses": [ + "201" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "createApproval", + "path": "/v1/approvals", + "request_schema": "#/components/schemas/CreateApprovalRequest", + "response_schemas": [ + "#/components/schemas/ApprovalRecordEnvelope", + "#/components/schemas/Problem" + ], + "scopes": [ + "release:write" + ], + "success_statuses": [ + "201" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "createArtifactSignature", + "path": "/v1/artifact-signatures", + "request_schema": "#/components/schemas/CreateArtifactSignatureRequest", + "response_schemas": [ + "#/components/schemas/ArtifactSignatureEnvelope", + "#/components/schemas/Problem" + ], + "scopes": [ + "evidence:write" + ], + "success_statuses": [ + "201" + ] + }, + { + "idempotency_key_required": false, + "method": "GET", + "operation_id": "getArtifactSignature", + "path": "/v1/artifact-signatures/{id}", + "request_schema": "", + "response_schemas": [ + "#/components/schemas/ArtifactSignatureEnvelope", + "#/components/schemas/Problem" + ], + "scopes": [ + "evidence:read" + ], + "success_statuses": [ + "200" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "verifyCosignSignature", + "path": "/v1/artifact-signatures/{id}/verify-cosign", + "request_schema": "#/components/schemas/VerifyCosignSignatureRequest", + "response_schemas": [ + "#/components/schemas/CosignVerificationEnvelope", + "#/components/schemas/Problem" + ], + "scopes": [ + "verify:read" + ], + "success_statuses": [ + "200" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "registerArtifact", + "path": "/v1/artifacts", + "request_schema": "#/components/schemas/RegisterArtifactRequest", + "response_schemas": [ + "#/components/schemas/ArtifactEnvelope", + "#/components/schemas/Problem" + ], + "scopes": [ + "evidence:write" + ], + "success_statuses": [ + "201" + ] + }, + { + "idempotency_key_required": false, + "method": "GET", + "operation_id": "getArtifact", + "path": "/v1/artifacts/{id}", + "request_schema": "", + "response_schemas": [ + "#/components/schemas/ArtifactEnvelope", + "#/components/schemas/Problem" + ], + "scopes": [ + "evidence:read" + ], + "success_statuses": [ + "200" + ] + }, + { + "idempotency_key_required": false, + "method": "GET", + "operation_id": "verifyAuditChain", + "path": "/v1/audit-chain/verify", + "request_schema": "", + "response_schemas": [ + "#/components/schemas/Problem", + "#/components/schemas/VerificationResultEnvelope" + ], + "scopes": [ + "verify:read" + ], + "success_statuses": [ + "200" + ] + }, + { + "idempotency_key_required": false, + "method": "GET", + "operation_id": "listAuditLog", + "path": "/v1/audit-log", + "request_schema": "", + "response_schemas": [ + "#/components/schemas/AuditChainEntryListEnvelope", + "#/components/schemas/Problem" + ], + "scopes": [ + "admin" + ], + "success_statuses": [ + "200" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "generateBackupManifest", + "path": "/v1/backup-manifests", + "request_schema": "#/components/schemas/EmptyObject", + "response_schemas": [ + "#/components/schemas/BackupManifestEnvelope", + "#/components/schemas/Problem" + ], + "scopes": [ + "admin" + ], + "success_statuses": [ + "201" + ] + }, + { + "idempotency_key_required": false, + "method": "GET", + "operation_id": "verifyBackupManifest", + "path": "/v1/backup-manifests/{id}/verify", + "request_schema": "", + "response_schemas": [ + "#/components/schemas/Problem", + "#/components/schemas/VerificationResultEnvelope" + ], + "scopes": [ + "verify:read" + ], + "success_statuses": [ + "200" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "verifyBuildAttestationSignature", + "path": "/v1/build-attestations/{id}/verify-signature", + "request_schema": "#/components/schemas/EmptyObject", + "response_schemas": [ + "#/components/schemas/Problem", + "#/components/schemas/VerificationResultEnvelope" + ], + "scopes": [ + "verify:read" + ], + "success_statuses": [ + "200" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "createBuild", + "path": "/v1/builds", + "request_schema": "#/components/schemas/CreateBuildRequest", + "response_schemas": [ + "#/components/schemas/BuildRunEnvelope", + "#/components/schemas/Problem" + ], + "scopes": [ + "build:write" + ], + "success_statuses": [ + "201" + ] + }, + { + "idempotency_key_required": false, + "method": "GET", + "operation_id": "getBuild", + "path": "/v1/builds/{id}", + "request_schema": "", + "response_schemas": [ + "#/components/schemas/BuildRunEnvelope", + "#/components/schemas/Problem" + ], + "scopes": [ + "build:read" + ], + "success_statuses": [ + "200" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "uploadBuildAttestation", + "path": "/v1/builds/{id}/attestations", + "request_schema": "#/components/schemas/DSSEEnvelope", + "response_schemas": [ + "#/components/schemas/BuildAttestationEnvelope", + "#/components/schemas/Problem" + ], + "scopes": [ + "build:write" + ], + "success_statuses": [ + "201" + ] + }, + { + "idempotency_key_required": false, + "method": "GET", + "operation_id": "listCollectors", + "path": "/v1/collectors", + "request_schema": "", + "response_schemas": [ + "#/components/schemas/CollectorListEnvelope", + "#/components/schemas/Problem" + ], + "scopes": [ + "collector:read" + ], + "success_statuses": [ + "200" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "createCollector", + "path": "/v1/collectors", + "request_schema": "#/components/schemas/CreateCollectorRequest", + "response_schemas": [ + "#/components/schemas/CollectorCreateEnvelope", + "#/components/schemas/Problem" + ], + "scopes": [ + "collector:admin" + ], + "success_statuses": [ + "201" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "uploadGitHubSourceSnapshot", + "path": "/v1/collectors/github/source-snapshots", + "request_schema": "#/components/schemas/SourceSnapshotRequest", + "response_schemas": [ + "#/components/schemas/Problem", + "#/components/schemas/SourceSnapshotEnvelope" + ], + "scopes": [ + "source:write" + ], + "success_statuses": [ + "201" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "uploadGitLabSourceSnapshot", + "path": "/v1/collectors/gitlab/source-snapshots", + "request_schema": "#/components/schemas/SourceSnapshotRequest", + "response_schemas": [ + "#/components/schemas/Problem", + "#/components/schemas/SourceSnapshotEnvelope" + ], + "scopes": [ + "source:write" + ], + "success_statuses": [ + "201" + ] + }, + { + "idempotency_key_required": false, + "method": "GET", + "operation_id": "collectorHealthReport", + "path": "/v1/collectors/{id}/health", + "request_schema": "", + "response_schemas": [ + "#/components/schemas/CollectorHealthReportEnvelope", + "#/components/schemas/Problem" + ], + "scopes": [ + "collector:read" + ], + "success_statuses": [ + "200" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "recordCollectorRelease", + "path": "/v1/collectors/{id}/releases", + "request_schema": "#/components/schemas/RecordCollectorReleaseRequest", + "response_schemas": [ + "#/components/schemas/CollectorReleaseEnvelope", + "#/components/schemas/Problem" + ], + "scopes": [ + "collector:admin" + ], + "success_statuses": [ + "201" + ] + }, + { + "idempotency_key_required": false, + "method": "GET", + "operation_id": "listCommercialCollectors", + "path": "/v1/commercial-collectors", + "request_schema": "", + "response_schemas": [ + "#/components/schemas/CommercialCollectorDefinitionListEnvelope", + "#/components/schemas/Problem" + ], + "scopes": [ + "collector:read" + ], + "success_statuses": [ + "200" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "createCommercialCollector", + "path": "/v1/commercial-collectors", + "request_schema": "#/components/schemas/CreateCommercialCollectorRequest", + "response_schemas": [ + "#/components/schemas/CommercialCollectorDefinitionEnvelope", + "#/components/schemas/Problem" + ], + "scopes": [ + "collector:admin" + ], + "success_statuses": [ + "201" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "registerContainerImage", + "path": "/v1/container-images", + "request_schema": "#/components/schemas/RegisterContainerImageRequest", + "response_schemas": [ + "#/components/schemas/ContainerImageEnvelope", + "#/components/schemas/Problem" + ], + "scopes": [ + "evidence:write" + ], + "success_statuses": [ + "201" + ] + }, + { + "idempotency_key_required": false, + "method": "GET", + "operation_id": "listControlEvidence", + "path": "/v1/control-evidence", + "request_schema": "", + "response_schemas": [ + "#/components/schemas/ControlEvidenceListEnvelope", + "#/components/schemas/Problem" + ], + "scopes": [ + "controls:read" + ], + "success_statuses": [ + "200" + ] + }, + { + "idempotency_key_required": false, + "method": "GET", + "operation_id": "listControlFrameworkTemplatePacks", + "path": "/v1/control-framework-template-packs", + "request_schema": "", + "response_schemas": [ + "#/components/schemas/ControlFrameworkTemplatePackListEnvelope", + "#/components/schemas/Problem" + ], + "scopes": [ + "controls:read" + ], + "success_statuses": [ + "200" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "installControlFrameworkTemplatePack", + "path": "/v1/control-framework-template-packs/{slug}/install", + "request_schema": "#/components/schemas/EmptyObject", + "response_schemas": [ + "#/components/schemas/ControlFrameworkEnvelope", + "#/components/schemas/Problem" + ], + "scopes": [ + "controls:admin" + ], + "success_statuses": [ + "201" + ] + }, + { + "idempotency_key_required": false, + "method": "GET", + "operation_id": "listControlFrameworks", + "path": "/v1/control-frameworks", + "request_schema": "", + "response_schemas": [ + "#/components/schemas/ControlFrameworkListEnvelope", + "#/components/schemas/Problem" + ], + "scopes": [ + "controls:read" + ], + "success_statuses": [ + "200" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "createControlFramework", + "path": "/v1/control-frameworks", + "request_schema": "#/components/schemas/CreateControlFrameworkRequest", + "response_schemas": [ + "#/components/schemas/ControlFrameworkEnvelope", + "#/components/schemas/Problem" + ], + "scopes": [ + "controls:admin" + ], + "success_statuses": [ + "201" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "createSecurityControl", + "path": "/v1/controls", + "request_schema": "#/components/schemas/CreateSecurityControlRequest", + "response_schemas": [ + "#/components/schemas/Problem", + "#/components/schemas/SecurityControlEnvelope" + ], + "scopes": [ + "controls:admin" + ], + "success_statuses": [ + "201" + ] + }, + { + "idempotency_key_required": false, + "method": "GET", + "operation_id": "getSecurityControl", + "path": "/v1/controls/{id}", + "request_schema": "", + "response_schemas": [ + "#/components/schemas/Problem", + "#/components/schemas/SecurityControlEnvelope" + ], + "scopes": [ + "controls:read" + ], + "success_statuses": [ + "200" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "linkControlEvidence", + "path": "/v1/controls/{id}/evidence", + "request_schema": "#/components/schemas/LinkControlEvidenceRequest", + "response_schemas": [ + "#/components/schemas/ControlEvidenceEnvelope", + "#/components/schemas/Problem" + ], + "scopes": [ + "controls:write" + ], + "success_statuses": [ + "201" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "createCustomPolicy", + "path": "/v1/custom-policies", + "request_schema": "#/components/schemas/CreateCustomPolicyRequest", + "response_schemas": [ + "#/components/schemas/CustomPolicyEnvelope", + "#/components/schemas/Problem" + ], + "scopes": [ + "policy:write" + ], + "success_statuses": [ + "201" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "evaluateCustomPolicy", + "path": "/v1/custom-policies/{id}/evaluate", + "request_schema": "#/components/schemas/EvaluatePolicyRequest", + "response_schemas": [ + "#/components/schemas/CustomPolicyEvaluationEnvelope", + "#/components/schemas/Problem" + ], + "scopes": [ + "policy:read" + ], + "success_statuses": [ + "201" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "createCustomerPackage", + "path": "/v1/customer-packages", + "request_schema": "#/components/schemas/CreateCustomerPackageRequest", + "response_schemas": [ + "#/components/schemas/CustomerSecurityPackageEnvelope", + "#/components/schemas/Problem" + ], + "scopes": [ + "package:write" + ], + "success_statuses": [ + "201" + ] + }, + { + "idempotency_key_required": false, + "method": "GET", + "operation_id": "getCustomerPackage", + "path": "/v1/customer-packages/{id}", + "request_schema": "", + "response_schemas": [ + "#/components/schemas/CustomerSecurityPackageEnvelope", + "#/components/schemas/Problem" + ], + "scopes": [ + "package:read" + ], + "success_statuses": [ + "200" + ] + }, + { + "idempotency_key_required": false, + "method": "GET", + "operation_id": "downloadCustomerPackage", + "path": "/v1/customer-packages/{id}/download", + "request_schema": "", + "response_schemas": [ + "#/components/schemas/Problem" + ], + "scopes": [ + "package:read" + ], + "success_statuses": [ + "200" + ] + }, + { + "idempotency_key_required": false, + "method": "GET", + "operation_id": "listCustomerPortalAccess", + "path": "/v1/customer-portal/access", + "request_schema": "", + "response_schemas": [ + "#/components/schemas/CustomerPortalAccessListEnvelope", + "#/components/schemas/Problem" + ], + "scopes": [ + "package:read" + ], + "success_statuses": [ + "200" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "createCustomerPortalAccess", + "path": "/v1/customer-portal/access", + "request_schema": "#/components/schemas/CreateCustomerPortalAccessRequest", + "response_schemas": [ + "#/components/schemas/CustomerPortalAccessCreateEnvelope", + "#/components/schemas/Problem" + ], + "scopes": [ + "package:write" + ], + "success_statuses": [ + "201" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "revokeCustomerPortalAccess", + "path": "/v1/customer-portal/access/{id}/revoke", + "request_schema": "#/components/schemas/EmptyObject", + "response_schemas": [ + "#/components/schemas/CustomerPortalAccessEnvelope", + "#/components/schemas/Problem" + ], + "scopes": [ + "package:write" + ], + "success_statuses": [ + "200" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "accessCustomerPortalPackage", + "path": "/v1/customer-portal/package", + "request_schema": "#/components/schemas/CustomerPortalPackageRequest", + "response_schemas": [ + "#/components/schemas/CustomerSecurityPackageEnvelope", + "#/components/schemas/Problem" + ], + "scopes": [], + "success_statuses": [ + "200" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "downloadCustomerPortalPackage", + "path": "/v1/customer-portal/package/download", + "request_schema": "#/components/schemas/CustomerPortalPackageRequest", + "response_schemas": [ + "#/components/schemas/Problem" + ], + "scopes": [], + "success_statuses": [ + "200" + ] + }, + { + "idempotency_key_required": false, + "method": "GET", + "operation_id": "customerPortalPackageViewForm", + "path": "/v1/customer-portal/package/view", + "request_schema": "", + "response_schemas": [ + "#/components/schemas/Problem" + ], + "scopes": [], + "success_statuses": [ + "200" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "customerPortalPackageView", + "path": "/v1/customer-portal/package/view", + "request_schema": "", + "response_schemas": [ + "#/components/schemas/Problem" + ], + "scopes": [], + "success_statuses": [ + "200" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "downloadCustomerPortalPackageView", + "path": "/v1/customer-portal/package/view/download", + "request_schema": "", + "response_schemas": [ + "#/components/schemas/Problem" + ], + "scopes": [], + "success_statuses": [ + "200" + ] + }, + { + "idempotency_key_required": false, + "method": "GET", + "operation_id": "listDeployments", + "path": "/v1/deployments", + "request_schema": "", + "response_schemas": [ + "#/components/schemas/DeploymentEventListEnvelope", + "#/components/schemas/Problem" + ], + "scopes": [ + "deployment:read" + ], + "success_statuses": [ + "200" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "recordDeployment", + "path": "/v1/deployments", + "request_schema": "#/components/schemas/RecordDeploymentRequest", + "response_schemas": [ + "#/components/schemas/DeploymentEventEnvelope", + "#/components/schemas/Problem" + ], + "scopes": [ + "deployment:write" + ], + "success_statuses": [ + "201" + ] + }, + { + "idempotency_key_required": false, + "method": "GET", + "operation_id": "getDeployment", + "path": "/v1/deployments/{id}", + "request_schema": "", + "response_schemas": [ + "#/components/schemas/DeploymentEventEnvelope", + "#/components/schemas/Problem" + ], + "scopes": [ + "deployment:read" + ], + "success_statuses": [ + "200" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "createDSSETrustRoot", + "path": "/v1/dsse-trust-roots", + "request_schema": "#/components/schemas/CreateDSSETrustRootRequest", + "response_schemas": [ + "#/components/schemas/DSSETrustRootEnvelope", + "#/components/schemas/Problem" + ], + "scopes": [ + "keys:admin" + ], + "success_statuses": [ + "201" + ] + }, + { + "idempotency_key_required": false, + "method": "GET", + "operation_id": "listDeploymentEnvironments", + "path": "/v1/environments", + "request_schema": "", + "response_schemas": [ + "#/components/schemas/DeploymentEnvironmentListEnvelope", + "#/components/schemas/Problem" + ], + "scopes": [ + "deployment:read" + ], + "success_statuses": [ + "200" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "createDeploymentEnvironment", + "path": "/v1/environments", + "request_schema": "#/components/schemas/CreateDeploymentEnvironmentRequest", + "response_schemas": [ + "#/components/schemas/DeploymentEnvironmentEnvelope", + "#/components/schemas/Problem" + ], + "scopes": [ + "deployment:write" + ], + "success_statuses": [ + "201" + ] + }, + { + "idempotency_key_required": false, + "method": "GET", + "operation_id": "listEvidence", + "path": "/v1/evidence", + "request_schema": "", + "response_schemas": [ + "#/components/schemas/EvidenceItemListEnvelope", + "#/components/schemas/Problem" + ], + "scopes": [ + "evidence:read" + ], + "success_statuses": [ + "200" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "createEvidence", + "path": "/v1/evidence", + "request_schema": "#/components/schemas/CreateEvidenceRequest", + "response_schemas": [ + "#/components/schemas/EvidenceItemEnvelope", + "#/components/schemas/Problem" + ], + "scopes": [ + "evidence:write" + ], + "success_statuses": [ + "201" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "exportEvidenceBundle", + "path": "/v1/evidence-bundles", + "request_schema": "#/components/schemas/ExportEvidenceBundleRequest", + "response_schemas": [ + "#/components/schemas/EvidenceBundleEnvelope", + "#/components/schemas/Problem" + ], + "scopes": [ + "bundle:read" + ], + "success_statuses": [ + "201" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "importEvidenceBundle", + "path": "/v1/evidence-bundles/import", + "request_schema": "#/components/schemas/EvidenceBundle", + "response_schemas": [ + "#/components/schemas/EvidenceBundleImportEnvelope", + "#/components/schemas/Problem" + ], + "scopes": [ + "bundle:write" + ], + "success_statuses": [ + "201" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "createGraphSnapshot", + "path": "/v1/evidence-graph-snapshots", + "request_schema": "#/components/schemas/CreateGraphSnapshotRequest", + "response_schemas": [ + "#/components/schemas/EvidenceGraphSnapshotEnvelope", + "#/components/schemas/Problem" + ], + "scopes": [ + "evidence:read" + ], + "success_statuses": [ + "201" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "createEvidenceSummary", + "path": "/v1/evidence-summaries", + "request_schema": "#/components/schemas/CreateEvidenceSummaryRequest", + "response_schemas": [ + "#/components/schemas/EvidenceSummaryEnvelope", + "#/components/schemas/Problem" + ], + "scopes": [ + "report:read" + ], + "success_statuses": [ + "201" + ] + }, + { + "idempotency_key_required": false, + "method": "GET", + "operation_id": "searchEvidence", + "path": "/v1/evidence/search", + "request_schema": "", + "response_schemas": [ + "#/components/schemas/EvidenceSearchEnvelope", + "#/components/schemas/Problem" + ], + "scopes": [ + "evidence:read" + ], + "success_statuses": [ + "200" + ] + }, + { + "idempotency_key_required": false, + "method": "GET", + "operation_id": "getEvidence", + "path": "/v1/evidence/{id}", + "request_schema": "", + "response_schemas": [ + "#/components/schemas/EvidenceItemEnvelope", + "#/components/schemas/Problem" + ], + "scopes": [ + "evidence:read" + ], + "success_statuses": [ + "200" + ] + }, + { + "idempotency_key_required": false, + "method": "GET", + "operation_id": "listEvidenceLifecycleEvents", + "path": "/v1/evidence/{id}/lifecycle-events", + "request_schema": "", + "response_schemas": [ + "#/components/schemas/EvidenceLifecycleEventListEnvelope", + "#/components/schemas/Problem" + ], + "scopes": [ + "evidence:read" + ], + "success_statuses": [ + "200" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "recordEvidenceLifecycleEvent", + "path": "/v1/evidence/{id}/lifecycle-events", + "request_schema": "#/components/schemas/RecordEvidenceLifecycleEventRequest", + "response_schemas": [ + "#/components/schemas/EvidenceLifecycleEventEnvelope", + "#/components/schemas/Problem" + ], + "scopes": [ + "evidence:write" + ], + "success_statuses": [ + "201" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "linkEvidence", + "path": "/v1/evidence/{id}/link", + "request_schema": "#/components/schemas/LinkEvidenceRequest", + "response_schemas": [ + "#/components/schemas/EvidenceItemEnvelope", + "#/components/schemas/Problem" + ], + "scopes": [ + "evidence:write" + ], + "success_statuses": [ + "201" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "supersedeEvidence", + "path": "/v1/evidence/{id}/supersede", + "request_schema": "#/components/schemas/SupersedeEvidenceRequest", + "response_schemas": [ + "#/components/schemas/EvidenceItemEnvelope", + "#/components/schemas/Problem" + ], + "scopes": [ + "evidence:write" + ], + "success_statuses": [ + "201" + ] + }, + { + "idempotency_key_required": false, + "method": "GET", + "operation_id": "listExceptions", + "path": "/v1/exceptions", + "request_schema": "", + "response_schemas": [ + "#/components/schemas/ExceptionListEnvelope", + "#/components/schemas/Problem" + ], + "scopes": [ + "verify:read" + ], + "success_statuses": [ + "200" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "createException", + "path": "/v1/exceptions", + "request_schema": "#/components/schemas/CreateExceptionRequest", + "response_schemas": [ + "#/components/schemas/ExceptionEnvelope", + "#/components/schemas/Problem" + ], + "scopes": [ + "release:write" + ], + "success_statuses": [ + "201" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "approveException", + "path": "/v1/exceptions/{id}/approve", + "request_schema": "#/components/schemas/EmptyObject", + "response_schemas": [ + "#/components/schemas/ExceptionEnvelope", + "#/components/schemas/Problem" + ], + "scopes": [ + "release:write" + ], + "success_statuses": [ + "200" + ] + }, + { + "idempotency_key_required": false, + "method": "GET", + "operation_id": "health", + "path": "/v1/health", + "request_schema": "", + "response_schemas": [ + "#/components/schemas/HealthStatusEnvelope", + "#/components/schemas/Problem" + ], + "scopes": [], + "success_statuses": [ + "200" + ] + }, + { + "idempotency_key_required": false, + "method": "POST", + "operation_id": "receiveIncidentWebhook", + "path": "/v1/incident-webhooks/{receiver_id}", + "request_schema": "#/components/schemas/SignedIncidentWebhookPayload", + "response_schemas": [ + "#/components/schemas/IncidentWebhookDeliveryEnvelope", + "#/components/schemas/Problem" + ], + "scopes": [], + "success_statuses": [ + "201" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "createIncident", + "path": "/v1/incidents", + "request_schema": "#/components/schemas/CreateIncidentRequest", + "response_schemas": [ + "#/components/schemas/IncidentEnvelope", + "#/components/schemas/Problem" + ], + "scopes": [ + "incident:write" + ], + "success_statuses": [ + "201" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "recordIncidentTimeline", + "path": "/v1/incidents/{id}/timeline", + "request_schema": "#/components/schemas/RecordIncidentTimelineRequest", + "response_schemas": [ + "#/components/schemas/IncidentTimelineEventEnvelope", + "#/components/schemas/Problem" + ], + "scopes": [ + "incident:write" + ], + "success_statuses": [ + "201" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "createIncidentWebhookReceiver", + "path": "/v1/incidents/{id}/webhook-receivers", + "request_schema": "#/components/schemas/CreateIncidentWebhookReceiverRequest", + "response_schemas": [ + "#/components/schemas/IncidentWebhookReceiverEnvelope", + "#/components/schemas/Problem" + ], + "scopes": [ + "incident:write" + ], + "success_statuses": [ + "201" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "createLegalHold", + "path": "/v1/legal-holds", + "request_schema": "#/components/schemas/CreateLegalHoldRequest", + "response_schemas": [ + "#/components/schemas/LegalHoldEnvelope", + "#/components/schemas/Problem" + ], + "scopes": [ + "admin" + ], + "success_statuses": [ + "201" + ] + }, + { + "idempotency_key_required": false, + "method": "GET", + "operation_id": "listMarketplaceCollectors", + "path": "/v1/marketplace-collectors", + "request_schema": "", + "response_schemas": [ + "#/components/schemas/MarketplaceCollectorListEnvelope", + "#/components/schemas/Problem" + ], + "scopes": [ + "collector:read" + ], + "success_statuses": [ + "200" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "createMarketplaceCollector", + "path": "/v1/marketplace-collectors", + "request_schema": "#/components/schemas/CreateMarketplaceCollectorRequest", + "response_schemas": [ + "#/components/schemas/MarketplaceCollectorEnvelope", + "#/components/schemas/Problem" + ], + "scopes": [ + "collector:admin" + ], + "success_statuses": [ + "201" + ] + }, + { + "idempotency_key_required": false, + "method": "GET", + "operation_id": "marketplaceCollectorHealth", + "path": "/v1/marketplace-collectors/{id}/health", + "request_schema": "", + "response_schemas": [ + "#/components/schemas/MarketplaceCollectorHealthReportEnvelope", + "#/components/schemas/Problem" + ], + "scopes": [ + "collector:read" + ], + "success_statuses": [ + "200" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "createMerkleBatch", + "path": "/v1/merkle-batches", + "request_schema": "#/components/schemas/CreateMerkleBatchRequest", + "response_schemas": [ + "#/components/schemas/MerkleBatchEnvelope", + "#/components/schemas/Problem" + ], + "scopes": [ + "keys:admin" + ], + "success_statuses": [ + "201" + ] + }, + { + "idempotency_key_required": false, + "method": "GET", + "operation_id": "verifyMerkleBatch", + "path": "/v1/merkle-batches/{id}/verify", + "request_schema": "", + "response_schemas": [ + "#/components/schemas/Problem", + "#/components/schemas/VerificationResultEnvelope" + ], + "scopes": [ + "verify:read" + ], + "success_statuses": [ + "200" + ] + }, + { + "idempotency_key_required": false, + "method": "GET", + "operation_id": "metrics", + "path": "/v1/metrics", + "request_schema": "", + "response_schemas": [ + "#/components/schemas/MetricsSnapshotEnvelope", + "#/components/schemas/Problem" + ], + "scopes": [ + "admin" + ], + "success_statuses": [ + "200" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "createObjectRetentionPolicy", + "path": "/v1/object-retention-policies", + "request_schema": "#/components/schemas/CreateObjectRetentionPolicyRequest", + "response_schemas": [ + "#/components/schemas/ObjectRetentionPolicyEnvelope", + "#/components/schemas/Problem" + ], + "scopes": [ + "admin" + ], + "success_statuses": [ + "201" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "verifyObjectRetentionPolicy", + "path": "/v1/object-retention-policies/{id}/verify", + "request_schema": "#/components/schemas/EmptyObject", + "response_schemas": [ + "#/components/schemas/ObjectRetentionPolicyEnvelope", + "#/components/schemas/Problem" + ], + "scopes": [ + "verify:read" + ], + "success_statuses": [ + "200" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "uploadOpenAPIContract", + "path": "/v1/openapi-contracts", + "request_schema": "#/components/schemas/UploadOpenAPIContractRequest", + "response_schemas": [ + "#/components/schemas/OpenAPIContractEnvelope", + "#/components/schemas/Problem" + ], + "scopes": [ + "evidence:write" + ], + "success_statuses": [ + "201" + ] + }, + { + "idempotency_key_required": false, + "method": "GET", + "operation_id": "getOpenAPIContract", + "path": "/v1/openapi-contracts/{id}", + "request_schema": "", + "response_schemas": [ + "#/components/schemas/OpenAPIContractEnvelope", + "#/components/schemas/Problem" + ], + "scopes": [ + "evidence:read" + ], + "success_statuses": [ + "200" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "createOpenAPIDiff", + "path": "/v1/openapi-diffs", + "request_schema": "#/components/schemas/CreateOpenAPIDiffRequest", + "response_schemas": [ + "#/components/schemas/ContractDiffEnvelope", + "#/components/schemas/Problem" + ], + "scopes": [ + "evidence:read" + ], + "success_statuses": [ + "201" + ] + }, + { + "idempotency_key_required": false, + "method": "GET", + "operation_id": "openapi", + "path": "/v1/openapi.json", + "request_schema": "", + "response_schemas": [ + "#/components/schemas/OpenAPIDocument", + "#/components/schemas/Problem" + ], + "scopes": [], + "success_statuses": [ + "200" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "createOrganization", + "path": "/v1/organizations", + "request_schema": "#/components/schemas/CreateOrganizationRequest", + "response_schemas": [ + "#/components/schemas/OrganizationEnvelope", + "#/components/schemas/Problem" + ], + "scopes": [ + "identity:admin" + ], + "success_statuses": [ + "201" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "evaluatePolicy", + "path": "/v1/policies/evaluate", + "request_schema": "#/components/schemas/EvaluatePolicyRequest", + "response_schemas": [ + "#/components/schemas/PolicyEvaluationEnvelope", + "#/components/schemas/Problem" + ], + "scopes": [ + "verify:read" + ], + "success_statuses": [ + "201" + ] + }, + { + "idempotency_key_required": false, + "method": "GET", + "operation_id": "listProducts", + "path": "/v1/products", + "request_schema": "", + "response_schemas": [ + "#/components/schemas/Problem", + "#/components/schemas/ProductListEnvelope" + ], + "scopes": [ + "product:read" + ], + "success_statuses": [ + "200" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "createProduct", + "path": "/v1/products", + "request_schema": "#/components/schemas/CreateProductRequest", + "response_schemas": [ + "#/components/schemas/Problem", + "#/components/schemas/ProductEnvelope" + ], + "scopes": [ + "product:write" + ], + "success_statuses": [ + "201" + ] + }, + { + "idempotency_key_required": false, + "method": "GET", + "operation_id": "getProduct", + "path": "/v1/products/{id}", + "request_schema": "", + "response_schemas": [ + "#/components/schemas/Problem", + "#/components/schemas/ProductEnvelope" + ], + "scopes": [ + "product:read" + ], + "success_statuses": [ + "200" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "createProject", + "path": "/v1/projects", + "request_schema": "#/components/schemas/CreateProjectRequest", + "response_schemas": [ + "#/components/schemas/Problem", + "#/components/schemas/ProjectEnvelope" + ], + "scopes": [ + "project:write" + ], + "success_statuses": [ + "201" + ] + }, + { + "idempotency_key_required": false, + "method": "GET", + "operation_id": "getProject", + "path": "/v1/projects/{id}", + "request_schema": "", + "response_schemas": [ + "#/components/schemas/Problem", + "#/components/schemas/ProjectEnvelope" + ], + "scopes": [ + "project:read" + ], + "success_statuses": [ + "200" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "verifyProviderIdentity", + "path": "/v1/provider-verifications", + "request_schema": "#/components/schemas/VerifyProviderIdentityRequest", + "response_schemas": [ + "#/components/schemas/Problem", + "#/components/schemas/ProviderVerificationEnvelope" + ], + "scopes": [ + "identity:admin" + ], + "success_statuses": [ + "201" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "publishPublicTransparencyLogEntry", + "path": "/v1/public-transparency-log-entries", + "request_schema": "#/components/schemas/PublishPublicTransparencyLogEntryRequest", + "response_schemas": [ + "#/components/schemas/Problem", + "#/components/schemas/PublicTransparencyLogEntryEnvelope" + ], + "scopes": [ + "keys:admin" + ], + "success_statuses": [ + "201" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "fetchPublicTransparencyLogEntryProof", + "path": "/v1/public-transparency-log-entries/{id}/fetch-proof", + "request_schema": "#/components/schemas/EmptyObject", + "response_schemas": [ + "#/components/schemas/Problem", + "#/components/schemas/PublicTransparencyLogEntryEnvelope" + ], + "scopes": [ + "keys:admin" + ], + "success_statuses": [ + "200" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "verifyPublicTransparencyLogEntry", + "path": "/v1/public-transparency-log-entries/{id}/verify", + "request_schema": "#/components/schemas/VerifyPublicTransparencyLogEntryRequest", + "response_schemas": [ + "#/components/schemas/Problem", + "#/components/schemas/PublicTransparencyLogEntryEnvelope" + ], + "scopes": [ + "keys:admin" + ], + "success_statuses": [ + "200" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "createPublicTransparencyLog", + "path": "/v1/public-transparency-logs", + "request_schema": "#/components/schemas/CreatePublicTransparencyLogRequest", + "response_schemas": [ + "#/components/schemas/Problem", + "#/components/schemas/PublicTransparencyLogEnvelope" + ], + "scopes": [ + "keys:admin" + ], + "success_statuses": [ + "201" + ] + }, + { + "idempotency_key_required": false, + "method": "GET", + "operation_id": "listQuestionnaireAnswerLibrary", + "path": "/v1/questionnaire-answer-library", + "request_schema": "", + "response_schemas": [ + "#/components/schemas/Problem", + "#/components/schemas/QuestionnaireAnswerLibraryEntryListEnvelope" + ], + "scopes": [ + "package:read" + ], + "success_statuses": [ + "200" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "createQuestionnaireAnswerLibraryEntry", + "path": "/v1/questionnaire-answer-library", + "request_schema": "#/components/schemas/CreateQuestionnaireAnswerLibraryEntryRequest", + "response_schemas": [ + "#/components/schemas/Problem", + "#/components/schemas/QuestionnaireAnswerLibraryEntryEnvelope" + ], + "scopes": [ + "package:write" + ], + "success_statuses": [ + "201" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "createQuestionnaireDraft", + "path": "/v1/questionnaire-drafts", + "request_schema": "#/components/schemas/CreateQuestionnaireDraftRequest", + "response_schemas": [ + "#/components/schemas/Problem", + "#/components/schemas/QuestionnaireDraftEnvelope" + ], + "scopes": [ + "package:read" + ], + "success_statuses": [ + "201" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "createQuestionnairePackage", + "path": "/v1/questionnaire-packages", + "request_schema": "#/components/schemas/CreateQuestionnairePackageRequest", + "response_schemas": [ + "#/components/schemas/Problem", + "#/components/schemas/QuestionnairePackageEnvelope" + ], + "scopes": [ + "package:write" + ], + "success_statuses": [ + "201" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "createQuestionnaireTemplate", + "path": "/v1/questionnaire-templates", + "request_schema": "#/components/schemas/CreateQuestionnaireTemplateRequest", + "response_schemas": [ + "#/components/schemas/Problem", + "#/components/schemas/QuestionnaireTemplateEnvelope" + ], + "scopes": [ + "package:write" + ], + "success_statuses": [ + "201" + ] + }, + { + "idempotency_key_required": false, + "method": "GET", + "operation_id": "ready", + "path": "/v1/ready", + "request_schema": "", + "response_schemas": [ + "#/components/schemas/Problem", + "#/components/schemas/ReadinessStatusEnvelope" + ], + "scopes": [], + "success_statuses": [ + "200" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "createRedactionProfile", + "path": "/v1/redaction-profiles", + "request_schema": "#/components/schemas/CreateRedactionProfileRequest", + "response_schemas": [ + "#/components/schemas/Problem", + "#/components/schemas/RedactionProfileEnvelope" + ], + "scopes": [ + "package:write" + ], + "success_statuses": [ + "201" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "createReleaseBundle", + "path": "/v1/release-bundles", + "request_schema": "#/components/schemas/CreateReleaseBundleRequest", + "response_schemas": [ + "#/components/schemas/Problem", + "#/components/schemas/ReleaseBundleEnvelope" + ], + "scopes": [ + "bundle:write" + ], + "success_statuses": [ + "201" + ] + }, + { + "idempotency_key_required": false, + "method": "GET", + "operation_id": "getReleaseBundle", + "path": "/v1/release-bundles/{id}", + "request_schema": "", + "response_schemas": [ + "#/components/schemas/Problem", + "#/components/schemas/ReleaseBundleEnvelope" + ], + "scopes": [ + "bundle:read" + ], + "success_statuses": [ + "200" + ] + }, + { + "idempotency_key_required": false, + "method": "GET", + "operation_id": "getReleaseBundleManifest", + "path": "/v1/release-bundles/{id}/manifest", + "request_schema": "", + "response_schemas": [ + "#/components/schemas/Problem", + "#/components/schemas/ReleaseBundleManifestEnvelope" + ], + "scopes": [ + "bundle:read" + ], + "success_statuses": [ + "200" + ] + }, + { + "idempotency_key_required": false, + "method": "GET", + "operation_id": "verifyReleaseBundle", + "path": "/v1/release-bundles/{id}/verify", + "request_schema": "", + "response_schemas": [ + "#/components/schemas/Problem", + "#/components/schemas/VerificationResultEnvelope" + ], + "scopes": [ + "verify:read" + ], + "success_statuses": [ + "200" + ] + }, + { + "idempotency_key_required": false, + "method": "GET", + "operation_id": "listReleaseCandidates", + "path": "/v1/release-candidates", + "request_schema": "", + "response_schemas": [ + "#/components/schemas/Problem", + "#/components/schemas/ReleaseCandidateListEnvelope" + ], + "scopes": [ + "release:read" + ], + "success_statuses": [ + "200" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "createReleaseCandidate", + "path": "/v1/release-candidates", + "request_schema": "#/components/schemas/CreateReleaseCandidateRequest", + "response_schemas": [ + "#/components/schemas/Problem", + "#/components/schemas/ReleaseCandidateEnvelope" + ], + "scopes": [ + "release:write" + ], + "success_statuses": [ + "201" + ] + }, + { + "idempotency_key_required": false, + "method": "GET", + "operation_id": "getReleaseCandidate", + "path": "/v1/release-candidates/{id}", + "request_schema": "", + "response_schemas": [ + "#/components/schemas/Problem", + "#/components/schemas/ReleaseCandidateEnvelope" + ], + "scopes": [ + "release:read" + ], + "success_statuses": [ + "200" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "promoteReleaseCandidate", + "path": "/v1/release-candidates/{id}/promote", + "request_schema": "#/components/schemas/ReleaseCandidateTransitionRequest", + "response_schemas": [ + "#/components/schemas/Problem", + "#/components/schemas/ReleaseCandidateEnvelope" + ], + "scopes": [ + "release:write" + ], + "success_statuses": [ + "200" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "rejectReleaseCandidate", + "path": "/v1/release-candidates/{id}/reject", + "request_schema": "#/components/schemas/ReleaseCandidateTransitionRequest", + "response_schemas": [ + "#/components/schemas/Problem", + "#/components/schemas/ReleaseCandidateEnvelope" + ], + "scopes": [ + "release:write" + ], + "success_statuses": [ + "200" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "createRelease", + "path": "/v1/releases", + "request_schema": "#/components/schemas/CreateReleaseRequest", + "response_schemas": [ + "#/components/schemas/Problem", + "#/components/schemas/ReleaseEnvelope" + ], + "scopes": [ + "release:write" + ], + "success_statuses": [ + "201" + ] + }, + { + "idempotency_key_required": false, + "method": "GET", + "operation_id": "getRelease", + "path": "/v1/releases/{id}", + "request_schema": "", + "response_schemas": [ + "#/components/schemas/Problem", + "#/components/schemas/ReleaseEnvelope" + ], + "scopes": [ + "release:read" + ], + "success_statuses": [ + "200" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "approveRelease", + "path": "/v1/releases/{id}/approve", + "request_schema": "#/components/schemas/EmptyObject", + "response_schemas": [ + "#/components/schemas/Problem", + "#/components/schemas/ReleaseEnvelope" + ], + "scopes": [ + "release:write" + ], + "success_statuses": [ + "200" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "startReleaseEvidenceFlow", + "path": "/v1/releases/{id}/evidence-flow/start", + "request_schema": "", + "response_schemas": [ + "#/components/schemas/Problem", + "#/components/schemas/ReleaseEvidenceFlowEnvelope" + ], + "scopes": [ + "release:read" + ], + "success_statuses": [ + "200" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "freezeRelease", + "path": "/v1/releases/{id}/freeze", + "request_schema": "#/components/schemas/EmptyObject", + "response_schemas": [ + "#/components/schemas/Problem", + "#/components/schemas/ReleaseEnvelope" + ], + "scopes": [ + "release:write" + ], + "success_statuses": [ + "200" + ] + }, + { + "idempotency_key_required": false, + "method": "GET", + "operation_id": "releaseSecuritySummary", + "path": "/v1/releases/{id}/security-summary", + "request_schema": "", + "response_schemas": [ + "#/components/schemas/Problem", + "#/components/schemas/ReleaseSecuritySummaryEnvelope" + ], + "scopes": [ + "report:read" + ], + "success_statuses": [ + "200" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "createRemediationTask", + "path": "/v1/remediation-tasks", + "request_schema": "#/components/schemas/CreateRemediationTaskRequest", + "response_schemas": [ + "#/components/schemas/Problem", + "#/components/schemas/RemediationTaskEnvelope" + ], + "scopes": [ + "incident:write" + ], + "success_statuses": [ + "201" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "createReportTemplate", + "path": "/v1/report-templates", + "request_schema": "#/components/schemas/CreateReportTemplateRequest", + "response_schemas": [ + "#/components/schemas/CustomReportTemplateEnvelope", + "#/components/schemas/Problem" + ], + "scopes": [ + "report:read" + ], + "success_statuses": [ + "201" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "renderReportTemplate", + "path": "/v1/report-templates/{id}/render", + "request_schema": "#/components/schemas/RenderReportTemplateRequest", + "response_schemas": [ + "#/components/schemas/Problem", + "#/components/schemas/RenderedCustomReportEnvelope" + ], + "scopes": [ + "report:read" + ], + "success_statuses": [ + "201" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "generateAnomalyReport", + "path": "/v1/reports/anomaly", + "request_schema": "#/components/schemas/CreateAnomalyReportRequest", + "response_schemas": [ + "#/components/schemas/AnomalyReportEnvelope", + "#/components/schemas/Problem" + ], + "scopes": [ + "report:read" + ], + "success_statuses": [ + "201" + ] + }, + { + "idempotency_key_required": false, + "method": "GET", + "operation_id": "controlCoverageReport", + "path": "/v1/reports/control-coverage", + "request_schema": "", + "response_schemas": [ + "#/components/schemas/Problem", + "#/components/schemas/ReadinessReportEnvelope" + ], + "scopes": [ + "report:read" + ], + "success_statuses": [ + "200" + ] + }, + { + "idempotency_key_required": false, + "method": "GET", + "operation_id": "craReadinessReport", + "path": "/v1/reports/cra-readiness", + "request_schema": "", + "response_schemas": [ + "#/components/schemas/Problem", + "#/components/schemas/ReadinessReportEnvelope" + ], + "scopes": [ + "report:read" + ], + "success_statuses": [ + "200" + ] + }, + { + "idempotency_key_required": false, + "method": "GET", + "operation_id": "craReadinessHTMLPackage", + "path": "/v1/reports/cra-readiness-html", + "request_schema": "", + "response_schemas": [ + "#/components/schemas/HTMLReportPackageEnvelope", + "#/components/schemas/Problem" + ], + "scopes": [ + "report:read" + ], + "success_statuses": [ + "200" + ] + }, + { + "idempotency_key_required": false, + "method": "GET", + "operation_id": "craVulnerabilityHandlingReport", + "path": "/v1/reports/cra-vulnerability-handling", + "request_schema": "", + "response_schemas": [ + "#/components/schemas/CRAVulnerabilityHandlingReportEnvelope", + "#/components/schemas/Problem" + ], + "scopes": [ + "report:read" + ], + "success_statuses": [ + "200" + ] + }, + { + "idempotency_key_required": false, + "method": "GET", + "operation_id": "signingCustodyReviewReport", + "path": "/v1/reports/custody-review", + "request_schema": "", + "response_schemas": [ + "#/components/schemas/Problem", + "#/components/schemas/SigningCustodyReviewReportEnvelope" + ], + "scopes": [ + "keys:admin" + ], + "success_statuses": [ + "200" + ] + }, + { + "idempotency_key_required": false, + "method": "GET", + "operation_id": "incidentReport", + "path": "/v1/reports/incident-package", + "request_schema": "", + "response_schemas": [ + "#/components/schemas/IncidentReportEnvelope", + "#/components/schemas/Problem" + ], + "scopes": [ + "incident:read" + ], + "success_statuses": [ + "200" + ] + }, + { + "idempotency_key_required": false, + "method": "GET", + "operation_id": "missingEvidenceReport", + "path": "/v1/reports/missing-evidence", + "request_schema": "", + "response_schemas": [ + "#/components/schemas/MissingEvidenceReportEnvelope", + "#/components/schemas/Problem" + ], + "scopes": [ + "verify:read" + ], + "success_statuses": [ + "200" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "createPDFReportPackage", + "path": "/v1/reports/pdf", + "request_schema": "#/components/schemas/CreatePDFReportPackageRequest", + "response_schemas": [ + "#/components/schemas/PDFReportPackageEnvelope", + "#/components/schemas/Problem" + ], + "scopes": [ + "report:read" + ], + "success_statuses": [ + "201" + ] + }, + { + "idempotency_key_required": false, + "method": "GET", + "operation_id": "releaseReadinessReport", + "path": "/v1/reports/release-readiness", + "request_schema": "", + "response_schemas": [ + "#/components/schemas/Problem", + "#/components/schemas/ReadinessReportEnvelope" + ], + "scopes": [ + "verify:read" + ], + "success_statuses": [ + "200" + ] + }, + { + "idempotency_key_required": false, + "method": "GET", + "operation_id": "retentionReport", + "path": "/v1/reports/retention", + "request_schema": "", + "response_schemas": [ + "#/components/schemas/Problem", + "#/components/schemas/RetentionReportEnvelope" + ], + "scopes": [ + "admin" + ], + "success_statuses": [ + "200" + ] + }, + { + "idempotency_key_required": false, + "method": "GET", + "operation_id": "securityReviewPackageReport", + "path": "/v1/reports/security-review-package", + "request_schema": "", + "response_schemas": [ + "#/components/schemas/Problem", + "#/components/schemas/SecurityReviewPackageReportEnvelope" + ], + "scopes": [ + "package:read" + ], + "success_statuses": [ + "200" + ] + }, + { + "idempotency_key_required": false, + "method": "GET", + "operation_id": "securityUpdateEvidenceReport", + "path": "/v1/reports/security-update-evidence", + "request_schema": "", + "response_schemas": [ + "#/components/schemas/Problem", + "#/components/schemas/SecurityUpdateEvidenceReportEnvelope" + ], + "scopes": [ + "report:read" + ], + "success_statuses": [ + "200" + ] + }, + { + "idempotency_key_required": false, + "method": "GET", + "operation_id": "vulnerabilityDecisionSummaryReport", + "path": "/v1/reports/vulnerability-decision-summary", + "request_schema": "", + "response_schemas": [ + "#/components/schemas/Problem", + "#/components/schemas/VulnerabilityDecisionSummaryReportEnvelope" + ], + "scopes": [ + "report:read" + ], + "success_statuses": [ + "200" + ] + }, + { + "idempotency_key_required": false, + "method": "GET", + "operation_id": "vulnerabilityPostureReport", + "path": "/v1/reports/vulnerability-posture", + "request_schema": "", + "response_schemas": [ + "#/components/schemas/Problem", + "#/components/schemas/VulnerabilityPostureReportEnvelope" + ], + "scopes": [ + "security:read" + ], + "success_statuses": [ + "200" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "createRetentionOverride", + "path": "/v1/retention-overrides", + "request_schema": "#/components/schemas/CreateRetentionOverrideRequest", + "response_schemas": [ + "#/components/schemas/Problem", + "#/components/schemas/RetentionOverrideEnvelope" + ], + "scopes": [ + "admin" + ], + "success_statuses": [ + "201" + ] + }, + { + "idempotency_key_required": false, + "method": "GET", + "operation_id": "listRoleBindings", + "path": "/v1/role-bindings", + "request_schema": "", + "response_schemas": [ + "#/components/schemas/Problem", + "#/components/schemas/RoleBindingListEnvelope" + ], + "scopes": [ + "identity:admin" + ], + "success_statuses": [ + "200" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "createRoleBinding", + "path": "/v1/role-bindings", + "request_schema": "#/components/schemas/CreateRoleBindingRequest", + "response_schemas": [ + "#/components/schemas/Problem", + "#/components/schemas/RoleBindingEnvelope" + ], + "scopes": [ + "identity:admin" + ], + "success_statuses": [ + "201" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "createSaaSEditionProfile", + "path": "/v1/saas/profiles", + "request_schema": "#/components/schemas/CreateSaaSEditionProfileRequest", + "response_schemas": [ + "#/components/schemas/Problem", + "#/components/schemas/SaaSEditionProfileEnvelope" + ], + "scopes": [ + "instance:admin" + ], + "success_statuses": [ + "201" + ] + }, + { + "idempotency_key_required": false, + "method": "GET", + "operation_id": "listSBOMComponents", + "path": "/v1/sbom-components", + "request_schema": "", + "response_schemas": [ + "#/components/schemas/Problem", + "#/components/schemas/SBOMComponentRecordListEnvelope" + ], + "scopes": [ + "evidence:read" + ], + "success_statuses": [ + "200" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "createSBOMDiff", + "path": "/v1/sbom-diffs", + "request_schema": "#/components/schemas/CreateSBOMDiffRequest", + "response_schemas": [ + "#/components/schemas/Problem", + "#/components/schemas/SBOMDiffEnvelope" + ], + "scopes": [ + "evidence:read" + ], + "success_statuses": [ + "201" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "uploadSBOM", + "path": "/v1/sboms", + "request_schema": "#/components/schemas/EvidenceUploadRequest", + "response_schemas": [ + "#/components/schemas/Problem", + "#/components/schemas/SBOMEnvelope" + ], + "scopes": [ + "evidence:write" + ], + "success_statuses": [ + "201" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "uploadSPDXSBOM", + "path": "/v1/sboms/spdx", + "request_schema": "#/components/schemas/UploadSPDXSBOMRequest", + "response_schemas": [ + "#/components/schemas/Problem", + "#/components/schemas/SBOMEnvelope" + ], + "scopes": [ + "evidence:write" + ], + "success_statuses": [ + "201" + ] + }, + { + "idempotency_key_required": false, + "method": "GET", + "operation_id": "getSBOM", + "path": "/v1/sboms/{id}", + "request_schema": "", + "response_schemas": [ + "#/components/schemas/Problem", + "#/components/schemas/SBOMEnvelope" + ], + "scopes": [ + "evidence:read" + ], + "success_statuses": [ + "200" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "uploadManualSecurityDocument", + "path": "/v1/security-documents", + "request_schema": "#/components/schemas/UploadManualSecurityDocumentRequest", + "response_schemas": [ + "#/components/schemas/ManualSecurityDocumentEnvelope", + "#/components/schemas/Problem" + ], + "scopes": [ + "security:write" + ], + "success_statuses": [ + "201" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "uploadSecurityScan", + "path": "/v1/security-scans", + "request_schema": "#/components/schemas/UploadSecurityScanRequest", + "response_schemas": [ + "#/components/schemas/Problem", + "#/components/schemas/SecurityScanEnvelope" + ], + "scopes": [ + "security:write" + ], + "success_statuses": [ + "201" + ] + }, + { + "idempotency_key_required": false, + "method": "GET", + "operation_id": "listSigningKeys", + "path": "/v1/signing-keys", + "request_schema": "", + "response_schemas": [ + "#/components/schemas/Problem", + "#/components/schemas/SigningKeyListEnvelope" + ], + "scopes": [ + "verify:read" + ], + "success_statuses": [ + "200" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "rotateSigningKey", + "path": "/v1/signing-keys/rotate", + "request_schema": "#/components/schemas/SigningKeyTransitionRequest", + "response_schemas": [ + "#/components/schemas/Problem", + "#/components/schemas/SigningKeyEnvelope" + ], + "scopes": [ + "keys:admin" + ], + "success_statuses": [ + "201" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "revokeSigningKey", + "path": "/v1/signing-keys/{id}/revoke", + "request_schema": "#/components/schemas/SigningKeyTransitionRequest", + "response_schemas": [ + "#/components/schemas/Problem", + "#/components/schemas/SigningKeyEnvelope" + ], + "scopes": [ + "keys:admin" + ], + "success_statuses": [ + "200" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "createSigningOperation", + "path": "/v1/signing-operations", + "request_schema": "#/components/schemas/CreateSigningOperationRequest", + "response_schemas": [ + "#/components/schemas/Problem", + "#/components/schemas/SigningOperationEnvelope" + ], + "scopes": [ + "keys:admin" + ], + "success_statuses": [ + "201" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "createSigningProvider", + "path": "/v1/signing-providers", + "request_schema": "#/components/schemas/CreateSigningProviderRequest", + "response_schemas": [ + "#/components/schemas/Problem", + "#/components/schemas/SigningProviderEnvelope" + ], + "scopes": [ + "keys:admin" + ], + "success_statuses": [ + "201" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "upsertSourceBranch", + "path": "/v1/source/branches", + "request_schema": "#/components/schemas/UpsertSourceBranchRequest", + "response_schemas": [ + "#/components/schemas/Problem", + "#/components/schemas/SourceBranchEnvelope" + ], + "scopes": [ + "source:write" + ], + "success_statuses": [ + "201" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "recordSourceCommit", + "path": "/v1/source/commits", + "request_schema": "#/components/schemas/RecordSourceCommitRequest", + "response_schemas": [ + "#/components/schemas/Problem", + "#/components/schemas/SourceCommitEnvelope" + ], + "scopes": [ + "source:write" + ], + "success_statuses": [ + "201" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "recordPullRequest", + "path": "/v1/source/pull-requests", + "request_schema": "#/components/schemas/RecordPullRequestRequest", + "response_schemas": [ + "#/components/schemas/Problem", + "#/components/schemas/PullRequestEnvelope" + ], + "scopes": [ + "source:write" + ], + "success_statuses": [ + "201" + ] + }, + { + "idempotency_key_required": false, + "method": "GET", + "operation_id": "listSourceRepositories", + "path": "/v1/source/repositories", + "request_schema": "", + "response_schemas": [ + "#/components/schemas/Problem", + "#/components/schemas/SourceRepositoryListEnvelope" + ], + "scopes": [ + "source:read" + ], + "success_statuses": [ + "200" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "createSourceRepository", + "path": "/v1/source/repositories", + "request_schema": "#/components/schemas/CreateSourceRepositoryRequest", + "response_schemas": [ + "#/components/schemas/Problem", + "#/components/schemas/SourceRepositoryEnvelope" + ], + "scopes": [ + "source:write" + ], + "success_statuses": [ + "201" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "linkSSOIdentity", + "path": "/v1/sso/identity-links", + "request_schema": "#/components/schemas/LinkSSOIdentityRequest", + "response_schemas": [ + "#/components/schemas/Problem", + "#/components/schemas/UserIdentityLinkEnvelope" + ], + "scopes": [ + "identity:admin" + ], + "success_statuses": [ + "201" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "logoutSSOSession", + "path": "/v1/sso/logout", + "request_schema": "#/components/schemas/EmptyObject", + "response_schemas": [ + "#/components/schemas/Problem", + "#/components/schemas/SSOSessionEnvelope" + ], + "scopes": [], + "success_statuses": [ + "200" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "createSSOProvider", + "path": "/v1/sso/providers", + "request_schema": "#/components/schemas/CreateSSOProviderRequest", + "response_schemas": [ + "#/components/schemas/Problem", + "#/components/schemas/SSOProviderEnvelope" + ], + "scopes": [ + "identity:admin" + ], + "success_statuses": [ + "201" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "refreshSSOProviderOIDCTrustMaterial", + "path": "/v1/sso/providers/{id}/discover-oidc", + "request_schema": "#/components/schemas/EmptyObject", + "response_schemas": [ + "#/components/schemas/Problem", + "#/components/schemas/SSOProviderEnvelope" + ], + "scopes": [ + "identity:admin" + ], + "success_statuses": [ + "200" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "updateSSOProviderTrustMaterial", + "path": "/v1/sso/providers/{id}/trust-material", + "request_schema": "#/components/schemas/UpdateSSOProviderTrustMaterialRequest", + "response_schemas": [ + "#/components/schemas/Problem", + "#/components/schemas/SSOProviderEnvelope" + ], + "scopes": [ + "identity:admin" + ], + "success_statuses": [ + "200" + ] + }, + { + "idempotency_key_required": false, + "method": "POST", + "operation_id": "exchangeSSOCredential", + "path": "/v1/sso/session-exchanges", + "request_schema": "#/components/schemas/ExchangeSSOCredentialRequest", + "response_schemas": [ + "#/components/schemas/Problem", + "#/components/schemas/SSOCredentialExchangeEnvelope" + ], + "scopes": [], + "success_statuses": [ + "201" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "createSSOSession", + "path": "/v1/sso/sessions", + "request_schema": "#/components/schemas/CreateSSOSessionRequest", + "response_schemas": [ + "#/components/schemas/Problem", + "#/components/schemas/SSOSessionCreateEnvelope" + ], + "scopes": [ + "identity:admin" + ], + "success_statuses": [ + "201" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "revokeSSOSession", + "path": "/v1/sso/sessions/{id}/revoke", + "request_schema": "#/components/schemas/EmptyObject", + "response_schemas": [ + "#/components/schemas/Problem", + "#/components/schemas/SSOSessionEnvelope" + ], + "scopes": [ + "identity:admin" + ], + "success_statuses": [ + "200" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "createTransparencyCheckpoint", + "path": "/v1/transparency-checkpoints", + "request_schema": "#/components/schemas/CreateTransparencyCheckpointRequest", + "response_schemas": [ + "#/components/schemas/Problem", + "#/components/schemas/TransparencyCheckpointEnvelope" + ], + "scopes": [ + "keys:admin" + ], + "success_statuses": [ + "201" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "createUser", + "path": "/v1/users", + "request_schema": "#/components/schemas/CreateUserRequest", + "response_schemas": [ + "#/components/schemas/HumanUserEnvelope", + "#/components/schemas/Problem" + ], + "scopes": [ + "identity:admin" + ], + "success_statuses": [ + "201" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "deactivateUser", + "path": "/v1/users/{id}/deactivate", + "request_schema": "#/components/schemas/EmptyObject", + "response_schemas": [ + "#/components/schemas/HumanUserEnvelope", + "#/components/schemas/Problem" + ], + "scopes": [ + "identity:admin" + ], + "success_statuses": [ + "200" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "verify", + "path": "/v1/verify", + "request_schema": "#/components/schemas/VerifySubjectRequest", + "response_schemas": [ + "#/components/schemas/Problem", + "#/components/schemas/VerificationResultEnvelope" + ], + "scopes": [ + "verify:read" + ], + "success_statuses": [ + "200" + ] + }, + { + "idempotency_key_required": false, + "method": "GET", + "operation_id": "version", + "path": "/v1/version", + "request_schema": "", + "response_schemas": [ + "#/components/schemas/Problem", + "#/components/schemas/VersionInfoEnvelope" + ], + "scopes": [], + "success_statuses": [ + "200" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "uploadVEX", + "path": "/v1/vex", + "request_schema": "#/components/schemas/EvidenceUploadRequest", + "response_schemas": [ + "#/components/schemas/Problem", + "#/components/schemas/VEXDocumentEnvelope" + ], + "scopes": [ + "evidence:write" + ], + "success_statuses": [ + "201" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "uploadCycloneDXVEX", + "path": "/v1/vex/cyclonedx", + "request_schema": "#/components/schemas/EvidenceUploadRequest", + "response_schemas": [ + "#/components/schemas/Problem", + "#/components/schemas/VEXDocumentEnvelope" + ], + "scopes": [ + "evidence:write" + ], + "success_statuses": [ + "201" + ] + }, + { + "idempotency_key_required": false, + "method": "POST", + "operation_id": "previewCycloneDXVEXImport", + "path": "/v1/vex/cyclonedx/preview", + "request_schema": "#/components/schemas/EvidenceUploadRequest", + "response_schemas": [ + "#/components/schemas/Problem", + "#/components/schemas/VEXImportPreviewEnvelope" + ], + "scopes": [ + "evidence:read" + ], + "success_statuses": [ + "200" + ] + }, + { + "idempotency_key_required": false, + "method": "POST", + "operation_id": "previewVEXImport", + "path": "/v1/vex/preview", + "request_schema": "#/components/schemas/EvidenceUploadRequest", + "response_schemas": [ + "#/components/schemas/Problem", + "#/components/schemas/VEXImportPreviewEnvelope" + ], + "scopes": [ + "evidence:read" + ], + "success_statuses": [ + "200" + ] + }, + { + "idempotency_key_required": false, + "method": "GET", + "operation_id": "getVEX", + "path": "/v1/vex/{id}", + "request_schema": "", + "response_schemas": [ + "#/components/schemas/Problem", + "#/components/schemas/VEXDocumentEnvelope" + ], + "scopes": [ + "evidence:read" + ], + "success_statuses": [ + "200" + ] + }, + { + "idempotency_key_required": false, + "method": "GET", + "operation_id": "getVEXImportReport", + "path": "/v1/vex/{id}/import-report", + "request_schema": "", + "response_schemas": [ + "#/components/schemas/Problem", + "#/components/schemas/VEXImportReportEnvelope" + ], + "scopes": [ + "evidence:read" + ], + "success_statuses": [ + "200" + ] + }, + { + "idempotency_key_required": false, + "method": "GET", + "operation_id": "listVulnerabilityDecisions", + "path": "/v1/vulnerability-decisions", + "request_schema": "", + "response_schemas": [ + "#/components/schemas/Problem", + "#/components/schemas/VulnerabilityDecisionListEnvelope" + ], + "scopes": [ + "evidence:read" + ], + "success_statuses": [ + "200" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "createVulnerabilityDecision", + "path": "/v1/vulnerability-findings/{id}/decisions", + "request_schema": "#/components/schemas/CreateVulnerabilityDecisionRequest", + "response_schemas": [ + "#/components/schemas/Problem", + "#/components/schemas/VulnerabilityDecisionEnvelope" + ], + "scopes": [ + "evidence:write" + ], + "success_statuses": [ + "201" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "recordVulnerabilityWorkflow", + "path": "/v1/vulnerability-findings/{id}/workflow", + "request_schema": "#/components/schemas/RecordVulnerabilityWorkflowRequest", + "response_schemas": [ + "#/components/schemas/Problem", + "#/components/schemas/VulnerabilityWorkflowRecordEnvelope" + ], + "scopes": [ + "security:write" + ], + "success_statuses": [ + "201" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "uploadVulnerabilityScan", + "path": "/v1/vulnerability-scans", + "request_schema": "#/components/schemas/UploadVulnerabilityScanRequest", + "response_schemas": [ + "#/components/schemas/Problem", + "#/components/schemas/VulnerabilityScanEnvelope" + ], + "scopes": [ + "evidence:write" + ], + "success_statuses": [ + "201" + ] + }, + { + "idempotency_key_required": false, + "method": "GET", + "operation_id": "getVulnerabilityScan", + "path": "/v1/vulnerability-scans/{id}", + "request_schema": "", + "response_schemas": [ + "#/components/schemas/Problem", + "#/components/schemas/VulnerabilityScanEnvelope" + ], + "scopes": [ + "evidence:read" + ], + "success_statuses": [ + "200" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "createWaiver", + "path": "/v1/waivers", + "request_schema": "#/components/schemas/CreateWaiverRequest", + "response_schemas": [ + "#/components/schemas/Problem", + "#/components/schemas/WaiverEnvelope" + ], + "scopes": [ + "policy:write" + ], + "success_statuses": [ + "201" + ] + }, + { + "idempotency_key_required": true, + "method": "POST", + "operation_id": "approveWaiver", + "path": "/v1/waivers/{id}/approve", + "request_schema": "#/components/schemas/EmptyObject", + "response_schemas": [ + "#/components/schemas/Problem", + "#/components/schemas/WaiverEnvelope" + ], + "scopes": [ + "policy:write" + ], + "success_statuses": [ + "200" + ] + } + ], + "source": "openapi.yaml" +} diff --git a/sdk/python/evydence_client.py b/sdk/python/evydence_client.py index 182286c..cb2ef0c 100644 --- a/sdk/python/evydence_client.py +++ b/sdk/python/evydence_client.py @@ -2,6 +2,7 @@ import json import urllib.error +import urllib.parse import urllib.request from dataclasses import dataclass from typing import Any @@ -31,3 +32,69 @@ def post(self, path: str, idempotency_key: str, payload: dict[str, Any]) -> dict return json.loads(response.read().decode("utf-8")) except urllib.error.HTTPError as exc: raise RuntimeError(f"Evydence request failed with status {exc.code}") from exc + + def get(self, path: str) -> dict[str, Any]: + if not path.startswith("/v1/"): + raise ValueError("invalid Evydence path") + headers = {} + if self.api_key.strip(): + headers["Authorization"] = f"Bearer {self.api_key.strip()}" + request = urllib.request.Request( + self.base_url.rstrip("/") + path, + method="GET", + headers=headers, + ) + try: + with urllib.request.urlopen(request, timeout=30) as response: + return json.loads(response.read().decode("utf-8")) + except urllib.error.HTTPError as exc: + raise RuntimeError(f"Evydence request failed with status {exc.code}") from exc + + def create_product( + self, + idempotency_key: str, + payload: dict[str, Any], + ) -> dict[str, Any]: + return self.post("/v1/products", idempotency_key, payload) + + def create_release( + self, + idempotency_key: str, + payload: dict[str, Any], + ) -> dict[str, Any]: + return self.post("/v1/releases", idempotency_key, payload) + + def register_artifact( + self, + idempotency_key: str, + payload: dict[str, Any], + ) -> dict[str, Any]: + return self.post("/v1/artifacts", idempotency_key, payload) + + def create_build( + self, + idempotency_key: str, + payload: dict[str, Any], + ) -> dict[str, Any]: + return self.post("/v1/builds", idempotency_key, payload) + + def readiness(self) -> dict[str, Any]: + return self.get("/v1/ready") + + def release_readiness(self, release_id: str) -> dict[str, Any]: + encoded_release_id = urllib.parse.quote(release_id, safe="") + return self.get(f"/v1/reports/release-readiness?release_id={encoded_release_id}") + + def create_sso_provider( + self, + idempotency_key: str, + payload: dict[str, Any], + ) -> dict[str, Any]: + return self.post("/v1/sso/providers", idempotency_key, payload) + + def verify_provider_identity( + self, + idempotency_key: str, + payload: dict[str, Any], + ) -> dict[str, Any]: + return self.post("/v1/provider-verifications", idempotency_key, payload) diff --git a/sdk/typescript/client.ts b/sdk/typescript/client.ts index b4e031a..603ca5c 100644 --- a/sdk/typescript/client.ts +++ b/sdk/typescript/client.ts @@ -4,6 +4,62 @@ export type EvydenceClientOptions = { fetchImpl?: typeof fetch; }; +export type CreateProductRequest = { + name: string; + slug: string; +}; + +export type CreateReleaseRequest = { + product_id: string; + project_id?: string; + version: string; +}; + +export type RegisterArtifactRequest = { + release_id?: string; + name?: string; + media_type?: string; + digest: string; + size?: number; +}; + +export type BuildOutput = { + artifact_id?: string; + digest: string; + name?: string; +}; + +export type CreateBuildRequest = { + project_id: string; + release_id: string; + provider: "github_actions" | "generic"; + commit_sha: string; + status: "queued" | "running" | "passed" | "failed" | "cancelled"; + started_at: string; + outputs?: BuildOutput[]; + github?: Record; +}; + +export type CreateSSOProviderRequest = { + name: string; + type: "oidc" | "saml"; + issuer: string; + client_id: string; + groups_claim?: string; + role_mapping?: Record; + jwks?: Record; + saml_signing_certificates?: string[]; +}; + +export type VerifyProviderIdentityRequest = { + provider_type: "oidc" | "saml"; + provider_id: string; + subject: string; + id_token?: string; + saml_assertion?: string; + access_token?: string; +}; + export class EvydenceClient { private readonly baseUrl: string; private readonly apiKey: string; @@ -33,4 +89,72 @@ export class EvydenceClient { } return response.json() as Promise; } + + async get(path: string): Promise { + if (!path.startsWith("/v1/")) { + throw new Error("invalid Evydence path"); + } + const headers: Record = {}; + if (this.apiKey.trim()) { + headers["Authorization"] = `Bearer ${this.apiKey.trim()}`; + } + const response = await this.fetchImpl(`${this.baseUrl}${path}`, { + method: "GET", + headers, + }); + if (!response.ok) { + throw new Error(`Evydence request failed with status ${response.status}`); + } + return response.json() as Promise; + } + + async createProduct( + idempotencyKey: string, + payload: CreateProductRequest, + ): Promise { + return this.post("/v1/products", idempotencyKey, payload); + } + + async createRelease( + idempotencyKey: string, + payload: CreateReleaseRequest, + ): Promise { + return this.post("/v1/releases", idempotencyKey, payload); + } + + async registerArtifact( + idempotencyKey: string, + payload: RegisterArtifactRequest, + ): Promise { + return this.post("/v1/artifacts", idempotencyKey, payload); + } + + async createBuild( + idempotencyKey: string, + payload: CreateBuildRequest, + ): Promise { + return this.post("/v1/builds", idempotencyKey, payload); + } + + async readiness(): Promise { + return this.get("/v1/ready"); + } + + async releaseReadiness(releaseId: string): Promise { + return this.get(`/v1/reports/release-readiness?release_id=${encodeURIComponent(releaseId)}`); + } + + async createSSOProvider( + idempotencyKey: string, + payload: CreateSSOProviderRequest, + ): Promise { + return this.post("/v1/sso/providers", idempotencyKey, payload); + } + + async verifyProviderIdentity( + idempotencyKey: string, + payload: VerifyProviderIdentityRequest, + ): Promise { + return this.post("/v1/provider-verifications", idempotencyKey, payload); + } } diff --git a/site/marketing/astro.config.mjs b/site/marketing/astro.config.mjs new file mode 100644 index 0000000..04e2ef3 --- /dev/null +++ b/site/marketing/astro.config.mjs @@ -0,0 +1,17 @@ +import { defineConfig } from "astro/config"; + +const site = process.env.PUBLIC_SITE_URL || "https://aatuh.github.io"; +const base = process.env.PUBLIC_SITE_BASE || "/evydence"; + +export default defineConfig({ + site, + base, + output: "static", + i18n: { + defaultLocale: "en", + locales: ["en", "fi"], + routing: { + prefixDefaultLocale: true + } + } +}); diff --git a/site/marketing/package-lock.json b/site/marketing/package-lock.json new file mode 100644 index 0000000..3b72625 --- /dev/null +++ b/site/marketing/package-lock.json @@ -0,0 +1,5170 @@ +{ + "name": "@evydence/marketing-site", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@evydence/marketing-site", + "version": "0.1.0", + "devDependencies": { + "astro": "6.4.2" + } + }, + "node_modules/@astrojs/internal-helpers": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@astrojs/internal-helpers/-/internal-helpers-0.10.0.tgz", + "integrity": "sha512-Ry2R3VPeIN4uPCSA4xQc+e+vsJXkalKpEbDc07hV+a/o5Bs2N/s/uDcPJH/05L19DKh9tAy7e6JM3YZ6Cxfezw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.4", + "@types/mdast": "^4.0.4", + "js-yaml": "^4.1.1", + "picomatch": "^4.0.4", + "retext-smartypants": "^6.2.0", + "shiki": "^4.0.2", + "smol-toml": "^1.6.0", + "unified": "^11.0.5" + } + }, + "node_modules/@astrojs/markdown-remark": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@astrojs/markdown-remark/-/markdown-remark-7.2.0.tgz", + "integrity": "sha512-+YxmVQu1Bd+MFfSzjq1rOJvD9+nIOJzz5YIIhdIH01RrxRkKbyKoEgyIqP3yv51MhzMDgd79QaPv+kCVPT8vHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@astrojs/internal-helpers": "0.10.0", + "@astrojs/prism": "4.0.2", + "github-slugger": "^2.0.0", + "hast-util-from-html": "^2.0.3", + "hast-util-to-text": "^4.0.2", + "mdast-util-definitions": "^6.0.0", + "rehype-raw": "^7.0.0", + "rehype-stringify": "^10.0.1", + "remark-gfm": "^4.0.1", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.1.2", + "remark-smartypants": "^3.0.2", + "unified": "^11.0.5", + "unist-util-remove-position": "^5.0.0", + "unist-util-visit": "^5.1.0", + "unist-util-visit-parents": "^6.0.2", + "vfile": "^6.0.3" + } + }, + "node_modules/@astrojs/prism": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@astrojs/prism/-/prism-4.0.2.tgz", + "integrity": "sha512-KTivpmnz6lDsC6o9H4+DNm2SrE/GHzw8cNAvEJwAvUT+eoaEnn/4NtbDNfRRaxaJHdp15gf+tfHAWiXR4wB3BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "prismjs": "^1.30.0" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@astrojs/telemetry": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/@astrojs/telemetry/-/telemetry-3.3.2.tgz", + "integrity": "sha512-j8DNruA8ors99Al39RYZPJK4DC1bKkoNm93mAMuBhY9TCNC4R8n1q7ovFnJ5qhGh5Lsh7pa1gpQVpYpsJPeTHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ci-info": "^4.4.0", + "dset": "^3.1.4", + "is-docker": "^4.0.0", + "is-wsl": "^3.1.1", + "which-pm-runs": "^1.1.0" + }, + "engines": { + "node": "18.20.8 || ^20.3.0 || >=22.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@capsizecss/unpack": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@capsizecss/unpack/-/unpack-4.0.0.tgz", + "integrity": "sha512-VERIM64vtTP1C4mxQ5thVT9fK0apjPFobqybMtA1UdUujWka24ERHbRHFGmpbbhp73MhV+KSsHQH9C6uOTdEQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fontkitten": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@clack/core": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@clack/core/-/core-1.4.0.tgz", + "integrity": "sha512-7Wctjq6f7c1CPz8sPpkwUnz8yRgVANkpNupb81q432FjcJg4l+Sw7XANdNSdWfAKq0IHI0JTcUeK5dxs/HrGPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-wrap-ansi": "^0.2.0", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 20.12.0" + } + }, + "node_modules/@clack/prompts": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.5.0.tgz", + "integrity": "sha512-wKh+wTjmrUoUdkZg8KpJO5X+p9PWV+KE9mePseq9UYWkukgTKsGS47RRL2HstwVcvDQH+PenrPJWII8+MfiiyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@clack/core": "1.4.0", + "fast-string-width": "^3.0.2", + "fast-wrap-ansi": "^0.2.0", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 20.12.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@oslojs/encoding": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@oslojs/encoding/-/encoding-1.1.0.tgz", + "integrity": "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/pluginutils": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.4.0.tgz", + "integrity": "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.61.0.tgz", + "integrity": "sha512-dnxczajOqt0gesZlN5pGQ1s1imQVrsmCw5G2Ci4oM+0WvNz3pyRnlWrT7McoZIb8VlFwCawdmbWRmxRn7HI+VQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.61.0.tgz", + "integrity": "sha512-Bp3JpGP00Vu3f238ivRrjf7z3xSzVPXqCmaJYA9t2c+c8vKYvOzmXF7LkkeUalTEGd6cZcSWe+PFIP3Vy48fRg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.61.0.tgz", + "integrity": "sha512-zaYIpr670mUmmZ1tVzUFplbQbG7h3Gugx3L5FoqhsC2m/YnLlR1a7zVLmXNPy+iY1tFPEbNG+HHBXZGyId0G5w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.61.0.tgz", + "integrity": "sha512-+P49fvkv2dSoeevUW+lgZ/I2JHSsJCK1Lyjj7Cu6E4UHG4tS9XIefzIjo5qhgELjAclnen1rLzK2PMKJdo+Dyg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.61.0.tgz", + "integrity": "sha512-l3FAAOyKJXH2ea6KNFN+MMgC/rnE94YGLXs2ehYqDcCoHt1DpvgWX75BhUJxN38XojP7Ul+4H8PRn7EdyqSDrw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.61.0.tgz", + "integrity": "sha512-VokPN3TSctKj65cyCNPaUh4vMFA8awxOot/0sp+4J7ZlNRKQEhXhawqPwajoi8H5ZFt61i0ugZJuTKXBjGJ17Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.61.0.tgz", + "integrity": "sha512-DxH0P3wxm+Yzs/p3zrk9dw1rURu8p0Nv5+MRK/L7OtnLNg5rLZraSBFZ8iUXOd9f2BlhJyEpIZUH/emjq4UJ4g==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.61.0.tgz", + "integrity": "sha512-T6ZvMNe84kAz6TBWHC7hGAoEtzP1LWYw/AqayGWEF6uISt3Abk/st06LqRD9THd7Xz3NxzurUpzAuEAUbZf+nw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.61.0.tgz", + "integrity": "sha512-q/4hzvQkDs8b4jIBab1pnLiiM0ayTZsN2amBFPDzuyZxjEd4wDwx0UJFYM3cOZzSf5Kw8fnWSprJzIBMkcR44Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.61.0.tgz", + "integrity": "sha512-vvYWX3akdEAY6km+9wAqFDnk6pQsbJKVnj7xawcvs/+fdlYBGp+U+Qq/lLfpIxYIZvZLHMAKD9HLdacSx/r3dw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.61.0.tgz", + "integrity": "sha512-DePa5cqOxDP/Zp0VOXpeWaGew5iIv5DXp9NYbzkX5PFQyWVX9184WCTh3hvr/7lhXo8ZVlbFLkz8+o/q1dU6gA==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.61.0.tgz", + "integrity": "sha512-LV8aWMB8UChglMCEzs7RkN0GsH29RJaLLqwm9fCIjlqwxQTiWAqNcc7wjBkH31hV0PU/yVxGYvrYsgfea2qw6g==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.61.0.tgz", + "integrity": "sha512-QoNSnwQtaeNu5grdBbsL0tt1uyl5EnS8DA8Mr3nluMXbhdQNyhN+G4tBax7VCdxLKj8YJ0/4OO9Ho84jMnJtKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.61.0.tgz", + "integrity": "sha512-/zZp5MKapIIApE8trN8qLGNSiRN9TUoaUZ1cmVu4XnVdd5LQLOXTtyi+vtfUbNnT3iyjzpPqYeKXmvJ+gJGYWw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.61.0.tgz", + "integrity": "sha512-RbrzcD3aJ1k3UbtMRRBNwojdVVyXjuVAFTfn/xPa6EEl6GE9Sm/akPgFTb9aAC9pMKGJ6CtWxaGrqWcabH+ySg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.61.0.tgz", + "integrity": "sha512-ZF+onDsBso8PJf1XaG9lB+O9RnBpKGnY6OrzC4CSHrtC1jb6jWLTKK4bRqdoCXHd22gyr2hiYmEAm8Wns/BOCw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.61.0.tgz", + "integrity": "sha512-Atk0aSIk5Zx2Wuh9dgRQgLP0Koc8hOeYpbWryMXyk8G8/HmPkwPPkMqIIDhrXHHYqfUzSJA/I7IWSBv8xSmRBA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.61.0.tgz", + "integrity": "sha512-0uMOcf3eZ5K+K4cYHkdxShFMPlPXCOdfDFEFn9dNYAEEd2cVvmOfH7zFgRVoDgmtQ1m9k5q7qfrHzyMAubKYUA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.61.0.tgz", + "integrity": "sha512-mvFtE4A/t/7hRJ7X8Ozmu8FsIkAUat2nzl12pgU337BRmq87AQUJztwHz2Zv5/tjo9/C95E66CK03SI/ToEDJw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.61.0.tgz", + "integrity": "sha512-z9b9+aTxvt8n2rNltMPvyaUfB8NJ+CVyOrGK/MdIKHx7B+lXmZpm/XbRsU7Rpf3fRqJ2uS6mBJiJveCtq8LHDg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.61.0.tgz", + "integrity": "sha512-jXaXFqKMehsOc+g8R6oo33RRC6w07G9jDBxAE5eAKX7mOcCbZloYIPNhfG9Wl+P9O9IWHFO4OJgPi1Ml2qkt7w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.61.0.tgz", + "integrity": "sha512-OXNWVFocS2IA4+QplhTZZ2a+8hPZR7T8KuozsNmJKK8y7cp83StHvGksfHzPG3wczWTczyWHVQuqeiTUbjiyBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.61.0.tgz", + "integrity": "sha512-AlAbNtBO637LxSldqV43z0FfXoGfl2TW1DgAg/bs7aQswFbDewz2SJm3BUhiGfbOVtW571xbc9p+REdxhyN/Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.61.0.tgz", + "integrity": "sha512-QRSrQXyJ1M4tjNXdR0/G/IgV6lzfQQJYBjlWIEYkY2Xs86DRl/iEpQ4blMDjJxSl7n19eDKKXMg0AmuBVYy8pQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.61.0.tgz", + "integrity": "sha512-tkuFxhvKO/HlGd0VsINF6vHSYH8AF8W0TcNxKDK6JZmrehngFj78pToc8iemtnvwilDjs2G/qSzYFhe9U8q+fw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@shikijs/core": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-4.1.0.tgz", + "integrity": "sha512-jLJtSJeuFffqX6/inRE1zqU5aFv2hrszvYgq3OjbAgFRZiWv7abKMDdQzYxuSDfmUPQozZvI/kuy6VMTvnvqTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/primitive": "4.1.0", + "@shikijs/types": "4.1.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4", + "hast-util-to-html": "^9.0.5" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/engine-javascript": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-4.1.0.tgz", + "integrity": "sha512-YquhawCUgaBfhsS72e2Y/dI59gCBNPHu3fEO/tvLaXrTssxZrY5ddjtNLTwndrMgPo8b3IscE+xoICDzpTmlFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.1.0", + "@shikijs/vscode-textmate": "^10.0.2", + "oniguruma-to-es": "^4.3.6" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/engine-oniguruma": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-4.1.0.tgz", + "integrity": "sha512-axLpjVs45YBvvINa+dJF+NPW+KtFkNXsFr4SDw2BMj9GdeMnGxVB9PQb2xXlJYovslt/nz6giedAyOANkfc7hg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.1.0", + "@shikijs/vscode-textmate": "^10.0.2" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/langs": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-4.1.0.tgz", + "integrity": "sha512-nwOMruEkbgdZfQ/b8CgpNBVOpvG1k0N5tbmgiFeqsan401+x3ILqlzZJowSla4Agmq4hG2Uf2wh5jLTEhR8VSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.1.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/primitive": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@shikijs/primitive/-/primitive-4.1.0.tgz", + "integrity": "sha512-zx2/2Uwj2q9X3KSyYREEhXO23xBw5WUhP4orK2lE4r+t9JGITmEe0JH+wPmJhqHpOT2bRRs6lAL945+LDvOAGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.1.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/themes": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-4.1.0.tgz", + "integrity": "sha512-emCcTnUM7yO2wltYbaxm+yLvcCI4+h8XBKc4KmJ7EZUXoSGjcCHifkI//R4OFit9ewpg7H2/9tjOuXrT2v/Knw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.1.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/types": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-4.1.0.tgz", + "integrity": "sha512-3EQWX54fMpniOrDblzAhiwiJwpiTMW6+B9DWyUd9ska483tbayFYuw47UxwuPknI31bKnySfVQ/QW+jFL4rFdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/vscode-textmate": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", + "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/nlcst": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/nlcst/-/nlcst-2.0.3.tgz", + "integrity": "sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.1.tgz", + "integrity": "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/array-iterate": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/array-iterate/-/array-iterate-2.0.1.tgz", + "integrity": "sha512-I1jXZMjAgCMmxT4qxXfPXa6SthSoE8h6gkSI9BGGNv8mP8G/v0blc+qFnZu6K42vTOiuME596QaLO0TP3Lk0xg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/astro": { + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/astro/-/astro-6.4.2.tgz", + "integrity": "sha512-8H89CH2dKL5SCU99OCqdU9BGjmPkSJqaPurywj5XMo7eMFGUFD3vsNhdEKnEh4mK4LgGje3/QDTTSIIGst0G0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@astrojs/compiler": "^4.0.0", + "@astrojs/internal-helpers": "0.10.0", + "@astrojs/markdown-remark": "7.2.0", + "@astrojs/telemetry": "3.3.2", + "@capsizecss/unpack": "^4.0.0", + "@clack/prompts": "^1.1.0", + "@oslojs/encoding": "^1.1.0", + "@rollup/pluginutils": "^5.3.0", + "aria-query": "^5.3.2", + "axobject-query": "^4.1.0", + "ci-info": "^4.4.0", + "clsx": "^2.1.1", + "common-ancestor-path": "^2.0.0", + "cookie": "^1.1.1", + "devalue": "^5.6.3", + "diff": "^8.0.3", + "dset": "^3.1.4", + "es-module-lexer": "^2.0.0", + "esbuild": "^0.27.3", + "flattie": "^1.1.1", + "fontace": "~0.4.1", + "get-tsconfig": "5.0.0-beta.4", + "github-slugger": "^2.0.0", + "html-escaper": "3.0.3", + "http-cache-semantics": "^4.2.0", + "js-yaml": "^4.1.1", + "jsonc-parser": "^3.3.1", + "magic-string": "^0.30.21", + "magicast": "^0.5.2", + "mrmime": "^2.0.1", + "neotraverse": "^0.6.18", + "obug": "^2.1.1", + "p-limit": "^7.3.0", + "p-queue": "^9.1.0", + "package-manager-detector": "^1.6.0", + "piccolore": "^0.1.3", + "picomatch": "^4.0.4", + "rehype": "^13.0.2", + "semver": "^7.7.4", + "shiki": "^4.0.2", + "smol-toml": "^1.6.0", + "svgo": "^4.0.1", + "tinyclip": "^0.1.12", + "tinyexec": "^1.0.4", + "tinyglobby": "^0.2.15", + "ultrahtml": "^1.6.0", + "unifont": "~0.7.4", + "unist-util-visit": "^5.1.0", + "unstorage": "^1.17.5", + "vfile": "^6.0.3", + "vite": "^7.3.2", + "vitefu": "^1.1.2", + "xxhash-wasm": "^1.1.0", + "yargs-parser": "^22.0.0", + "zod": "^4.3.6" + }, + "bin": { + "astro": "bin/astro.mjs" + }, + "engines": { + "node": ">=22.12.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/astrodotbuild" + }, + "optionalDependencies": { + "sharp": "^0.34.0" + } + }, + "node_modules/astro/node_modules/@astrojs/compiler": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@astrojs/compiler/-/compiler-4.0.0.tgz", + "integrity": "sha512-eouss7G8ygdZqHuke033VMcVw5HTZUu+PXd/h06DGDUg/jt5btPYPqh66ENWw/mU78rBrf/oeC4oqoBwMtDMNA==", + "dev": true, + "license": "MIT" + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "dev": true, + "license": "ISC" + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/commander": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", + "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/common-ancestor-path": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/common-ancestor-path/-/common-ancestor-path-2.0.0.tgz", + "integrity": "sha512-dnN3ibLeoRf2HNC+OlCiNc5d2zxbLJXOtiZUudNFSXZrNSydxcCsSpRzXwfu7BBWCIfHPw+xTayeBvJCP/D8Ng==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">= 18" + } + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cookie-es": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-1.2.3.tgz", + "integrity": "sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw==", + "dev": true, + "license": "MIT" + }, + "node_modules/crossws": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/crossws/-/crossws-0.3.5.tgz", + "integrity": "sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA==", + "dev": true, + "license": "MIT", + "dependencies": { + "uncrypto": "^0.1.3" + } + }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/csso": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/csso/-/csso-5.0.5.tgz", + "integrity": "sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "~2.2.0" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/csso/node_modules/css-tree": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.2.1.tgz", + "integrity": "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.0.28", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/csso/node_modules/mdn-data": { + "version": "2.0.28", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.28.tgz", + "integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/defu": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/destr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", + "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/devalue": { + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz", + "integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==", + "dev": true, + "license": "MIT" + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/diff": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", + "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/dom-serializer/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dset": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/dset/-/dset-3.1.4.tgz", + "integrity": "sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-string-truncated-width": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-string-width": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-truncated-width": "^3.0.2" + } + }, + "node_modules/fast-wrap-ansi": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", + "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-width": "^3.0.2" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/flattie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/flattie/-/flattie-1.1.1.tgz", + "integrity": "sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/fontace": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/fontace/-/fontace-0.4.1.tgz", + "integrity": "sha512-lDMvbAzSnHmbYMTEld5qdtvNH2/pWpICOqpean9IgC7vUbUJc3k+k5Dokp85CegamqQpFbXf0rAVkbzpyTA8aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fontkitten": "^1.0.2" + } + }, + "node_modules/fontkitten": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/fontkitten/-/fontkitten-1.0.3.tgz", + "integrity": "sha512-Wp1zXWPVUPBmfoa3Cqc9ctaKuzKAV6uLstRqlR56kSjplf5uAce+qeyYym7F+PHbGTk+tCEdkCW6RD7DX/gBZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tiny-inflate": "^1.0.3" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-tsconfig": { + "version": "5.0.0-beta.4", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-5.0.0-beta.4.tgz", + "integrity": "sha512-7nF7C9fIPFEMHgEMEfgIlO9wDdZ8CyHw27rWciFZfHvHDReIiPhsYuzPRXsfvBCqFy1l8RRyyWV7QLM+ZhUJsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "engines": { + "node": ">=20.20.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/github-slugger": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-2.0.0.tgz", + "integrity": "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==", + "dev": true, + "license": "ISC" + }, + "node_modules/h3": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/h3/-/h3-1.15.11.tgz", + "integrity": "sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cookie-es": "^1.2.3", + "crossws": "^0.3.5", + "defu": "^6.1.6", + "destr": "^2.0.5", + "iron-webcrypto": "^1.2.1", + "node-mock-http": "^1.0.4", + "radix3": "^1.1.2", + "ufo": "^1.6.3", + "uncrypto": "^0.1.3" + } + }, + "node_modules/hast-util-from-html": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hast-util-from-html/-/hast-util-from-html-2.0.3.tgz", + "integrity": "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "devlop": "^1.1.0", + "hast-util-from-parse5": "^8.0.0", + "parse5": "^7.0.0", + "vfile": "^6.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-parse5": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz", + "integrity": "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "devlop": "^1.0.0", + "hastscript": "^9.0.0", + "property-information": "^7.0.0", + "vfile": "^6.0.0", + "vfile-location": "^5.0.0", + "web-namespaces": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-is-element": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz", + "integrity": "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-parse-selector": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", + "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-raw": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/hast-util-raw/-/hast-util-raw-9.1.0.tgz", + "integrity": "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "@ungap/structured-clone": "^1.0.0", + "hast-util-from-parse5": "^8.0.0", + "hast-util-to-parse5": "^8.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "parse5": "^7.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-html": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", + "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-whitespace": "^3.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "stringify-entities": "^4.0.0", + "zwitch": "^2.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-8.0.1.tgz", + "integrity": "sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-text": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz", + "integrity": "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "hast-util-is-element": "^3.0.0", + "unist-util-find-after": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hastscript": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz", + "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-parse-selector": "^4.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/html-escaper": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz", + "integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/iron-webcrypto": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/iron-webcrypto/-/iron-webcrypto-1.2.1.tgz", + "integrity": "sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/brc-dd" + } + }, + "node_modules/is-docker": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-4.0.0.tgz", + "integrity": "sha512-LHE+wROyG/Y/0ZnbktRCoTix2c1RhgWaZraMZ8o1Q7zCh0VSrICJQO5oqIIISrcSBtrXv0o233w1IYwsWCjTzA==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-inside-container/node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-wsl": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/js-yaml": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/lru-cache": { + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magicast": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", + "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/mdast-util-definitions": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-definitions/-/mdast-util-definitions-6.0.0.tgz", + "integrity": "sha512-scTllyX6pnYNZH/AIp/0ePz6s4cZtARxImwoPJ7kS42n+MnVsI4XbnG6d4ibehRIldYMWM2LD7ImQblVhUejVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "dev": true, + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "dev": true, + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/neotraverse": { + "version": "0.6.18", + "resolved": "https://registry.npmjs.org/neotraverse/-/neotraverse-0.6.18.tgz", + "integrity": "sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/nlcst-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/nlcst-to-string/-/nlcst-to-string-4.0.0.tgz", + "integrity": "sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/nlcst": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/node-fetch-native": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", + "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-mock-http": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/node-mock-http/-/node-mock-http-1.0.4.tgz", + "integrity": "sha512-8DY+kFsDkNXy1sJglUfuODx1/opAGJGyrTuFqEoN90oRc2Vk0ZbD4K2qmKXBBEhZQzdKHIVfEJpDU8Ak2NJEvQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/ofetch": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/ofetch/-/ofetch-1.5.1.tgz", + "integrity": "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "destr": "^2.0.5", + "node-fetch-native": "^1.6.7", + "ufo": "^1.6.1" + } + }, + "node_modules/ohash": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", + "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/oniguruma-parser": { + "version": "0.12.2", + "resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.2.tgz", + "integrity": "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==", + "dev": true, + "license": "MIT" + }, + "node_modules/oniguruma-to-es": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.6.tgz", + "integrity": "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "oniguruma-parser": "^0.12.2", + "regex": "^6.1.0", + "regex-recursion": "^6.0.2" + } + }, + "node_modules/p-limit": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-7.3.0.tgz", + "integrity": "sha512-7cIXg/Z0M5WZRblrsOla88S4wAK+zOQQWeBYfV3qJuJXMr+LnbYjaadrFaS0JILfEDPVqHyKnZ1Z/1d6J9VVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^1.2.1" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-queue": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.3.0.tgz", + "integrity": "sha512-7NED7xhQ74Ngp4JP/2e0VZHp7vSWfJfqeiR92jPgxsz6m0Se4P03YoTKa9dDXyZ3r6P616gUXttrB6nnHYKang==", + "dev": true, + "license": "MIT", + "dependencies": { + "eventemitter3": "^5.0.4", + "p-timeout": "^7.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-timeout": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-7.0.1.tgz", + "integrity": "sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-manager-detector": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz", + "integrity": "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==", + "dev": true, + "license": "MIT" + }, + "node_modules/parse-latin": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/parse-latin/-/parse-latin-7.0.0.tgz", + "integrity": "sha512-mhHgobPPua5kZ98EF4HWiH167JWBfl4pvAIXXdbaVohtK7a6YBOy56kvhCqduqyo/f3yrHFWmqmiMg/BkBkYYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/nlcst": "^2.0.0", + "@types/unist": "^3.0.0", + "nlcst-to-string": "^4.0.0", + "unist-util-modify-children": "^4.0.0", + "unist-util-visit-children": "^3.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/piccolore": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/piccolore/-/piccolore-0.1.3.tgz", + "integrity": "sha512-o8bTeDWjE086iwKrROaDf31K0qC/BENdm15/uH9usSC/uZjJOKb2YGiVHfLY4GhwsERiPI1jmwI2XrA7ACOxVw==", + "dev": true, + "license": "ISC" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prismjs": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", + "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/property-information": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", + "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/radix3": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/radix3/-/radix3-1.1.2.tgz", + "integrity": "sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==", + "dev": true, + "license": "MIT" + }, + "node_modules/regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz", + "integrity": "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-recursion": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz", + "integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-utilities": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz", + "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", + "dev": true, + "license": "MIT" + }, + "node_modules/rehype": { + "version": "13.0.2", + "resolved": "https://registry.npmjs.org/rehype/-/rehype-13.0.2.tgz", + "integrity": "sha512-j31mdaRFrwFRUIlxGeuPXXKWQxet52RBQRvCmzl5eCefn/KGbomK5GMHNMsOJf55fgo3qw5tST5neDuarDYR2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "rehype-parse": "^9.0.0", + "rehype-stringify": "^10.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-parse": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/rehype-parse/-/rehype-parse-9.0.1.tgz", + "integrity": "sha512-ksCzCD0Fgfh7trPDxr2rSylbwq9iYDkSn8TCDmEJ49ljEUBxDVCzCHv7QNzZOfODanX4+bWQ4WZqLCRWYLfhag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-from-html": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-raw": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/rehype-raw/-/rehype-raw-7.0.0.tgz", + "integrity": "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-raw": "^9.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-stringify": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/rehype-stringify/-/rehype-stringify-10.0.1.tgz", + "integrity": "sha512-k9ecfXHmIPuFVI61B9DeLPN0qFHfawM6RsuX48hoqlaKSF61RskNjSm1lI8PhBEM0MRdLxVVm4WmTqJQccH9mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-to-html": "^9.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-smartypants": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/remark-smartypants/-/remark-smartypants-3.0.2.tgz", + "integrity": "sha512-ILTWeOriIluwEvPjv67v7Blgrcx+LZOkAUVtKI3putuhlZm84FnqDORNXPPm+HY3NdZOMhyDwZ1E+eZB/Df5dA==", + "dev": true, + "license": "MIT", + "dependencies": { + "retext": "^9.0.0", + "retext-smartypants": "^6.0.0", + "unified": "^11.0.4", + "unist-util-visit": "^5.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/retext": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/retext/-/retext-9.0.0.tgz", + "integrity": "sha512-sbMDcpHCNjvlheSgMfEcVrZko3cDzdbe1x/e7G66dFp0Ff7Mldvi2uv6JkJQzdRcvLYE8CA8Oe8siQx8ZOgTcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/nlcst": "^2.0.0", + "retext-latin": "^4.0.0", + "retext-stringify": "^4.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/retext-latin": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/retext-latin/-/retext-latin-4.0.0.tgz", + "integrity": "sha512-hv9woG7Fy0M9IlRQloq/N6atV82NxLGveq+3H2WOi79dtIYWN8OaxogDm77f8YnVXJL2VD3bbqowu5E3EMhBYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/nlcst": "^2.0.0", + "parse-latin": "^7.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/retext-smartypants": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/retext-smartypants/-/retext-smartypants-6.2.0.tgz", + "integrity": "sha512-kk0jOU7+zGv//kfjXEBjdIryL1Acl4i9XNkHxtM7Tm5lFiCog576fjNC9hjoR7LTKQ0DsPWy09JummSsH1uqfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/nlcst": "^2.0.0", + "nlcst-to-string": "^4.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/retext-stringify": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/retext-stringify/-/retext-stringify-4.0.0.tgz", + "integrity": "sha512-rtfN/0o8kL1e+78+uxPTqu1Klt0yPzKuQ2BfWwwfgIUSayyzxpM1PJzkKt4V8803uB9qSy32MvI7Xep9khTpiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/nlcst": "^2.0.0", + "nlcst-to-string": "^4.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rollup": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.0.tgz", + "integrity": "sha512-T9mWdbWfQtp0B5lv/HX+wrhYsmXRlcWnXXmJbXqKJhlRaoS6KMhq0gpyzW4UJfclcxrEdLnTgjT2NjruLONu0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.61.0", + "@rollup/rollup-android-arm64": "4.61.0", + "@rollup/rollup-darwin-arm64": "4.61.0", + "@rollup/rollup-darwin-x64": "4.61.0", + "@rollup/rollup-freebsd-arm64": "4.61.0", + "@rollup/rollup-freebsd-x64": "4.61.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.61.0", + "@rollup/rollup-linux-arm-musleabihf": "4.61.0", + "@rollup/rollup-linux-arm64-gnu": "4.61.0", + "@rollup/rollup-linux-arm64-musl": "4.61.0", + "@rollup/rollup-linux-loong64-gnu": "4.61.0", + "@rollup/rollup-linux-loong64-musl": "4.61.0", + "@rollup/rollup-linux-ppc64-gnu": "4.61.0", + "@rollup/rollup-linux-ppc64-musl": "4.61.0", + "@rollup/rollup-linux-riscv64-gnu": "4.61.0", + "@rollup/rollup-linux-riscv64-musl": "4.61.0", + "@rollup/rollup-linux-s390x-gnu": "4.61.0", + "@rollup/rollup-linux-x64-gnu": "4.61.0", + "@rollup/rollup-linux-x64-musl": "4.61.0", + "@rollup/rollup-openbsd-x64": "4.61.0", + "@rollup/rollup-openharmony-arm64": "4.61.0", + "@rollup/rollup-win32-arm64-msvc": "4.61.0", + "@rollup/rollup-win32-ia32-msvc": "4.61.0", + "@rollup/rollup-win32-x64-gnu": "4.61.0", + "@rollup/rollup-win32-x64-msvc": "4.61.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/sax": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", + "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/semver": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/shiki": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-4.1.0.tgz", + "integrity": "sha512-l/ABZPUR5v70jI10EzqfMS/I96vjSGv2y0ihUV+WYFzv0EfvW4s54m0Lg8wCrrL+2IkwBzFTuxkZjPf8b2NX9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/core": "4.1.0", + "@shikijs/engine-javascript": "4.1.0", + "@shikijs/engine-oniguruma": "4.1.0", + "@shikijs/langs": "4.1.0", + "@shikijs/themes": "4.1.0", + "@shikijs/types": "4.1.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/smol-toml": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.1.tgz", + "integrity": "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "dev": true, + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/svgo": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/svgo/-/svgo-4.0.1.tgz", + "integrity": "sha512-XDpWUOPC6FEibaLzjfe0ucaV0YrOjYotGJO1WpF0Zd+n6ZGEQUsSugaoLq9QkEZtAfQIxT42UChcssDVPP3+/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^11.1.0", + "css-select": "^5.1.0", + "css-tree": "^3.0.1", + "css-what": "^6.1.0", + "csso": "^5.0.5", + "picocolors": "^1.1.1", + "sax": "^1.5.0" + }, + "bin": { + "svgo": "bin/svgo.js" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/svgo" + } + }, + "node_modules/tiny-inflate": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", + "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyclip": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/tinyclip/-/tinyclip-0.1.13.tgz", + "integrity": "sha512-8OqlXQ35euK9+e7L68u8UwcODxkHoIkjbGsgXuARKNyQ5G6xt8nw1YPeMbxMLgCPFkToU+UEK5j05t2t8edKpQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^16.14.0 || >= 17.3.0" + } + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/ufo": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", + "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/ultrahtml": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/ultrahtml/-/ultrahtml-1.6.0.tgz", + "integrity": "sha512-R9fBn90VTJrqqLDwyMph+HGne8eqY1iPfYhPzZrvKpIfwkWZbcYlfpsb8B9dTvBfpy1/hqAD7Wi8EKfP9e8zdw==", + "dev": true, + "license": "MIT" + }, + "node_modules/uncrypto": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/uncrypto/-/uncrypto-0.1.3.tgz", + "integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unifont": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/unifont/-/unifont-0.7.4.tgz", + "integrity": "sha512-oHeis4/xl42HUIeHuNZRGEvxj5AaIKR+bHPNegRq5LV1gdc3jundpONbjglKpihmJf+dswygdMJn3eftGIMemg==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.1.0", + "ofetch": "^1.5.1", + "ohash": "^2.0.11" + } + }, + "node_modules/unist-util-find-after": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-find-after/-/unist-util-find-after-5.0.0.tgz", + "integrity": "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-modify-children": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-modify-children/-/unist-util-modify-children-4.0.0.tgz", + "integrity": "sha512-+tdN5fGNddvsQdIzUF3Xx82CU9sMM+fA0dLgR9vOmT0oPT2jH+P1nd5lSqfCfXAw+93NhcXNY2qqvTUtE4cQkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "array-iterate": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-remove-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-remove-position/-/unist-util-remove-position-5.0.0.tgz", + "integrity": "sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-children": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/unist-util-visit-children/-/unist-util-visit-children-3.0.0.tgz", + "integrity": "sha512-RgmdTfSBOg04sdPcpTSD1jzoNBjt9a80/ZCzp5cI9n1qPzLZWF9YdvWGN2zmTumP1HWhXKdUWexjy/Wy/lJ7tA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unstorage": { + "version": "1.17.5", + "resolved": "https://registry.npmjs.org/unstorage/-/unstorage-1.17.5.tgz", + "integrity": "sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "^3.1.3", + "chokidar": "^5.0.0", + "destr": "^2.0.5", + "h3": "^1.15.10", + "lru-cache": "^11.2.7", + "node-fetch-native": "^1.6.7", + "ofetch": "^1.5.1", + "ufo": "^1.6.3" + }, + "peerDependencies": { + "@azure/app-configuration": "^1.8.0", + "@azure/cosmos": "^4.2.0", + "@azure/data-tables": "^13.3.0", + "@azure/identity": "^4.6.0", + "@azure/keyvault-secrets": "^4.9.0", + "@azure/storage-blob": "^12.26.0", + "@capacitor/preferences": "^6 || ^7 || ^8", + "@deno/kv": ">=0.9.0", + "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", + "@planetscale/database": "^1.19.0", + "@upstash/redis": "^1.34.3", + "@vercel/blob": ">=0.27.1", + "@vercel/functions": "^2.2.12 || ^3.0.0", + "@vercel/kv": "^1 || ^2 || ^3", + "aws4fetch": "^1.0.20", + "db0": ">=0.2.1", + "idb-keyval": "^6.2.1", + "ioredis": "^5.4.2", + "uploadthing": "^7.4.4" + }, + "peerDependenciesMeta": { + "@azure/app-configuration": { + "optional": true + }, + "@azure/cosmos": { + "optional": true + }, + "@azure/data-tables": { + "optional": true + }, + "@azure/identity": { + "optional": true + }, + "@azure/keyvault-secrets": { + "optional": true + }, + "@azure/storage-blob": { + "optional": true + }, + "@capacitor/preferences": { + "optional": true + }, + "@deno/kv": { + "optional": true + }, + "@netlify/blobs": { + "optional": true + }, + "@planetscale/database": { + "optional": true + }, + "@upstash/redis": { + "optional": true + }, + "@vercel/blob": { + "optional": true + }, + "@vercel/functions": { + "optional": true + }, + "@vercel/kv": { + "optional": true + }, + "aws4fetch": { + "optional": true + }, + "db0": { + "optional": true + }, + "idb-keyval": { + "optional": true + }, + "ioredis": { + "optional": true + }, + "uploadthing": { + "optional": true + } + } + }, + "node_modules/unstorage/node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/unstorage/node_modules/readdirp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-location": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz", + "integrity": "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vite": { + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.5.tgz", + "integrity": "sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitefu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", + "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", + "dev": true, + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/web-namespaces": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", + "integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/which-pm-runs": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/which-pm-runs/-/which-pm-runs-1.1.0.tgz", + "integrity": "sha512-n1brCuqClxfFfq/Rb0ICg9giSZqCS+pLtccdag6C2HyufBrh3fBOiy9nb6ggRMvWOVH5GrdJskj5iGTZNxd7SA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/xxhash-wasm": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/xxhash-wasm/-/xxhash-wasm-1.1.0.tgz", + "integrity": "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==", + "dev": true, + "license": "MIT" + }, + "node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yocto-queue": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", + "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + } +} diff --git a/site/marketing/package.json b/site/marketing/package.json new file mode 100644 index 0000000..f1f6bad --- /dev/null +++ b/site/marketing/package.json @@ -0,0 +1,15 @@ +{ + "name": "@evydence/marketing-site", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build": "astro build", + "check": "npm run build && node scripts/check.mjs", + "dev": "astro dev", + "preview": "astro preview" + }, + "devDependencies": { + "astro": "6.4.2" + } +} diff --git a/site/marketing/public/CNAME b/site/marketing/public/CNAME new file mode 100644 index 0000000..1cda005 --- /dev/null +++ b/site/marketing/public/CNAME @@ -0,0 +1 @@ +evydence.app diff --git a/site/marketing/public/api/index.html b/site/marketing/public/api/index.html new file mode 100644 index 0000000..bb1407d --- /dev/null +++ b/site/marketing/public/api/index.html @@ -0,0 +1,5228 @@ + + + + + + Evydence API - Rendered OpenAPI Docs + + + +
+

Evydence API

+

Self-hosted API evidence and compliance-readiness ledger.

+

This page is generated from openapi.yaml. It is a human-readable companion to the committed contract and does not replace route-contract tests.

+
+
3.1.0OpenAPI version
+
169registered paths
+
186operations
+
375schemas
+
+
+
+
+

Runtime secrets, tenant data, raw evidence payloads, private keys, bearer tokens, and customer package contents are not embedded in this generated page. Admin-scoped routes appear only when they are part of the committed public contract.

+

Error responses use RFC 9457-style Problem Details. The registered Problem schema currently requires: none.

+
+ +
+

Operations

+
+ + +
+
+ +
+
+ GET + /v1/admin/instance +
+

Instance admin snapshot

+

Returns instance-level diagnostic counts. Requires the explicit instance:admin scope; tenant admin and ordinary wildcard tenant keys are insufficient.

+
+
Operation ID
instanceAdminSnapshot
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
instance:admin
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: InstanceAdminSnapshotEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/api-keys +
+

List API keys

+

Lists tenant-scoped API key metadata without key hashes or one-time secrets.

+
+
Operation ID
listAPIKeys
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
admin
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: APIKeyListEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/api-keys +
+

Create API key

+

Creates a tenant-scoped API key and returns the secret exactly once. Stored records expose only non-secret key metadata.

+
+
Operation ID
createAPIKey
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
admin
+
Idempotency
Idempotency-Key
+
Request
application/json: CreateAPIKeyRequest
+
Responses
201 application/json: APIKeyCreateEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/api-security-scans +
+

Upload API security scan

+

Uploads SAST, DAST, secret, license, or API security scan metadata and raw JSON payload evidence without exposing raw payload bytes in responses.

+
+
Operation ID
uploadAPISecurityScan
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
security:write
+
Idempotency
Idempotency-Key
+
Request
application/json: UploadSecurityScanRequest
+
Responses
201 application/json: SecurityScanEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/approvals +
+

Create approval record

+

Creates an immutable approval record for a release, waiver, package, or review subject.

+
+
Operation ID
createApproval
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
release:write
+
Idempotency
Idempotency-Key
+
Request
application/json: CreateApprovalRequest
+
Responses
201 application/json: ApprovalRecordEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/artifact-signatures +
+

Create artifact signature

+

Records detached artifact signature evidence and optional raw signature payload metadata.

+
+
Operation ID
createArtifactSignature
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
evidence:write
+
Idempotency
Idempotency-Key
+
Request
application/json: CreateArtifactSignatureRequest
+
Responses
201 application/json: ArtifactSignatureEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/artifact-signatures/{id} +
+

Get artifact signature

+

Returns tenant-scoped artifact signature metadata by id.

+
+
Operation ID
getArtifactSignature
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
evidence:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: ArtifactSignatureEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/artifact-signatures/{id}/verify-cosign +
+

Verify cosign-style artifact signature

+

Records deterministic cosign-style verification metadata for an artifact signature without implying online transparency trust.

+
+
Operation ID
verifyCosignSignature
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
verify:read
+
Idempotency
Idempotency-Key
+
Request
application/json: VerifyCosignSignatureRequest
+
Responses
200 application/json: CosignVerificationEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/artifacts +
+

Register artifact

+

Registers an artifact digest for release evidence and later build/attestation matching.

+
+
Operation ID
registerArtifact
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
evidence:write
+
Idempotency
Idempotency-Key
+
Request
application/json: RegisterArtifactRequest
+
Responses
201 application/json: ArtifactEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/artifacts/{id} +
+

Get artifact

+

Returns a tenant-scoped artifact by id.

+
+
Operation ID
getArtifact
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
evidence:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: ArtifactEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/audit-chain/verify +
+

Verify audit chain

+

Verifies the tenant audit chain continuity and returns deterministic verification checks.

+
+
Operation ID
verifyAuditChain
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
verify:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: VerificationResultEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/audit-log +
+

List tenant audit log

+

Lists tenant-scoped append-only audit-chain entries in reverse chronological order.

+
+
Operation ID
listAuditLog
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
admin
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: AuditChainEntryListEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/backup-manifests +
+

Generate backup manifest

+

Generates a tenant-scoped backup manifest after an operator backup completes. The manifest excludes raw payload bytes and private key material.

+
+
Operation ID
generateBackupManifest
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
admin
+
Idempotency
Idempotency-Key
+
Request
application/json: EmptyObject
+
Responses
201 application/json: BackupManifestEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/backup-manifests/{id}/verify +
+

Verify backup manifest

+

Verifies a tenant-scoped backup manifest and returns deterministic manifest verification checks.

+
+
Operation ID
verifyBackupManifest
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
verify:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: VerificationResultEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/build-attestations/{id}/verify-signature +
+

Verify build attestation signature

+

Verifies a build attestation signature against configured tenant DSSE trust roots.

+
+
Operation ID
verifyBuildAttestationSignature
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
verify:read
+
Idempotency
Idempotency-Key
+
Request
application/json: EmptyObject
+
Responses
200 application/json: VerificationResultEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/builds +
+

Create build run

+

Records an immutable CI build run. Collector identity is derived from the authenticated key when present.

+
+
Operation ID
createBuild
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
build:write
+
Idempotency
Idempotency-Key
+
Request
application/json: CreateBuildRequest
+
Responses
201 application/json: BuildRunEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/builds/{id} +
+

Get build run

+

Returns a tenant-scoped build run by id.

+
+
Operation ID
getBuild
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
build:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: BuildRunEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/builds/{id}/attestations +
+

Upload build attestation

+

Uploads a DSSE/in-toto build attestation for a tenant-scoped build and stores raw bytes in object storage.

+
+
Operation ID
uploadBuildAttestation
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
build:write
+
Idempotency
Idempotency-Key
+
Request
application/json: DSSEEnvelope
+
Responses
201 application/json: BuildAttestationEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/collectors +
+

List collectors

+

Lists tenant-scoped collector metadata without API key hashes or one-time secrets.

+
+
Operation ID
listCollectors
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
collector:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: CollectorListEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/collectors +
+

Create collector

+

Creates a tenant-scoped collector identity, binds a scoped API key, and returns the collector key secret exactly once.

+
+
Operation ID
createCollector
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
collector:admin
+
Idempotency
Idempotency-Key
+
Request
application/json: CreateCollectorRequest
+
Responses
201 application/json: CollectorCreateEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/collectors/github/source-snapshots +
+

Upload GitHub source snapshot

+

Uploads a strict GitHub source snapshot, hashes commit messages, and stores repository, commit, branch, and pull-request evidence records.

+
+
Operation ID
uploadGitHubSourceSnapshot
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
source:write
+
Idempotency
Idempotency-Key
+
Request
application/json: SourceSnapshotRequest
+
Responses
201 application/json: SourceSnapshotEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/collectors/gitlab/source-snapshots +
+

Upload GitLab source snapshot

+

Uploads a strict GitLab source snapshot, hashes commit messages, and stores repository, commit, branch, and pull-request evidence records.

+
+
Operation ID
uploadGitLabSourceSnapshot
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
source:write
+
Idempotency
Idempotency-Key
+
Request
application/json: SourceSnapshotRequest
+
Responses
201 application/json: SourceSnapshotEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/collectors/{id}/health +
+

Collector health report

+

Returns collector supply-chain health from recorded tenant evidence, assumptions, and limitations.

+
+
Operation ID
collectorHealthReport
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
collector:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: CollectorHealthReportEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/collectors/{id}/releases +
+

Record collector release evidence

+

Records collector release supply-chain evidence for a tenant-scoped collector.

+
+
Operation ID
recordCollectorRelease
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
collector:admin
+
Idempotency
Idempotency-Key
+
Request
application/json: RecordCollectorReleaseRequest
+
Responses
201 application/json: CollectorReleaseEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/commercial-collectors +
+

List commercial collector definitions

+

Lists tenant-scoped commercial collector definitions.

+
+
Operation ID
listCommercialCollectors
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
collector:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: CommercialCollectorDefinitionListEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/commercial-collectors +
+

Create commercial collector definition

+

Creates tenant-scoped commercial collector metadata without installing external code.

+
+
Operation ID
createCommercialCollector
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
collector:admin
+
Idempotency
Idempotency-Key
+
Request
application/json: CreateCommercialCollectorRequest
+
Responses
201 application/json: CommercialCollectorDefinitionEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/container-images +
+

Register container image

+

Registers OCI/container image metadata and digest evidence linked to an optional artifact.

+
+
Operation ID
registerContainerImage
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
evidence:write
+
Idempotency
Idempotency-Key
+
Request
application/json: RegisterContainerImageRequest
+
Responses
201 application/json: ContainerImageEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/control-evidence +
+

List control evidence

+

Lists tenant-scoped control evidence links with optional control, product, and release filters.

+
+
Operation ID
listControlEvidence
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
controls:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: ControlEvidenceListEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/control-framework-template-packs +
+

List control framework template packs

+

Lists built-in control framework template packs available for explicit tenant installation.

+
+
Operation ID
listControlFrameworkTemplatePacks
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
controls:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: ControlFrameworkTemplatePackListEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/control-framework-template-packs/{slug}/install +
+

Install control framework template pack

+

Installs a named control framework template pack into the tenant as ordinary framework/control records.

+
+
Operation ID
installControlFrameworkTemplatePack
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
controls:admin
+
Idempotency
Idempotency-Key
+
Request
application/json: EmptyObject
+
Responses
201 application/json: ControlFrameworkEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/control-frameworks +
+

List control frameworks

+

Lists tenant-scoped control frameworks.

+
+
Operation ID
listControlFrameworks
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
controls:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: ControlFrameworkListEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/control-frameworks +
+

Create control framework

+

Creates a tenant-scoped versioned control framework.

+
+
Operation ID
createControlFramework
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
controls:admin
+
Idempotency
Idempotency-Key
+
Request
application/json: CreateControlFrameworkRequest
+
Responses
201 application/json: ControlFrameworkEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/controls +
+

Create security control

+

Creates a framework-owned security control with deterministic evidence requirements.

+
+
Operation ID
createSecurityControl
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
controls:admin
+
Idempotency
Idempotency-Key
+
Request
application/json: CreateSecurityControlRequest
+
Responses
201 application/json: SecurityControlEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/controls/{id} +
+

Get security control

+

Returns a tenant-scoped security control by id.

+
+
Operation ID
getSecurityControl
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
controls:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: SecurityControlEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/controls/{id}/evidence +
+

Link control evidence

+

Creates an append-only link between a security control and tenant-scoped evidence or related release resource.

+
+
Operation ID
linkControlEvidence
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
controls:write
+
Idempotency
Idempotency-Key
+
Request
application/json: LinkControlEvidenceRequest
+
Responses
201 application/json: ControlEvidenceEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/custom-policies +
+

Create custom policy

+

Creates a deterministic custom policy definition for tenant-managed release checks.

+
+
Operation ID
createCustomPolicy
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
policy:write
+
Idempotency
Idempotency-Key
+
Request
application/json: CreateCustomPolicyRequest
+
Responses
201 application/json: CustomPolicyEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/custom-policies/{id}/evaluate +
+

Evaluate custom policy

+

Evaluates a tenant custom policy against a release and records the input hash.

+
+
Operation ID
evaluateCustomPolicy
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
policy:read
+
Idempotency
Idempotency-Key
+
Request
application/json: EvaluatePolicyRequest
+
Responses
201 application/json: CustomPolicyEvaluationEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/customer-packages +
+

Create customer security package

+

Creates a scoped customer security package manifest using an explicit redaction profile.

+
+
Operation ID
createCustomerPackage
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
package:write
+
Idempotency
Idempotency-Key
+
Request
application/json: CreateCustomerPackageRequest
+
Responses
201 application/json: CustomerSecurityPackageEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/customer-packages/{id} +
+

Get customer security package

+

Returns a tenant-scoped customer security package manifest by id.

+
+
Operation ID
getCustomerPackage
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
package:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: CustomerSecurityPackageEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/customer-packages/{id}/download +
+

Download customer security package ZIP

+

Downloads a scoped customer security package ZIP. The archive contains redacted manifest metadata and verification guidance, not raw tenant evidence payload bytes.

+
+
Operation ID
downloadCustomerPackage
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
package:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/zip: string; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/customer-portal/access +
+

List customer portal access records

+

Lists tenant-scoped external reviewer access records without token hashes or token secrets.

+
+
Operation ID
listCustomerPortalAccess
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
package:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: CustomerPortalAccessListEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/customer-portal/access +
+

Create customer portal access

+

Creates a named, expiring external reviewer access record for a customer package and returns the portal token once.

+
+
Operation ID
createCustomerPortalAccess
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
package:write
+
Idempotency
Idempotency-Key
+
Request
application/json: CreateCustomerPortalAccessRequest
+
Responses
201 application/json: CustomerPortalAccessCreateEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/customer-portal/access/{id}/revoke +
+

Revoke customer portal access

+

Revokes a tenant-scoped external reviewer access record; revocation is append-only and the original token cannot be used afterwards.

+
+
Operation ID
revokeCustomerPortalAccess
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
package:write
+
Idempotency
Idempotency-Key
+
Request
application/json: EmptyObject
+
Responses
200 application/json: CustomerPortalAccessEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/customer-portal/package +
+

Access customer portal package

+

Public token exchange endpoint for a scoped customer package. It intentionally uses no bearer authentication and accepts only the issued portal token in the JSON body.

+
+
Operation ID
accessCustomerPortalPackage
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
none
+
Idempotency
Idempotency-Key
+
Request
application/json: CustomerPortalPackageRequest
+
Responses
200 application/json: CustomerSecurityPackageEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/customer-portal/package/download +
+

Download customer portal package ZIP

+

Public token exchange endpoint for downloading a scoped customer package ZIP. It intentionally uses no bearer authentication and accepts only the issued portal token in the JSON body.

+
+
Operation ID
downloadCustomerPortalPackage
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
none
+
Idempotency
Idempotency-Key
+
Request
application/json: CustomerPortalPackageRequest
+
Responses
200 application/zip: string; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/customer-portal/package/view +
+

Customer portal package review form

+

Public HTML form for reviewing or downloading a scoped customer package with a portal token. It does not accept tokens in URLs and does not use bearer authentication.

+
+
Operation ID
customerPortalPackageViewForm
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
none
+
Idempotency
not required
+
Request
none
+
Responses
200 text/html: string; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/customer-portal/package/view +
+

Customer portal package review page

+

Public HTML package review endpoint backed by the customer portal token exchange. Tokens are accepted only as form body fields.

+
+
Operation ID
customerPortalPackageView
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
none
+
Idempotency
Idempotency-Key
+
Request
application/x-www-form-urlencoded: CustomerPortalPackageRequest
+
Responses
200 text/html: string; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/customer-portal/package/view/download +
+

Download customer portal package ZIP from review form

+

Public HTML-form package ZIP download endpoint backed by the customer portal token exchange. Tokens are accepted only as form body fields.

+
+
Operation ID
downloadCustomerPortalPackageView
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
none
+
Idempotency
Idempotency-Key
+
Request
application/x-www-form-urlencoded: CustomerPortalPackageRequest
+
Responses
200 application/zip: string; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/deployments +
+

List deployments

+

Lists tenant-scoped deployment events by optional release and environment filters.

+
+
Operation ID
listDeployments
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
deployment:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: DeploymentEventListEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/deployments +
+

Record deployment

+

Records append-only deployment evidence for a release/environment/artifact set.

+
+
Operation ID
recordDeployment
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
deployment:write
+
Idempotency
Idempotency-Key
+
Request
application/json: RecordDeploymentRequest
+
Responses
201 application/json: DeploymentEventEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/deployments/{id} +
+

Get deployment

+

Returns a tenant-scoped deployment event by id.

+
+
Operation ID
getDeployment
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
deployment:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: DeploymentEventEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/dsse-trust-roots +
+

Create DSSE trust root

+

Creates a tenant-scoped DSSE trust root using public verification key material only.

+
+
Operation ID
createDSSETrustRoot
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
keys:admin
+
Idempotency
Idempotency-Key
+
Request
application/json: CreateDSSETrustRootRequest
+
Responses
201 application/json: DSSETrustRootEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/environments +
+

List deployment environments

+

Lists tenant-scoped deployment environments, optionally filtered by product.

+
+
Operation ID
listDeploymentEnvironments
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
deployment:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: DeploymentEnvironmentListEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/environments +
+

Create deployment environment

+

Creates a tenant-scoped deployment environment for release deployment evidence.

+
+
Operation ID
createDeploymentEnvironment
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
deployment:write
+
Idempotency
Idempotency-Key
+
Request
application/json: CreateDeploymentEnvironmentRequest
+
Responses
201 application/json: DeploymentEnvironmentEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/evidence +
+

List evidence

+

Lists tenant-scoped evidence by optional release and evidence type filters.

+
+
Operation ID
listEvidence
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
evidence:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: EvidenceItemListEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/evidence +
+

Create evidence

+

Creates immutable evidence metadata and optional raw payload evidence. Evidence core fields are append-only after creation.

+
+
Operation ID
createEvidence
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
evidence:write
+
Idempotency
Idempotency-Key
+
Request
application/json: CreateEvidenceRequest
+
Responses
201 application/json: EvidenceItemEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/evidence-bundles +
+

Export evidence bundle

+

Exports a portable evidence bundle manifest with hashes, signatures, and verification text.

+
+
Operation ID
exportEvidenceBundle
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
bundle:read
+
Idempotency
Idempotency-Key
+
Request
application/json: ExportEvidenceBundleRequest
+
Responses
201 application/json: EvidenceBundleEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/evidence-bundles/import +
+

Import evidence bundle

+

Imports a portable evidence bundle manifest and records deterministic import metadata.

+
+
Operation ID
importEvidenceBundle
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
bundle:write
+
Idempotency
Idempotency-Key
+
Request
application/json: EvidenceBundle
+
Responses
201 application/json: EvidenceBundleImportEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/evidence-graph-snapshots +
+

Create evidence graph snapshot

+

Creates a deterministic product/release evidence adjacency snapshot from stored tenant-scoped evidence records.

+
+
Operation ID
createGraphSnapshot
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
evidence:read
+
Idempotency
Idempotency-Key
+
Request
application/json: CreateGraphSnapshotRequest
+
Responses
201 application/json: EvidenceGraphSnapshotEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/evidence-summaries +
+

Create evidence-backed summary

+

Creates an evidence-backed summary with citations, assumptions, and limitations.

+
+
Operation ID
createEvidenceSummary
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
report:read
+
Idempotency
Idempotency-Key
+
Request
application/json: CreateEvidenceSummaryRequest
+
Responses
201 application/json: EvidenceSummaryEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/evidence/search +
+

Search evidence

+

Searches tenant-scoped evidence with deterministic filters and cursor-style pagination.

+
+
Operation ID
searchEvidence
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
evidence:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: EvidenceSearchEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/evidence/{id} +
+

Get evidence

+

Returns a tenant-scoped immutable evidence item by id.

+
+
Operation ID
getEvidence
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
evidence:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: EvidenceItemEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/evidence/{id}/lifecycle-events +
+

List evidence lifecycle events

+

Lists append-only lifecycle events for a tenant-scoped evidence item.

+
+
Operation ID
listEvidenceLifecycleEvents
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
evidence:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: EvidenceLifecycleEventListEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/evidence/{id}/lifecycle-events +
+

Record evidence lifecycle event

+

Appends an evidence lifecycle event such as amendment, redaction marker, tombstone, or retention marker.

+
+
Operation ID
recordEvidenceLifecycleEvent
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
evidence:write
+
Idempotency
Idempotency-Key
+
Request
application/json: RecordEvidenceLifecycleEventRequest
+
Responses
201 application/json: EvidenceLifecycleEventEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/evidence/{id}/link +
+

Link evidence

+

Creates an append-only relationship from evidence to another tenant-scoped subject.

+
+
Operation ID
linkEvidence
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
evidence:write
+
Idempotency
Idempotency-Key
+
Request
application/json: LinkEvidenceRequest
+
Responses
201 application/json: EvidenceItemEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/evidence/{id}/supersede +
+

Supersede evidence

+

Supersedes immutable evidence by linking it to replacement evidence and appending lifecycle metadata.

+
+
Operation ID
supersedeEvidence
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
evidence:write
+
Idempotency
Idempotency-Key
+
Request
application/json: SupersedeEvidenceRequest
+
Responses
201 application/json: EvidenceItemEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/exceptions +
+

List exceptions

+

Lists tenant-scoped exceptions, optionally filtered by release.

+
+
Operation ID
listExceptions
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
verify:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: ExceptionListEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/exceptions +
+

Create exception

+

Creates a scoped, expiring release/finding/control exception that is inactive until approved.

+
+
Operation ID
createException
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
release:write
+
Idempotency
Idempotency-Key
+
Request
application/json: CreateExceptionRequest
+
Responses
201 application/json: ExceptionEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/exceptions/{id}/approve +
+

Approve exception

+

Approves an unexpired exception as an audited append-only transition.

+
+
Operation ID
approveException
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
release:write
+
Idempotency
Idempotency-Key
+
Request
application/json: EmptyObject
+
Responses
200 application/json: ExceptionEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/health +
+

Health

+

Returns low-detail liveness status without touching tenant evidence or secret material.

+
+
Operation ID
health
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
none
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: HealthStatusEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/incident-webhooks/{receiver_id} +
+

Receive signed incident webhook event

+

Public signed webhook endpoint for incident timeline events. It verifies Ed25519 signature, event id replay, and timestamp before parsing payload fields.

+
+
Operation ID
receiveIncidentWebhook
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
none
+
Idempotency
not required
+
Request
application/json: SignedIncidentWebhookPayload
+
Responses
201 application/json: IncidentWebhookDeliveryEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/incidents +
+

Create incident

+

Creates an append-only incident record linked to tenant-scoped product and optional release evidence.

+
+
Operation ID
createIncident
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
incident:write
+
Idempotency
Idempotency-Key
+
Request
application/json: CreateIncidentRequest
+
Responses
201 application/json: IncidentEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/incidents/{id}/timeline +
+

Record incident timeline event

+

Appends an incident timeline event and optional evidence reference.

+
+
Operation ID
recordIncidentTimeline
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
incident:write
+
Idempotency
Idempotency-Key
+
Request
application/json: RecordIncidentTimelineRequest
+
Responses
201 application/json: IncidentTimelineEventEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/incidents/{id}/webhook-receivers +
+

Create signed incident webhook receiver

+

Creates an incident-scoped webhook receiver with an Ed25519 public key. The matching private key stays with the external incident tool.

+
+
Operation ID
createIncidentWebhookReceiver
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
incident:write
+
Idempotency
Idempotency-Key
+
Request
application/json: CreateIncidentWebhookReceiverRequest
+
Responses
201 application/json: IncidentWebhookReceiverEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/legal-holds +
+

Create legal hold

+

Creates an append-only legal-hold marker for a tenant-scoped retention subject.

+
+
Operation ID
createLegalHold
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
admin
+
Idempotency
Idempotency-Key
+
Request
application/json: CreateLegalHoldRequest
+
Responses
201 application/json: LegalHoldEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/marketplace-collectors +
+

List marketplace collector records

+

Lists tenant-scoped marketplace collector package metadata.

+
+
Operation ID
listMarketplaceCollectors
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
collector:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: MarketplaceCollectorListEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/marketplace-collectors +
+

Create marketplace collector record

+

Creates tenant-scoped marketplace collector package metadata and evidence references.

+
+
Operation ID
createMarketplaceCollector
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
collector:admin
+
Idempotency
Idempotency-Key
+
Request
application/json: CreateMarketplaceCollectorRequest
+
Responses
201 application/json: MarketplaceCollectorEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/marketplace-collectors/{id}/health +
+

Marketplace collector health report

+

Returns marketplace collector package health from recorded signature, SBOM, and scan evidence.

+
+
Operation ID
marketplaceCollectorHealth
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
collector:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: MarketplaceCollectorHealthReportEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/merkle-batches +
+

Create Merkle checkpoint batch

+

Creates a Merkle batch over tenant audit-chain entries for checkpoint export or transparency anchoring.

+
+
Operation ID
createMerkleBatch
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
keys:admin
+
Idempotency
Idempotency-Key
+
Request
application/json: CreateMerkleBatchRequest
+
Responses
201 application/json: MerkleBatchEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/merkle-batches/{id}/verify +
+

Verify Merkle checkpoint batch

+

Verifies a tenant-scoped Merkle batch root and leaf set against stored audit-chain entries.

+
+
Operation ID
verifyMerkleBatch
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
verify:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: VerificationResultEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/metrics +
+

Safe tenant metrics

+

Returns safe tenant-scoped resource metrics for admin actors. A Prometheus text response is also available when requested with Accept: text/plain.

+
+
Operation ID
metrics
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
admin
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: MetricsSnapshotEnvelope, text/plain: string; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/object-retention-policies +
+

Create object retention policy record

+

Creates an object retention policy record for storage immutability verification.

+
+
Operation ID
createObjectRetentionPolicy
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
admin
+
Idempotency
Idempotency-Key
+
Request
application/json: CreateObjectRetentionPolicyRequest
+
Responses
201 application/json: ObjectRetentionPolicyEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/object-retention-policies/{id}/verify +
+

Verify object retention policy record

+

Records verification metadata for a tenant object retention policy.

+
+
Operation ID
verifyObjectRetentionPolicy
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
verify:read
+
Idempotency
Idempotency-Key
+
Request
application/json: EmptyObject
+
Responses
200 application/json: ObjectRetentionPolicyEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/openapi-contracts +
+

Upload OpenAPI contract

+

Uploads an OpenAPI 3.1 contract, stores raw bytes as evidence, and records normalized operation metadata.

+
+
Operation ID
uploadOpenAPIContract
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
evidence:write
+
Idempotency
Idempotency-Key
+
Request
application/json: UploadOpenAPIContractRequest
+
Responses
201 application/json: OpenAPIContractEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/openapi-contracts/{id} +
+

Get OpenAPI contract

+

Returns a tenant-scoped OpenAPI contract metadata record by id.

+
+
Operation ID
getOpenAPIContract
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
evidence:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: OpenAPIContractEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/openapi-diffs +
+

Create OpenAPI contract diff

+

Creates a deterministic OpenAPI contract diff for release contract checks.

+
+
Operation ID
createOpenAPIDiff
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
evidence:read
+
Idempotency
Idempotency-Key
+
Request
application/json: CreateOpenAPIDiffRequest
+
Responses
201 application/json: ContractDiffEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/openapi.json +
+

OpenAPI

+

Returns the generated OpenAPI 3.1 document served by this process.

+
+
Operation ID
openapi
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
none
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: OpenAPIDocument; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/organizations +
+

Create organization

+

Creates a tenant-scoped organization record for human identity grouping.

+
+
Operation ID
createOrganization
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
identity:admin
+
Idempotency
Idempotency-Key
+
Request
application/json: CreateOrganizationRequest
+
Responses
201 application/json: OrganizationEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/policies/evaluate +
+

Evaluate release policy

+

Evaluates built-in deterministic release policy checks for a release.

+
+
Operation ID
evaluatePolicy
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
verify:read
+
Idempotency
Idempotency-Key
+
Request
application/json: EvaluatePolicyRequest
+
Responses
201 application/json: PolicyEvaluationEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/products +
+

List products

+

Lists tenant-scoped products visible to the authenticated actor.

+
+
Operation ID
listProducts
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
product:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: ProductListEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/products +
+

Create product

+

Creates a tenant-scoped product. Product slugs must be unique per tenant.

+
+
Operation ID
createProduct
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
product:write
+
Idempotency
Idempotency-Key
+
Request
application/json: CreateProductRequest
+
Responses
201 application/json: ProductEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/products/{id} +
+

Get product

+

Returns a tenant-scoped product by id.

+
+
Operation ID
getProduct
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
product:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: ProductEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/projects +
+

Create project

+

Creates a tenant-scoped project under a product.

+
+
Operation ID
createProject
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
project:write
+
Idempotency
Idempotency-Key
+
Request
application/json: CreateProjectRequest
+
Responses
201 application/json: ProjectEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/projects/{id} +
+

Get project

+

Returns a tenant-scoped project by id.

+
+
Operation ID
getProject
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
project:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: ProjectEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/provider-verifications +
+

Verify stored provider identity metadata

+

Verifies stored provider identity metadata and, when supplied, locally verifies OIDC ID-token or SAML assertion issuer, audience, subject, time bounds, and signature against configured tenant trust material.

+
+
Operation ID
verifyProviderIdentity
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
identity:admin
+
Idempotency
Idempotency-Key
+
Request
application/json: VerifyProviderIdentityRequest
+
Responses
201 application/json: ProviderVerificationEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/public-transparency-log-entries +
+

Publish public transparency log entry record

+

Records publication metadata for a checkpoint submitted to a configured public transparency log.

+
+
Operation ID
publishPublicTransparencyLogEntry
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
keys:admin
+
Idempotency
Idempotency-Key
+
Request
application/json: PublishPublicTransparencyLogEntryRequest
+
Responses
201 application/json: PublicTransparencyLogEntryEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/public-transparency-log-entries/{id}/fetch-proof +
+

Fetch and verify public transparency log inclusion proof

+

Fetches public transparency inclusion proof material from the configured log endpoint or transparency proof gateway and verifies it locally. Endpoint trust and provider semantics remain deployment responsibilities.

+
+
Operation ID
fetchPublicTransparencyLogEntryProof
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
keys:admin
+
Idempotency
Idempotency-Key
+
Request
application/json: EmptyObject
+
Responses
200 application/json: PublicTransparencyLogEntryEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/public-transparency-log-entries/{id}/verify +
+

Verify public transparency log inclusion proof

+

Verifies operator-supplied RFC6962-style public transparency inclusion proof material for a published entry.

+
+
Operation ID
verifyPublicTransparencyLogEntry
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
keys:admin
+
Idempotency
Idempotency-Key
+
Request
application/json: VerifyPublicTransparencyLogEntryRequest
+
Responses
200 application/json: PublicTransparencyLogEntryEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/public-transparency-logs +
+

Create public transparency log record

+

Creates tenant metadata for an optional public transparency log trust root.

+
+
Operation ID
createPublicTransparencyLog
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
keys:admin
+
Idempotency
Idempotency-Key
+
Request
application/json: CreatePublicTransparencyLogRequest
+
Responses
201 application/json: PublicTransparencyLogEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/questionnaire-answer-library +
+

List questionnaire answer library entries

+

Lists tenant-scoped questionnaire answer library entries with optional question, product, and release filters.

+
+
Operation ID
listQuestionnaireAnswerLibrary
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
package:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: QuestionnaireAnswerLibraryEntryListEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/questionnaire-answer-library +
+

Create questionnaire answer library entry

+

Creates a tenant-scoped reusable questionnaire answer draft linked to optional evidence, product, release, or control scope.

+
+
Operation ID
createQuestionnaireAnswerLibraryEntry
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
package:write
+
Idempotency
Idempotency-Key
+
Request
application/json: CreateQuestionnaireAnswerLibraryEntryRequest
+
Responses
201 application/json: QuestionnaireAnswerLibraryEntryEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/questionnaire-drafts +
+

Create evidence-backed questionnaire draft

+

Creates an evidence-backed questionnaire draft with limitations.

+
+
Operation ID
createQuestionnaireDraft
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
package:read
+
Idempotency
Idempotency-Key
+
Request
application/json: CreateQuestionnaireDraftRequest
+
Responses
201 application/json: QuestionnaireDraftEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/questionnaire-packages +
+

Create questionnaire package

+

Creates a questionnaire response package from a template and scoped evidence package.

+
+
Operation ID
createQuestionnairePackage
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
package:write
+
Idempotency
Idempotency-Key
+
Request
application/json: CreateQuestionnairePackageRequest
+
Responses
201 application/json: QuestionnairePackageEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/questionnaire-templates +
+

Create questionnaire template

+

Creates a tenant questionnaire template with explicit evidence/control mapping fields.

+
+
Operation ID
createQuestionnaireTemplate
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
package:write
+
Idempotency
Idempotency-Key
+
Request
application/json: CreateQuestionnaireTemplateRequest
+
Responses
201 application/json: QuestionnaireTemplateEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/ready +
+

Readiness

+

Returns low-detail process readiness without tenant evidence or secret material.

+
+
Operation ID
ready
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
none
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: ReadinessStatusEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/redaction-profiles +
+

Create redaction profile

+

Creates an explicit redaction profile for customer and report package generation.

+
+
Operation ID
createRedactionProfile
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
package:write
+
Idempotency
Idempotency-Key
+
Request
application/json: CreateRedactionProfileRequest
+
Responses
201 application/json: RedactionProfileEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/release-bundles +
+

Create release bundle

+

Creates an immutable signed release bundle for a release.

+
+
Operation ID
createReleaseBundle
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
bundle:write
+
Idempotency
Idempotency-Key
+
Request
application/json: CreateReleaseBundleRequest
+
Responses
201 application/json: ReleaseBundleEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/release-bundles/{id} +
+

Get release bundle

+

Returns a tenant-scoped immutable release bundle by id.

+
+
Operation ID
getReleaseBundle
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
bundle:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: ReleaseBundleEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/release-bundles/{id}/manifest +
+

Get release bundle manifest

+

Returns the deterministic release bundle manifest by bundle id.

+
+
Operation ID
getReleaseBundleManifest
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
bundle:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: ReleaseBundleManifestEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/release-bundles/{id}/verify +
+

Verify release bundle

+

Verifies a tenant-scoped release bundle and returns a deterministic verification result.

+
+
Operation ID
verifyReleaseBundle
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
verify:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: VerificationResultEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/release-candidates +
+

List release candidates

+

Lists tenant-scoped release candidates, optionally filtered by release.

+
+
Operation ID
listReleaseCandidates
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
release:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: ReleaseCandidateListEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/release-candidates +
+

Create release candidate

+

Creates an immutable release-candidate snapshot of selected release evidence references.

+
+
Operation ID
createReleaseCandidate
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
release:write
+
Idempotency
Idempotency-Key
+
Request
application/json: CreateReleaseCandidateRequest
+
Responses
201 application/json: ReleaseCandidateEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/release-candidates/{id} +
+

Get release candidate

+

Returns a tenant-scoped release candidate by id.

+
+
Operation ID
getReleaseCandidate
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
release:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: ReleaseCandidateEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/release-candidates/{id}/promote +
+

Promote release candidate

+

Records a release-candidate lifecycle transition without mutating the original snapshot.

+
+
Operation ID
promoteReleaseCandidate
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
release:write
+
Idempotency
Idempotency-Key
+
Request
application/json: ReleaseCandidateTransitionRequest
+
Responses
200 application/json: ReleaseCandidateEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/release-candidates/{id}/reject +
+

Reject release candidate

+

Records a release-candidate lifecycle transition without mutating the original snapshot.

+
+
Operation ID
rejectReleaseCandidate
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
release:write
+
Idempotency
Idempotency-Key
+
Request
application/json: ReleaseCandidateTransitionRequest
+
Responses
200 application/json: ReleaseCandidateEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/releases +
+

Create release

+

Creates an append-only release record under a product and optional project.

+
+
Operation ID
createRelease
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
release:write
+
Idempotency
Idempotency-Key
+
Request
application/json: CreateReleaseRequest
+
Responses
201 application/json: ReleaseEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/releases/{id} +
+

Get release

+

Returns a tenant-scoped release by id.

+
+
Operation ID
getRelease
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
release:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: ReleaseEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/releases/{id}/approve +
+

Approve release

+

Approves a release as an append-only transition.

+
+
Operation ID
approveRelease
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
release:write
+
Idempotency
Idempotency-Key
+
Request
application/json: EmptyObject
+
Responses
200 application/json: ReleaseEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/releases/{id}/evidence-flow/start +
+

Start release evidence flow

+

Returns a high-level release evidence workflow plan, current evidence counts, required scopes, assumptions, and limitations. This read-only convenience operation does not create evidence.

+
+
Operation ID
startReleaseEvidenceFlow
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
release:read
+
Idempotency
Idempotency-Key
+
Request
none
+
Responses
200 application/json: ReleaseEvidenceFlowEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/releases/{id}/freeze +
+

Freeze release

+

Freezes a release as an append-only transition.

+
+
Operation ID
freezeRelease
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
release:write
+
Idempotency
Idempotency-Key
+
Request
application/json: EmptyObject
+
Responses
200 application/json: ReleaseEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/releases/{id}/security-summary +
+

Release security summary

+

Returns a tenant-scoped release security summary for review surfaces without raw evidence payload bytes.

+
+
Operation ID
releaseSecuritySummary
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
report:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: ReleaseSecuritySummaryEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/remediation-tasks +
+

Create remediation task

+

Creates an incident or release remediation task linked to optional evidence.

+
+
Operation ID
createRemediationTask
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
incident:write
+
Idempotency
Idempotency-Key
+
Request
application/json: CreateRemediationTaskRequest
+
Responses
201 application/json: RemediationTaskEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/report-templates +
+

Create report template

+

Creates a tenant-defined deterministic report template with an explicit allowed-field list.

+
+
Operation ID
createReportTemplate
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
report:read
+
Idempotency
Idempotency-Key
+
Request
application/json: CreateReportTemplateRequest
+
Responses
201 application/json: CustomReportTemplateEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/report-templates/{id}/render +
+

Render report template

+

Renders a tenant report template for a scoped subject using allowed fields only.

+
+
Operation ID
renderReportTemplate
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
report:read
+
Idempotency
Idempotency-Key
+
Request
application/json: RenderReportTemplateRequest
+
Responses
201 application/json: RenderedCustomReportEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/reports/anomaly +
+

Generate deterministic anomaly report

+

Creates a deterministic anomaly report over existing tenant evidence and metrics with assumptions and limitations.

+
+
Operation ID
generateAnomalyReport
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
report:read
+
Idempotency
Idempotency-Key
+
Request
application/json: CreateAnomalyReportRequest
+
Responses
201 application/json: AnomalyReportEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/reports/control-coverage +
+

Control coverage report

+

Returns deterministic control coverage with linked evidence, missing evidence, assumptions, and limitations.

+
+
Operation ID
controlCoverageReport
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
report:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: ReadinessReportEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/reports/cra-readiness +
+

CRA readiness report

+

Returns a CRA-oriented readiness report without legal compliance or certification conclusions.

+
+
Operation ID
craReadinessReport
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
report:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: ReadinessReportEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/reports/cra-readiness-html +
+

CRA readiness HTML package

+

Creates a deterministic CRA-readiness HTML package without legal compliance or certification conclusions.

+
+
Operation ID
craReadinessHTMLPackage
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
report:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: HTMLReportPackageEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/reports/cra-vulnerability-handling +
+

CRA vulnerability handling report

+

Returns a CRA-oriented vulnerability handling evidence report without legal compliance, certification, scanner-authority, or release-security conclusions.

+
+
Operation ID
craVulnerabilityHandlingReport
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
report:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: CRAVulnerabilityHandlingReportEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/reports/custody-review +
+

Signing custody review report

+

Returns tenant signing-provider and object-lock verification metadata for deployment custody review. It is evidence metadata only, not legal compliance proof, certification, HSM custody proof, or a secure-deployment guarantee.

+
+
Operation ID
signingCustodyReviewReport
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
keys:admin
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: SigningCustodyReviewReportEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/reports/incident-package +
+

Incident package report

+

Returns a deterministic incident package report with timeline, remediation tasks, linked evidence, assumptions, and limitations.

+
+
Operation ID
incidentReport
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
incident:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: IncidentReportEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/reports/missing-evidence +
+

Missing evidence report

+

Returns a deterministic missing-evidence report for a release with assumptions and limitations.

+
+
Operation ID
missingEvidenceReport
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
verify:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: MissingEvidenceReportEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/reports/pdf +
+

Create reproducible PDF report package

+

Creates a deterministic PDF report package record and payload metadata.

+
+
Operation ID
createPDFReportPackage
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
report:read
+
Idempotency
Idempotency-Key
+
Request
application/json: CreatePDFReportPackageRequest
+
Responses
201 application/json: PDFReportPackageEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/reports/release-readiness +
+

Release readiness report

+

Returns a deterministic release-readiness report with gaps, assumptions, and limitations.

+
+
Operation ID
releaseReadinessReport
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
verify:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: ReadinessReportEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/reports/retention +
+

Retention report

+

Returns a retention report for tenant-scoped holds and overrides with storage verification limitations.

+
+
Operation ID
retentionReport
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
admin
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: RetentionReportEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/reports/security-review-package +
+

Security review package report

+

Returns a redaction-aware security-review package report with assumptions and limitations.

+
+
Operation ID
securityReviewPackageReport
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
package:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: SecurityReviewPackageReportEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/reports/security-update-evidence +
+

Security update evidence report

+

Returns a security update evidence report for release-scoped fixed decisions, incidents, remediation tasks, and linked evidence without legal or security conclusions.

+
+
Operation ID
securityUpdateEvidenceReport
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
report:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: SecurityUpdateEvidenceReportEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/reports/vulnerability-decision-summary +
+

Customer-safe vulnerability decision summary

+

Returns customer-safe active vulnerability decision summaries for a release with assumptions and limitations. Raw payloads and internal notes are excluded.

+
+
Operation ID
vulnerabilityDecisionSummaryReport
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
report:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: VulnerabilityDecisionSummaryReportEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/reports/vulnerability-posture +
+

Vulnerability posture report

+

Returns a vulnerability posture report derived from stored scan, decision, VEX, exception, and workflow records.

+
+
Operation ID
vulnerabilityPostureReport
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
security:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: VulnerabilityPostureReportEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/retention-overrides +
+

Create retention override

+

Creates an append-only retention override for a tenant-scoped retention subject.

+
+
Operation ID
createRetentionOverride
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
admin
+
Idempotency
Idempotency-Key
+
Request
application/json: CreateRetentionOverrideRequest
+
Responses
201 application/json: RetentionOverrideEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/role-bindings +
+

List role bindings

+

Lists tenant-scoped role bindings visible to the identity administrator.

+
+
Operation ID
listRoleBindings
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
identity:admin
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: RoleBindingListEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/role-bindings +
+

Create role binding

+

Creates a tenant-scoped role binding for a user or collector subject.

+
+
Operation ID
createRoleBinding
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
identity:admin
+
Idempotency
Idempotency-Key
+
Request
application/json: CreateRoleBindingRequest
+
Responses
201 application/json: RoleBindingEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/saas/profiles +
+

Create SaaS edition profile

+

Creates a SaaS edition profile record for future hosted deployment planning; it is not a production readiness claim.

+
+
Operation ID
createSaaSEditionProfile
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
instance:admin
+
Idempotency
Idempotency-Key
+
Request
application/json: CreateSaaSEditionProfileRequest
+
Responses
201 application/json: SaaSEditionProfileEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/sbom-components +
+

List SBOM components

+

Lists tenant-scoped SBOM components by SBOM, release, artifact, name/version/PURL query, or exact PURL.

+
+
Operation ID
listSBOMComponents
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
evidence:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: SBOMComponentRecordListEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/sbom-diffs +
+

Create SBOM diff

+

Creates a deterministic SBOM diff between two tenant-scoped SBOM records.

+
+
Operation ID
createSBOMDiff
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
evidence:read
+
Idempotency
Idempotency-Key
+
Request
application/json: CreateSBOMDiffRequest
+
Responses
201 application/json: SBOMDiffEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/sboms +
+

Upload CycloneDX SBOM

+

Uploads a CycloneDX SBOM payload, stores raw bytes in object storage, and records normalized SBOM metadata.

+
+
Operation ID
uploadSBOM
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
evidence:write
+
Idempotency
Idempotency-Key
+
Request
application/json: EvidenceUploadRequest
+
Responses
201 application/json: SBOMEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/sboms/spdx +
+

Upload SPDX SBOM

+

Uploads an SPDX JSON SBOM payload, stores raw bytes as evidence, and records normalized SBOM metadata.

+
+
Operation ID
uploadSPDXSBOM
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
evidence:write
+
Idempotency
Idempotency-Key
+
Request
application/json: UploadSPDXSBOMRequest
+
Responses
201 application/json: SBOMEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/sboms/{id} +
+

Get SBOM

+

Returns a tenant-scoped SBOM metadata record by id.

+
+
Operation ID
getSBOM
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
evidence:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: SBOMEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/security-documents +
+

Upload manual security document

+

Uploads sensitive manual security evidence such as threat model, security review, or penetration-test report metadata and raw payload reference.

+
+
Operation ID
uploadManualSecurityDocument
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
security:write
+
Idempotency
Idempotency-Key
+
Request
application/json: UploadManualSecurityDocumentRequest
+
Responses
201 application/json: ManualSecurityDocumentEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/security-scans +
+

Upload security scan

+

Uploads SAST, DAST, secret, license, or API security scan metadata and raw JSON payload evidence without exposing raw payload bytes in responses.

+
+
Operation ID
uploadSecurityScan
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
security:write
+
Idempotency
Idempotency-Key
+
Request
application/json: UploadSecurityScanRequest
+
Responses
201 application/json: SecurityScanEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/signing-keys +
+

List signing keys

+

Lists tenant signing public-key metadata without private key material.

+
+
Operation ID
listSigningKeys
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
verify:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: SigningKeyListEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/signing-keys/rotate +
+

Rotate signing key

+

Rotates the active tenant signing key and returns public-key metadata only.

+
+
Operation ID
rotateSigningKey
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
keys:admin
+
Idempotency
Idempotency-Key
+
Request
application/json: SigningKeyTransitionRequest
+
Responses
201 application/json: SigningKeyEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/signing-keys/{id}/revoke +
+

Revoke signing key

+

Revokes a tenant signing key as an audited lifecycle transition.

+
+
Operation ID
revokeSigningKey
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
keys:admin
+
Idempotency
Idempotency-Key
+
Request
application/json: SigningKeyTransitionRequest
+
Responses
200 application/json: SigningKeyEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/signing-operations +
+

Create signing provider operation receipt

+

Records an external signing operation receipt and checks payload/signature metadata without logging secrets. When the API is configured with a signing executor, external_signature may be omitted and the executor signs the payload hash.

+
+
Operation ID
createSigningOperation
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
keys:admin
+
Idempotency
Idempotency-Key
+
Request
application/json: CreateSigningOperationRequest
+
Responses
201 application/json: SigningOperationEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/signing-providers +
+

Create signing provider record

+

Creates signing provider metadata for external signing operations. Production private key material must not be supplied.

+
+
Operation ID
createSigningProvider
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
keys:admin
+
Idempotency
Idempotency-Key
+
Request
application/json: CreateSigningProviderRequest
+
Responses
201 application/json: SigningProviderEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/source/branches +
+

Record source branch

+

Records or updates source branch metadata and protected-branch snapshot hash.

+
+
Operation ID
upsertSourceBranch
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
source:write
+
Idempotency
Idempotency-Key
+
Request
application/json: UpsertSourceBranchRequest
+
Responses
201 application/json: SourceBranchEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/source/commits +
+

Record source commit

+

Records immutable source commit metadata and stores only a hash of the commit message.

+
+
Operation ID
recordSourceCommit
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
source:write
+
Idempotency
Idempotency-Key
+
Request
application/json: RecordSourceCommitRequest
+
Responses
201 application/json: SourceCommitEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/source/pull-requests +
+

Record pull request

+

Records pull-request review metadata linked to source repository evidence.

+
+
Operation ID
recordPullRequest
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
source:write
+
Idempotency
Idempotency-Key
+
Request
application/json: RecordPullRequestRequest
+
Responses
201 application/json: PullRequestEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/source/repositories +
+

List source repositories

+

Lists tenant-scoped source repositories, optionally filtered by project.

+
+
Operation ID
listSourceRepositories
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
source:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: SourceRepositoryListEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/source/repositories +
+

Create source repository

+

Creates a tenant-scoped source repository record.

+
+
Operation ID
createSourceRepository
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
source:write
+
Idempotency
Idempotency-Key
+
Request
application/json: CreateSourceRepositoryRequest
+
Responses
201 application/json: SourceRepositoryEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/sso/identity-links +
+

Link SSO identity

+

Links a verified provider subject to a tenant-scoped human user.

+
+
Operation ID
linkSSOIdentity
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
identity:admin
+
Idempotency
Idempotency-Key
+
Request
application/json: LinkSSOIdentityRequest
+
Responses
201 application/json: UserIdentityLinkEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/sso/logout +
+

Logout SSO session

+

Revokes the currently authenticated SSO session without requiring identity administrator privileges. API keys and collector keys cannot use this route.

+
+
Operation ID
logoutSSOSession
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
none
+
Idempotency
Idempotency-Key
+
Request
application/json: EmptyObject
+
Responses
200 application/json: SSOSessionEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/sso/providers +
+

Create SSO provider

+

Records tenant SSO provider metadata. Optional static JWKS public keys and SAML signing certificates can be supplied for local token/assertion verification without live provider calls.

+
+
Operation ID
createSSOProvider
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
identity:admin
+
Idempotency
Idempotency-Key
+
Request
application/json: CreateSSOProviderRequest
+
Responses
201 application/json: SSOProviderEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/sso/providers/{id}/discover-oidc +
+

Refresh SSO provider OIDC trust material

+

Fetches the OIDC discovery document and public JWKS for the tenant provider issuer, then stores refreshed public trust material. This does not authenticate users, store provider secrets, or synchronize groups.

+
+
Operation ID
refreshSSOProviderOIDCTrustMaterial
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
identity:admin
+
Idempotency
Idempotency-Key
+
Request
application/json: EmptyObject
+
Responses
200 application/json: SSOProviderEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/sso/providers/{id}/trust-material +
+

Update SSO provider trust material

+

Rotates tenant SSO provider public trust material for local OIDC ID-token or SAML assertion verification without storing provider secrets.

+
+
Operation ID
updateSSOProviderTrustMaterial
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
identity:admin
+
Idempotency
Idempotency-Key
+
Request
application/json: UpdateSSOProviderTrustMaterialRequest
+
Responses
200 application/json: SSOProviderEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/sso/session-exchanges +
+

Exchange SSO credential

+

Exchanges a locally verified OIDC ID token or SAML assertion for an SSO session and HttpOnly browser cookie using configured tenant trust material and verified identity links. No live provider API or group synchronization call is made.

+
+
Operation ID
exchangeSSOCredential
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
none
+
Idempotency
not required
+
Request
application/json: ExchangeSSOCredentialRequest
+
Responses
201 application/json: SSOCredentialExchangeEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/sso/sessions +
+

Create SSO session

+

Creates an admin-managed human SSO session record and returns a one-time bearer secret.

+
+
Operation ID
createSSOSession
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
identity:admin
+
Idempotency
Idempotency-Key
+
Request
application/json: CreateSSOSessionRequest
+
Responses
201 application/json: SSOSessionCreateEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/sso/sessions/{id}/revoke +
+

Revoke SSO session

+

Revokes a tenant-scoped SSO session as an audited lifecycle transition.

+
+
Operation ID
revokeSSOSession
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
identity:admin
+
Idempotency
Idempotency-Key
+
Request
application/json: EmptyObject
+
Responses
200 application/json: SSOSessionEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/transparency-checkpoints +
+

Record external transparency checkpoint

+

Records an external transparency or timestamp checkpoint reference for a Merkle batch.

+
+
Operation ID
createTransparencyCheckpoint
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
keys:admin
+
Idempotency
Idempotency-Key
+
Request
application/json: CreateTransparencyCheckpointRequest
+
Responses
201 application/json: TransparencyCheckpointEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/users +
+

Create user

+

Creates a tenant-scoped human user metadata record. Authentication is still controlled by API keys or configured SSO/session flows.

+
+
Operation ID
createUser
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
identity:admin
+
Idempotency
Idempotency-Key
+
Request
application/json: CreateUserRequest
+
Responses
201 application/json: HumanUserEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/users/{id}/deactivate +
+

Deactivate user

+

Deactivates a tenant-scoped human user as an audited lifecycle transition.

+
+
Operation ID
deactivateUser
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
identity:admin
+
Idempotency
Idempotency-Key
+
Request
application/json: EmptyObject
+
Responses
200 application/json: HumanUserEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/verify +
+

Verify subject

+

Verifies a supported tenant-scoped subject such as evidence, audit chain, release bundle, artifact signature, or related verification target.

+
+
Operation ID
verify
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
verify:read
+
Idempotency
Idempotency-Key
+
Request
application/json: VerifySubjectRequest
+
Responses
200 application/json: VerificationResultEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/version +
+

Version

+

Returns the API process version string.

+
+
Operation ID
version
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
none
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: VersionInfoEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/vex +
+

Upload OpenVEX document

+

Uploads VEX payload bytes, stores raw evidence in object storage, and records normalized VEX metadata and decisions where applicable.

+
+
Operation ID
uploadVEX
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
evidence:write
+
Idempotency
Idempotency-Key
+
Request
application/json: EvidenceUploadRequest
+
Responses
201 application/json: VEXDocumentEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/vex/cyclonedx +
+

Upload CycloneDX VEX document

+

Uploads VEX payload bytes, stores raw evidence in object storage, and records normalized VEX metadata and decisions where applicable.

+
+
Operation ID
uploadCycloneDXVEX
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
evidence:write
+
Idempotency
Idempotency-Key
+
Request
application/json: EvidenceUploadRequest
+
Responses
201 application/json: VEXDocumentEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/vex/cyclonedx/preview +
+

Preview CycloneDX VEX import

+

Validates a CycloneDX VEX payload and returns advisory mapping counts without storing raw payloads, creating evidence, creating decisions, or enqueueing parser jobs.

+
+
Operation ID
previewCycloneDXVEXImport
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
evidence:read
+
Idempotency
not required
+
Request
application/json: EvidenceUploadRequest
+
Responses
200 application/json: VEXImportPreviewEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/vex/preview +
+

Preview OpenVEX import

+

Validates an OpenVEX payload and returns advisory mapping counts without storing raw payloads, creating evidence, creating decisions, or enqueueing parser jobs.

+
+
Operation ID
previewVEXImport
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
evidence:read
+
Idempotency
not required
+
Request
application/json: EvidenceUploadRequest
+
Responses
200 application/json: VEXImportPreviewEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/vex/{id} +
+

Get VEX document

+

Returns a tenant-scoped VEX document metadata record by id.

+
+
Operation ID
getVEX
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
evidence:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: VEXDocumentEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/vex/{id}/import-report +
+

Get VEX import report

+

Returns the persisted parser report for a tenant-scoped VEX import, including counts, warnings, and mapping failures without raw payload bytes.

+
+
Operation ID
getVEXImportReport
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
evidence:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: VEXImportReportEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/vulnerability-decisions +
+

List vulnerability decisions

+

Lists append-only vulnerability decisions over time with tenant-scoped product, release, vulnerability, component, status, and active filters.

+
+
Operation ID
listVulnerabilityDecisions
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
evidence:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: VulnerabilityDecisionListEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/vulnerability-findings/{id}/decisions +
+

Create vulnerability decision

+

Creates an append-only vulnerability decision for a tenant-scoped scan finding.

+
+
Operation ID
createVulnerabilityDecision
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
evidence:write
+
Idempotency
Idempotency-Key
+
Request
application/json: CreateVulnerabilityDecisionRequest
+
Responses
201 application/json: VulnerabilityDecisionEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/vulnerability-findings/{id}/workflow +
+

Record vulnerability workflow event

+

Records an append-only vulnerability workflow event for a tenant-scoped finding.

+
+
Operation ID
recordVulnerabilityWorkflow
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
security:write
+
Idempotency
Idempotency-Key
+
Request
application/json: RecordVulnerabilityWorkflowRequest
+
Responses
201 application/json: VulnerabilityWorkflowRecordEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/vulnerability-scans +
+

Upload vulnerability scan

+

Uploads a generic vulnerability scan JSON payload and records normalized findings.

+
+
Operation ID
uploadVulnerabilityScan
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
evidence:write
+
Idempotency
Idempotency-Key
+
Request
application/json: UploadVulnerabilityScanRequest
+
Responses
201 application/json: VulnerabilityScanEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ GET + /v1/vulnerability-scans/{id} +
+

Get vulnerability scan

+

Returns a tenant-scoped vulnerability scan by id.

+
+
Operation ID
getVulnerabilityScan
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
evidence:read
+
Idempotency
not required
+
Request
none
+
Responses
200 application/json: VulnerabilityScanEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/waivers +
+

Create waiver

+

Creates a first-class scoped waiver for controls or policies. Approval is a separate audited transition.

+
+
Operation ID
createWaiver
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
policy:write
+
Idempotency
Idempotency-Key
+
Request
application/json: CreateWaiverRequest
+
Responses
201 application/json: WaiverEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ POST + /v1/waivers/{id}/approve +
+

Approve waiver

+

Approves an unexpired waiver as an audited transition.

+
+
Operation ID
approveWaiver
+
Tags
evydence
+
Auth
BearerAuth
+
Scopes
policy:write
+
Idempotency
Idempotency-Key
+
Request
application/json: EmptyObject
+
Responses
200 application/json: WaiverEnvelope; 400 application/problem+json: Problem; 401 application/problem+json: Problem; 403 application/problem+json: Problem; 404 application/problem+json: Problem; 409 application/problem+json: Problem; 422 application/problem+json: Problem
+
+
+
+
+ +
+

Schemas

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameShapeRequired fields
APIKeyobjectid, tenant_id, name, prefix, scopes, created_at
APIKeyCreateEnvelopeobjectdata, meta
APIKeyCreateResponseobjectapi_key, secret
APIKeyListEnvelopeobjectdata, meta
AnomalyReportobjectid, tenant_id, subject_type, subject_id, result, assumptions, limitations, schema_version, created_at
AnomalyReportEnvelopeobjectdata, meta
AnomalySignalobjectname, severity, detail
ApprovalRecordobjectid, tenant_id, subject_type, subject_id, decision, reason, approver_id, schema_version, created_at
ApprovalRecordEnvelopeobjectdata, meta
Artifactobjectid, tenant_id, name, digest, schema_version, created_at
ArtifactEnvelopeobjectdata, meta
ArtifactSignatureobjectid, tenant_id, artifact_id, subject_digest, algorithm, signature, verification_status, schema_version, created_at
ArtifactSignatureEnvelopeobjectdata, meta
AuditChainEntryobjectid, tenant_id, sequence, entry_type, subject_type, subject_id, actor_type, actor_id, occurred_at, canonical_entry_hash, previous_entry_hash, entry_hash, schema_version
AuditChainEntryListEnvelopeobjectdata, meta
BackupManifestobjectid, tenant_id, state_hash, resource_counts, consistency_checks, limitations, schema_version, created_at
BackupManifestEnvelopeobjectdata, meta
BuildAttestationobjectid, tenant_id, build_id, evidence_id, payload_hash, payload_size, payload_type, predicate_type, subject_digests, signature_count, verification_status, schema_version, created_at
BuildAttestationEnvelopeobjectdata, meta
BuildRunobjectid, tenant_id, project_id, release_id, provider, commit_sha, status, schema_version, created_at
BuildRunEnvelopeobjectdata, meta
CRAVulnerabilityHandlingReportobjectreport_type, template_version, product_id, release_id, summary, assumptions, limitations, generated_at
CRAVulnerabilityHandlingReportEnvelopeobjectdata, meta
Collectorobjectid, tenant_id, name, type, version, api_key_id, status, allowed_scopes, schema_version, created_at
CollectorCreateEnvelopeobjectdata, meta
CollectorCreateResponseobjectcollector, api_key, secret
CollectorHealthReportobjectreport_type, collector_id, collector_status, supply_chain_status, checks, assumptions, limitations, generated_at
CollectorHealthReportEnvelopeobjectdata, meta
CollectorListEnvelopeobjectdata, meta
CollectorReleaseobjectid, tenant_id, collector_id, version, artifact_digest, pinned, verification_status, health_status, schema_version, created_at
CollectorReleaseEnvelopeobjectdata, meta
CommercialCollectorDefinitionobjectid, tenant_id, name, provider, version, manifest_hash, allowed_scopes, status, schema_version, created_at
CommercialCollectorDefinitionEnvelopeobjectdata, meta
CommercialCollectorDefinitionListEnvelopeobjectdata, meta
ContainerImageobjectid, tenant_id, repository, digest, schema_version, created_at
ContainerImageEnvelopeobjectdata, meta
ContractDiffobjectid, tenant_id, base_contract_id, target_contract_id, product_id, result, schema_version, created_at
ContractDiffEnvelopeobjectdata, meta
ControlEvidenceobjectid, tenant_id, control_id, evidence_type, subject_type, subject_id, confidence, schema_version, created_at
ControlEvidenceEnvelopeobjectdata, meta
ControlEvidenceListEnvelopeobjectdata, meta
ControlEvidenceRequirementobjecttype, required
ControlFrameworkobjectid, tenant_id, name, slug, version, status, schema_version, created_at
ControlFrameworkEnvelopeobjectdata, meta
ControlFrameworkListEnvelopeobjectdata, meta
ControlFrameworkTemplatePackobjectid, name, slug, version, controls, schema_version
ControlFrameworkTemplatePackListEnvelopeobjectdata, meta
CosignVerificationobjectid, tenant_id, artifact_signature_id, subject_digest, result, checks, schema_version, created_at
CosignVerificationEnvelopeobjectdata, meta
CreateAPIKeyRequestobjectname, scopes
CreateAnomalyReportRequestobjectsubject_type, subject_id
CreateApprovalRequestobjectsubject_type, subject_id, decision, reason
CreateArtifactSignatureRequestobjectartifact_id, algorithm, signature
CreateBuildRequestobjectproject_id, release_id, provider, commit_sha, status, started_at
CreateCollectorRequestobjectname, type, version
CreateCommercialCollectorRequestobjectname, provider, version, manifest_hash
CreateControlFrameworkRequestobjectname, version
CreateCustomPolicyRequestobjectname, version, rules
CreateCustomerPackageRequestobjectproduct_id, redaction_profile_id, title, expires_at
CreateCustomerPortalAccessRequestobjectpackage_id, customer_name, expires_at
CreateDSSETrustRootRequestobjectname, key_id, algorithm, public_key
CreateDeploymentEnvironmentRequestobjectproduct_id, name, kind
CreateEvidenceRequestobjecttype, title, payload_hash
CreateEvidenceSummaryRequestobjectsubject_type, subject_id, evidence_ids
CreateExceptionRequestobjectrelease_id, reason, owner, expires_at
CreateGraphSnapshotRequestobjectnone
CreateIncidentRequestobjectproduct_id, title, severity
CreateIncidentWebhookReceiverRequestobjectname, provider, public_key
CreateLegalHoldRequestobjectscope_type, scope_id, reason, owner
CreateMarketplaceCollectorRequestobjectname, provider, version, publisher, manifest_hash
CreateMerkleBatchRequestobjectnone
CreateObjectRetentionPolicyRequestobjectname, mode, retention_days
CreateOpenAPIDiffRequestobjectbase_contract_id, target_contract_id, release_id
CreateOrganizationRequestobjectname, slug
CreatePDFReportPackageRequestobjectreport_type, title
CreateProductRequestobjectname, slug
CreateProjectRequestobjectproduct_id, name, slug
CreatePublicTransparencyLogRequestobjectname, endpoint, public_key
CreateQuestionnaireAnswerLibraryEntryRequestobjectanswer
CreateQuestionnaireDraftRequestobjecttemplate_id
CreateQuestionnairePackageRequestobjecttemplate_id
CreateQuestionnaireTemplateRequestobjectname, version, questions
CreateRedactionProfileRequestobjectnone
CreateReleaseBundleRequestobjectrelease_id
CreateReleaseCandidateRequestobjectrelease_id, name
CreateReleaseRequestobjectproduct_id, version
CreateRemediationTaskRequestobjecttitle, owner
CreateReportTemplateRequestobjectname, version, report_type, allowed_fields, template
CreateRetentionOverrideRequestobjectscope_type, scope_id, retention_until, reason, owner
CreateRoleBindingRequestobjectsubject_type, subject_id, role
CreateSBOMDiffRequestobjectbase_sbom_id, target_sbom_id
CreateSSOProviderRequestobjectname, type, issuer, client_id
CreateSSOSessionRequestobjectuser_id, provider_id, expires_at
CreateSaaSEditionProfileRequestobjectname, region, admin_tenant_id, isolation_model
CreateSecurityControlRequestobjectframework_id, code, title, objective
CreateSigningOperationRequestobjectprovider_id, subject_type, subject_id, payload_hash
CreateSigningProviderRequestobjectname, type, key_ref
CreateSourceRepositoryRequestobjectprovider, full_name
CreateTransparencyCheckpointRequestobjectbatch_id, provider
CreateUserRequestobjectemail, display_name
CreateVulnerabilityDecisionRequestobjectstatus, justification
CreateWaiverRequestobjectscope_type, scope_id, owner, risk, reason, expires_at
CustomPolicyobjectid, tenant_id, name, version, rules, schema_version, created_at
CustomPolicyEnvelopeobjectdata, meta
CustomPolicyEvaluationobjectid, tenant_id, policy_id, release_id, result, checks, input_hash, schema_version, created_at
CustomPolicyEvaluationEnvelopeobjectdata, meta
CustomReportTemplateobjectid, tenant_id, name, version, report_type, allowed_fields, template, schema_version, created_at
CustomReportTemplateEnvelopeobjectdata, meta
CustomerPortalAccessobjectid, tenant_id, package_id, customer_name, prefix, expires_at, access_count, failed_access_count, schema_version, created_at
CustomerPortalAccessCreateEnvelopeobjectdata, meta
CustomerPortalAccessCreateResponseobjectaccess, secret
CustomerPortalAccessEnvelopeobjectdata, meta
CustomerPortalAccessListEnvelopeobjectdata, meta
CustomerPortalPackageRequestobjecttoken
CustomerSecurityPackageobjectid, tenant_id, product_id, redaction_profile_id, title, state, manifest, manifest_hash, expires_at, access_count, schema_version, created_at
CustomerSecurityPackageEnvelopeobjectdata, meta
DSSEEnvelopeobjectpayloadType, payload, signatures
DSSETrustRootobjectid, tenant_id, name, key_id, algorithm, public_key, status, schema_version, created_at
DSSETrustRootEnvelopeobjectdata, meta
DataEnvelopeobjectdata, meta
DependencyChangeobjectid, tenant_id, sbom_diff_id, change_type, component, schema_version, created_at
DeploymentEnvironmentobjectid, tenant_id, product_id, name, kind, schema_version, created_at
DeploymentEnvironmentEnvelopeobjectdata, meta
DeploymentEnvironmentListEnvelopeobjectdata, meta
DeploymentEventobjectid, tenant_id, environment_id, release_id, status, started_at, schema_version, created_at
DeploymentEventEnvelopeobjectdata, meta
DeploymentEventListEnvelopeobjectdata, meta
EmptyObjectobjectnone
EvaluatePolicyRequestobjectrelease_id
EvidenceBundleobjectid, tenant_id, evidence_ids, manifest, manifest_hash, verification_text, schema_version, created_at
EvidenceBundleEnvelopeobjectdata, meta
EvidenceBundleImportobjectid, tenant_id, bundle_hash, result, imported_count, schema_version, created_at
EvidenceBundleImportEnvelopeobjectdata, meta
EvidenceCitationobjectevidence_id, type, title, canonical_hash
EvidenceGraphEdgeobjectfrom, to, relationship
EvidenceGraphNodeobjectid, type, label
EvidenceGraphSnapshotobjectid, tenant_id, nodes, edges, graph_hash, limitations, schema_version, created_at
EvidenceGraphSnapshotEnvelopeobjectdata, meta
EvidenceItemobjectid, tenant_id, type, title, source_system, observed_at, evidence_version, schema_version, payload_hash, canonical_hash, canonicalization, trust_level, verification_status, chain_entry_id, created_at
EvidenceItemEnvelopeobjectdata, meta
EvidenceItemListEnvelopeobjectdata, meta
EvidenceLifecycleEventobjectid, tenant_id, evidence_id, action, reason, actor_id, schema_version, created_at
EvidenceLifecycleEventEnvelopeobjectdata, meta
EvidenceLifecycleEventListEnvelopeobjectdata, meta
EvidenceNoticeobjectcode, message
EvidenceRefobjecttype, id
EvidenceSearchEnvelopeobjectdata, meta
EvidenceSearchResponseobjectitems
EvidenceSummaryobjectid, tenant_id, subject_type, subject_id, evidence_ids, summary, citations, assumptions, limitations, schema_version, created_at
EvidenceSummaryEnvelopeobjectdata, meta
EvidenceUploadRequestobjectrelease_id, payload
Exceptionobjectid, tenant_id, release_id, reason, owner, expires_at, approved, created_at
ExceptionEnvelopeobjectdata, meta
ExceptionListEnvelopeobjectdata, meta
ExchangeSSOCredentialRequestobjectprovider_id, subject
ExportEvidenceBundleRequestobjectnone
HTMLReportPackageobjectid, tenant_id, report_type, product_id, html, hash, schema_version, created_at
HTMLReportPackageEnvelopeobjectdata, meta
HealthStatusobjectstatus
HealthStatusEnvelopeobjectdata, meta
HumanUserobjectid, tenant_id, email, display_name, status, schema_version, created_at
HumanUserEnvelopeobjectdata, meta
Incidentobjectid, tenant_id, product_id, title, severity, status, opened_at, schema_version, created_at
IncidentEnvelopeobjectdata, meta
IncidentReportobjectreport_type, template_version, incident_id, result, timeline, tasks, assumptions, limitations, generated_at
IncidentReportEnvelopeobjectdata, meta
IncidentTimelineEventobjectid, tenant_id, incident_id, event_type, summary, occurred_at, schema_version, created_at
IncidentTimelineEventEnvelopeobjectdata, meta
IncidentWebhookDeliveryobjectwebhook_event, timeline_event
IncidentWebhookDeliveryEnvelopeobjectdata, meta
IncidentWebhookEventobjectid, tenant_id, receiver_id, incident_id, provider, event_id, payload_hash, signature_hash, result, schema_version, created_at
IncidentWebhookReceiverobjectid, tenant_id, incident_id, name, provider, public_key, status, schema_version, created_at
IncidentWebhookReceiverEnvelopeobjectdata, meta
InstanceAdminSnapshotobjectreport_type, tenant_count, resource_counts, limitations, generated_at
InstanceAdminSnapshotEnvelopeobjectdata, meta
LegalHoldobjectid, tenant_id, scope_type, scope_id, reason, owner, schema_version, created_at
LegalHoldEnvelopeobjectdata, meta
LinkControlEvidenceRequestobjectevidence_type, subject_type, subject_id, confidence
LinkEvidenceRequestobjecttarget_type, target_id
LinkSSOIdentityRequestobjectuser_id, provider_id, subject, email, verified
ManualSecurityDocumentobjectid, tenant_id, document_type, title, sensitivity, evidence_id, payload_hash, schema_version, created_at
ManualSecurityDocumentEnvelopeobjectdata, meta
MarketplaceCollectorobjectid, tenant_id, name, provider, version, publisher, manifest_hash, state, schema_version, created_at
MarketplaceCollectorEnvelopeobjectdata, meta
MarketplaceCollectorHealthReportobjectreport_type, collector_id, name, provider, version, supply_chain_status, checks, collector, assumptions, limitations, generated_at
MarketplaceCollectorHealthReportEnvelopeobjectdata, meta
MarketplaceCollectorListEnvelopeobjectdata, meta
MerkleBatchobjectid, tenant_id, from_sequence, to_sequence, entry_count, leaf_hashes, root_hash, schema_version, created_at
MerkleBatchEnvelopeobjectdata, meta
MetricsSnapshotobjecttenant_id, resource_counts, customer_portal_failed_access_count, customer_portal_revoked_access_count
MetricsSnapshotEnvelopeobjectdata, meta
MissingEvidenceReportobjectreport_type, template_version, release_id, result, missing, assumptions, limitations
MissingEvidenceReportEnvelopeobjectdata, meta
ObjectRetentionPolicyobjectid, tenant_id, name, object_prefix, mode, retention_days, status, schema_version, created_at
ObjectRetentionPolicyEnvelopeobjectdata, meta
OpenAPIContractobjectid, tenant_id, product_id, version, hash, path_count, evidence_id, created_at
OpenAPIContractEnvelopeobjectdata, meta
OpenAPIDocumentobjectopenapi, info, paths
OpenAPIOperationRecordobjectpath, method
Organizationobjectid, tenant_id, name, slug, status, schema_version, created_at
OrganizationEnvelopeobjectdata, meta
PDFReportPackageobjectid, tenant_id, report_type, title, payload_hash, payload_size, limitations, schema_version, created_at
PDFReportPackageEnvelopeobjectdata, meta
PolicyCheckobjectname, result, severity, explanation
PolicyEvaluationobjectid, tenant_id, release_id, result, policy_set, checks, created_at
PolicyEvaluationEnvelopeobjectdata, meta
PolicyRuleobjectname, severity, required
Problemobjectnone
Productobjectid, tenant_id, name, slug, schema_version, created_at
ProductEnvelopeobjectdata, meta
ProductListEnvelopeobjectdata, meta
Projectobjectid, tenant_id, product_id, name, slug, schema_version, created_at
ProjectEnvelopeobjectdata, meta
ProviderVerificationobjectid, tenant_id, provider_type, provider_id, subject, result, checks, limitations, schema_version, created_at
ProviderVerificationEnvelopeobjectdata, meta
PublicTransparencyLogobjectid, tenant_id, name, endpoint, public_key, state, schema_version, created_at
PublicTransparencyLogEntryobjectid, tenant_id, log_id, checkpoint_id, merkle_batch_id, external_id, entry_hash, state, schema_version, created_at
PublicTransparencyLogEntryEnvelopeobjectdata, meta
PublicTransparencyLogEnvelopeobjectdata, meta
PublishPublicTransparencyLogEntryRequestobjectlog_id, checkpoint_id, external_id
PullRequestobjectid, tenant_id, repository_id, provider, provider_id, state, schema_version, created_at
PullRequestEnvelopeobjectdata, meta
QuestionnaireAnswerLibraryEntryobjectid, tenant_id, answer, schema_version, created_at
QuestionnaireAnswerLibraryEntryEnvelopeobjectdata, meta
QuestionnaireAnswerLibraryEntryListEnvelopeobjectdata, meta
QuestionnaireDraftobjectid, tenant_id, template_id, responses, manifest_hash, limitations, schema_version, created_at
QuestionnaireDraftEnvelopeobjectdata, meta
QuestionnairePackageobjectid, tenant_id, template_id, responses, manifest_hash, schema_version, created_at
QuestionnairePackageEnvelopeobjectdata, meta
QuestionnaireQuestionobjectid, prompt
QuestionnaireResponseobjectquestion_id, answer
QuestionnaireTemplateobjectid, tenant_id, name, version, questions, schema_version, created_at
QuestionnaireTemplateEnvelopeobjectdata, meta
ReadinessReportobjectreport_type, template_version, result, assumptions, limitations, generated_at
ReadinessReportEnvelopeobjectdata, meta
ReadinessStatusobjectstatus, checks
ReadinessStatusEnvelopeobjectdata, meta
RecordCollectorReleaseRequestobjectversion, artifact_digest
RecordDeploymentRequestobjectenvironment_id, release_id, status, started_at
RecordEvidenceLifecycleEventRequestobjectaction, reason
RecordIncidentTimelineRequestobjectevent_type, summary
RecordPullRequestRequestobjectrepository_id, provider, provider_id, title, state
RecordSourceCommitRequestobjectrepository_id, sha, committed_at
RecordVulnerabilityWorkflowRequestobjectaction, reason
RedactionProfileobjectid, tenant_id, name, schema_version, created_at
RedactionProfileEnvelopeobjectdata, meta
RegisterArtifactRequestobjectname, digest
RegisterContainerImageRequestobjectrepository, digest
Releaseobjectid, tenant_id, product_id, version, status, schema_version, created_at
ReleaseBundleobjectid, tenant_id, release_id, state, manifest, manifest_hash, signature_refs, created_at
ReleaseBundleEnvelopeobjectdata, meta
ReleaseBundleManifestobjectmanifest_version, bundle_id, tenant_id, release, evidence_ids, chain_checkpoint, generated_at, generator
ReleaseBundleManifestEnvelopeobjectdata, meta
ReleaseBundleVerificationobjectresult
ReleaseBundleVerificationEnvelopeobjectdata, meta
ReleaseCandidateobjectid, tenant_id, release_id, name, state, snapshot_hash, schema_version, created_at
ReleaseCandidateEnvelopeobjectdata, meta
ReleaseCandidateListEnvelopeobjectdata, meta
ReleaseCandidateTransitionRequestobjectnone
ReleaseEnvelopeobjectdata, meta
ReleaseEvidenceFlowobjectrelease_id, product_id, status, counts, steps, assumptions, limitations, schema_version, generated_at
ReleaseEvidenceFlowEnvelopeobjectdata, meta
ReleaseEvidenceFlowStepobjectid, title, status, required, method, path, required_scopes, idempotency_required, description
ReleaseSecurityApprovalSummaryobjecttotal, approved
ReleaseSecurityExceptionSummaryobjecttotal, approved_unexpired, unapproved, expired
ReleaseSecurityMissingDecisionobjectfinding_id, scan_id, vulnerability, severity, state
ReleaseSecurityProductSummaryobjectid, name, slug
ReleaseSecurityReleaseSummaryobjectid, version, state
ReleaseSecuritySummaryobjectproduct, release, artifact_count, sbom_status, vulnerability_scan_status, open_findings_by_severity, decisions_by_status, approval_summary, exception_summary, readiness_status, package_status, counts, assumptions, limitations, schema_version, generated_at
ReleaseSecuritySummaryEnvelopeobjectdata, meta
RemediationTaskobjectid, tenant_id, title, owner, status, schema_version, created_at
RemediationTaskEnvelopeobjectdata, meta
RenderReportTemplateRequestobjectsubject_type, subject_id
RenderedCustomReportobjectid, tenant_id, template_id, subject_type, subject_id, output, hash, schema_version, created_at
RenderedCustomReportEnvelopeobjectdata, meta
RetentionOverrideobjectid, tenant_id, scope_type, scope_id, retention_until, reason, owner, schema_version, created_at
RetentionOverrideEnvelopeobjectdata, meta
RetentionReportobjectreport_type, legal_holds, retention_overrides, limitations, generated_at
RetentionReportEnvelopeobjectdata, meta
RoleBindingobjectid, tenant_id, subject_type, subject_id, role, schema_version, created_at
RoleBindingEnvelopeobjectdata, meta
RoleBindingListEnvelopeobjectdata, meta
SBOMobjectid, tenant_id, evidence_id, release_id, format, component_count, created_at
SBOMComponentobjectname
SBOMComponentRecordobjectsbom_id, component
SBOMComponentRecordListEnvelopeobjectdata, meta
SBOMDiffobjectid, tenant_id, base_sbom_id, target_sbom_id, unchanged_count, schema_version, created_at
SBOMDiffEnvelopeobjectdata, meta
SBOMEnvelopeobjectdata, meta
SSOCredentialExchangeEnvelopeobjectdata, meta
SSOCredentialExchangeResponseobjectverification, session, secret
SSOProviderobjectid, tenant_id, name, type, issuer, client_id, status, schema_version, created_at
SSOProviderEnvelopeobjectdata, meta
SSOSessionobjectid, tenant_id, user_id, provider_id, prefix, expires_at, schema_version, created_at
SSOSessionCreateEnvelopeobjectdata, meta
SSOSessionCreateResponseobjectsession, secret
SSOSessionEnvelopeobjectdata, meta
SaaSEditionProfileobjectid, tenant_id, name, region, admin_tenant_id, isolation_model, status, config_hash, limitations, schema_version, created_at
SaaSEditionProfileEnvelopeobjectdata, meta
SecurityControlobjectid, tenant_id, framework_id, code, title, objective, schema_version, created_at
SecurityControlEnvelopeobjectdata, meta
SecurityReviewPackageReportobjectreport_type, template_version, package_id, product_id, evidence_ids, assumptions, limitations, generated_at
SecurityReviewPackageReportEnvelopeobjectdata, meta
SecurityScanobjectid, tenant_id, category, format, scanner, target_ref, evidence_id, payload_hash, finding_count, redacted, quarantined, schema_version, created_at
SecurityScanEnvelopeobjectdata, meta
SecurityUpdateEvidenceReportobjectreport_type, template_version, product_id, release_id, summary, assumptions, limitations, generated_at
SecurityUpdateEvidenceReportEnvelopeobjectdata, meta
SignedIncidentWebhookPayloadobjectevent_type, summary
SigningCustodyReviewReportobjectreport_type, tenant_id, checks, assumptions, limitations, generated_at
SigningCustodyReviewReportEnvelopeobjectdata, meta
SigningKeyobjectid, tenant_id, kid, algorithm, status, public_key, created_at
SigningKeyEnvelopeobjectdata, meta
SigningKeyListEnvelopeobjectdata, meta
SigningKeyTransitionRequestobjectnone
SigningOperationobjectid, tenant_id, provider_id, subject_type, subject_id, payload_hash, result, checks, schema_version, created_at
SigningOperationEnvelopeobjectdata, meta
SigningProviderobjectid, tenant_id, name, type, status, key_ref, encrypted, schema_version, created_at
SigningProviderEnvelopeobjectdata, meta
SourceBranchobjectid, tenant_id, repository_id, name, protected, schema_version, created_at
SourceBranchEnvelopeobjectdata, meta
SourceCommitobjectid, tenant_id, repository_id, sha, schema_version, created_at
SourceCommitEnvelopeobjectdata, meta
SourceRepositoryobjectid, tenant_id, provider, full_name, schema_version, created_at
SourceRepositoryEnvelopeobjectdata, meta
SourceRepositoryListEnvelopeobjectdata, meta
SourceSnapshotBranchInputobjectname
SourceSnapshotCommitInputobjectsha, committed_at
SourceSnapshotEnvelopeobjectdata, meta
SourceSnapshotPullRequestInputobjectprovider_id, state
SourceSnapshotRepositoryInputobjectfull_name
SourceSnapshotRequestobjectrepository
SourceSnapshotResultobjectrepository
SubjectRefobjecttype
SupersedeEvidenceRequestobjectreplacement_evidence_id, reason
TransparencyCheckpointobjectid, tenant_id, batch_id, provider, timestamp_hash, state, schema_version, created_at
TransparencyCheckpointEnvelopeobjectdata, meta
UpdateSSOProviderTrustMaterialRequestobjectnone
UploadManualSecurityDocumentRequestobjectdocument_type, title, sensitivity, payload
UploadOpenAPIContractRequestobjectproduct_id, release_id, version, spec
UploadSPDXSBOMRequestobjectrelease_id, payload
UploadSecurityScanRequestobjectcategory, format, scanner, target_ref, payload
UploadVulnerabilityScanRequestobjectscanner, target_ref, release_id, findings
UpsertSourceBranchRequestobjectrepository_id, name
UserIdentityLinkobjectid, tenant_id, user_id, provider_id, subject, email, verified, schema_version, created_at
UserIdentityLinkEnvelopeobjectdata, meta
VEXDocumentobjectid, tenant_id, evidence_id, release_id, format, statement_count, schema_version, created_at
VEXDocumentEnvelopeobjectdata, meta
VEXImportIssueobjectcode, detail
VEXImportPreviewobjecttenant_id, release_id, format, parser_version, advisory, statement_count, status_summary, decisions_would_create, decisions_would_supersede, assumptions, limitations, schema_version, generated_at
VEXImportPreviewEnvelopeobjectdata, meta
VEXImportReportobjectid, tenant_id, vex_document_id, evidence_id, parser_version, status, statement_count, decisions_created, decisions_superseded, schema_version, created_at, updated_at
VEXImportReportEnvelopeobjectdata, meta
VerificationResultobjectid, tenant_id, subject_type, subject_id, result, checks, verified_at
VerificationResultEnvelopeobjectdata, meta
VerifyCheckobjectname, result
VerifyCosignSignatureRequestobjectnone
VerifyProviderIdentityRequestobjectprovider_type, provider_id, subject
VerifyPublicTransparencyLogEntryRequestobjectroot_hash, leaf_index, tree_size, inclusion_proof
VerifySubjectRequestobjectsubject_type
VersionInfoobjectversion
VersionInfoEnvelopeobjectdata, meta
VulnerabilityDecisionobjectid, tenant_id, finding_id, scan_id, vulnerability, status, justification, source, schema_version, created_at
VulnerabilityDecisionCustomerSummaryobjectid, finding_id, scan_id, release_id, vulnerability, status, impact_statement, source, created_at
VulnerabilityDecisionEnvelopeobjectdata, meta
VulnerabilityDecisionListEnvelopeobjectdata, meta
VulnerabilityDecisionSummaryReportobjectreport_type, template_version, product_id, release_id, decisions, assumptions, limitations, generated_at
VulnerabilityDecisionSummaryReportEnvelopeobjectdata, meta
VulnerabilityPostureReportobjectreport_type, template_version, summary, open_critical, assumptions, limitations, generated_at
VulnerabilityPostureReportEnvelopeobjectdata, meta
VulnerabilityScanobjectid, tenant_id, release_id, scanner, target_ref, summary, findings, created_at
VulnerabilityScanEnvelopeobjectdata, meta
VulnerabilityWorkflowRecordobjectid, tenant_id, finding_id, action, reason, actor_id, schema_version, created_at
VulnerabilityWorkflowRecordEnvelopeobjectdata, meta
Waiverobjectid, tenant_id, scope_type, scope_id, owner, risk, reason, expires_at, approved, schema_version, created_at
WaiverEnvelopeobjectdata, meta
+
+
+
+ + + diff --git a/site/marketing/scripts/check.mjs b/site/marketing/scripts/check.mjs new file mode 100644 index 0000000..3cc6dfe --- /dev/null +++ b/site/marketing/scripts/check.mjs @@ -0,0 +1,234 @@ +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +const root = new URL("..", import.meta.url).pathname; +const dist = join(root, "dist"); +const configuredBase = process.env.PUBLIC_SITE_BASE ?? "/evydence"; +const normalizedBase = + configuredBase === "/" ? "" : `/${configuredBase.replace(/^\/+|\/+$/g, "")}`; +const withBase = (path) => `${normalizedBase}${path}`.replace(/\/{2,}/g, "/"); + +const requiredFiles = [ + "index.html", + "en/index.html", + "fi/index.html", + "en/product/index.html", + "fi/tuote/index.html", + "en/use-cases/customer-security-review/index.html", + "fi/kayttotapaukset/asiakkaan-tietoturvakatselmus/index.html", + "en/use-cases/release-evidence-package/index.html", + "fi/kayttotapaukset/julkaisun-evidence-paketti/index.html", + "en/commercial/index.html", + "fi/kaupallinen/index.html", + "en/status/index.html", + "fi/tila/index.html", + "en/resources/glossary/index.html", + "fi/resurssit/sanasto/index.html", + "en/resources/cra-readiness-evidence/index.html", + "fi/resurssit/cra-valmiuden-evidence/index.html", + "en/contact/index.html", + "fi/yhteys/index.html", + "en/privacy-cookies/index.html", + "fi/yksityisyys-evasteet/index.html", + "api/index.html", + "docs/index.html", + "github/index.html" +]; + +for (const file of requiredFiles) { + const path = join(dist, file); + if (!existsSync(path)) { + throw new Error(`missing built page: ${file}`); + } +} + +const rootPage = readFileSync(join(dist, "index.html"), "utf8"); +const english = readFileSync(join(dist, "en/index.html"), "utf8"); +const finnish = readFileSync(join(dist, "fi/index.html"), "utf8"); +const productEnglish = readFileSync(join(dist, "en/product/index.html"), "utf8"); +const productFinnish = readFileSync(join(dist, "fi/tuote/index.html"), "utf8"); +const commercialEnglish = readFileSync(join(dist, "en/commercial/index.html"), "utf8"); +const commercialFinnish = readFileSync(join(dist, "fi/kaupallinen/index.html"), "utf8"); +const privacy = readFileSync(join(dist, "en/privacy-cookies/index.html"), "utf8"); +const cname = readFileSync(join(dist, "CNAME"), "utf8").trim(); +const consentSource = readFileSync(join(root, "src/components/ConsentBanner.astro"), "utf8"); +const layoutSource = readFileSync(join(root, "src/layouts/BaseLayout.astro"), "utf8"); +const templateSource = readFileSync(join(root, "src/templates/MarketingPage.astro"), "utf8"); + +const requiredRoot = [ + 'lang="en"', + `url=${withBase("/en/")}`, + "Continue in English", + withBase("/fi/") +]; + +for (const text of requiredRoot) { + if (!rootPage.includes(text)) { + throw new Error(`Root page missing English default routing marker: ${text}`); + } +} + +if (cname !== "evydence.app") { + throw new Error(`marketing site CNAME must be evydence.app, got: ${cname}`); +} + +const requiredEnglish = [ + 'lang="en"', + 'hreflang="fi"', + "Stop scrambling when customers ask for release security evidence.", + "Run the customer CVE review demo", + "One release, one SBOM finding, one decision, one package verifier.", + "https://www.linkedin.com/in/aatu-harju", + "https://github.com/aatuh/evydence/tree/master/examples/customer-cve-review-demo", + withBase("/api/"), + "API docs", + "Cookie preferences", + "Evydence is not a legal compliance service" +]; + +for (const text of requiredEnglish) { + if (!english.includes(text)) { + throw new Error(`English homepage missing: ${text}`); + } +} + +const requiredFinnish = [ + 'lang="fi"', + 'hreflang="en"', + "Lopeta kiireinen selvittely", + "Aja asiakkaan CVE-katselmusdemo", + "Yksi julkaisu, yksi SBOM-löydös, yksi päätös ja paketin todennus.", + "API-dokumentaatio", + "Evästeasetukset", + "Evydence ei ole juridinen" +]; + +for (const text of requiredFinnish) { + if (!finnish.includes(text)) { + throw new Error(`Finnish homepage missing: ${text}`); + } +} + +const requiredProductEnglish = [ + "Different from adjacent tools", + "Dependency-Track and SBOM inventory", + "GUAC-style supply-chain graph tools", + "OpenVEX tooling", + "Vanta, Drata, and broad GRC", + "Scanners and internal scripts" +]; + +for (const text of requiredProductEnglish) { + if (!productEnglish.includes(text)) { + throw new Error(`English product page missing: ${text}`); + } +} + +const requiredProductFinnish = [ + "Ero viereisiin työkaluihin", + "Dependency-Track ja SBOM-inventaario", + "GUAC-tyyliset supply-chain-graafityökalut", + "OpenVEX-työkalut", + "Vanta, Drata ja laaja GRC", + "Skannerit ja sisäiset skriptit" +]; + +for (const text of requiredProductFinnish) { + if (!productFinnish.includes(text)) { + throw new Error(`Finnish product page missing: ${text}`); + } +} + +const requiredCommercialEnglish = [ + "Release evidence readiness review", + "one short readiness summary", + "one customer-safe package or evidence bundle", + "What is excluded", + "legal compliance advice or certification", + "secure-release guarantees" +]; + +for (const text of requiredCommercialEnglish) { + if (!commercialEnglish.includes(text)) { + throw new Error(`English commercial page missing: ${text}`); + } +} + +const requiredCommercialFinnish = [ + "Julkaisuevidencen readiness review", + "yksi lyhyt readiness-yhteenveto", + "asiakkaalle turvallinen paketti tai evidence bundle", + "Mitä ei sisälly", + "juridinen vaatimustenmukaisuusneuvonta tai sertifiointi", + "takuu turvallisesta julkaisusta" +]; + +for (const text of requiredCommercialFinnish) { + if (!commercialFinnish.includes(text)) { + throw new Error(`Finnish commercial page missing: ${text}`); + } +} + +const consentChecks = [ + "analytics_storage: \"denied\"", + "ad_storage: \"denied\"", + "https://www.googletagmanager.com/gtag/js?id=", + "data-consent-accept-all", + "data-consent-reject-optional", + "Accept all", + "data-cookie-preferences" +]; + +for (const text of consentChecks) { + if (!consentSource.includes(text)) { + throw new Error(`Consent implementation missing: ${text}`); + } +} + +const layoutChecks = [ + "class=\"mobile-nav\"", + "mobile-nav__panel", + "font-size: clamp(2.55rem, 4.65vw, 4.65rem)", + ".hero h1", + "hyphens: none", + "word-break: normal", + ":lang(fi) h2", + ":lang(fi) .section--split h2", + ".hero__demo", + "font-size: 1.85rem", + "overflow-wrap: anywhere", + ".section--split > *", + "min-height: min(760px, calc(100vh - 4.8rem))", + ".nav,\n .header-actions {\n display: none;" +]; + +for (const text of layoutChecks) { + if (!layoutSource.includes(text)) { + throw new Error(`Layout implementation missing: ${text}`); + } +} + +if (!templateSource.includes('data-track="demo_click"')) { + throw new Error('Marketing template missing demo click tracking marker'); +} + +for (const forbidden of [ + "innerHTML", + "Start free trial", + "100% secure", + "automatically compliant", + "mailto:", + "aatu@example.com" +]) { + if (english.includes(forbidden) || finnish.includes(forbidden) || privacy.includes(forbidden)) { + throw new Error(`forbidden site text found: ${forbidden}`); + } +} + +for (const forbidden of ["Accept analytics", "Manage choices", "data-consent-manage"]) { + if (consentSource.includes(forbidden)) { + throw new Error(`forbidden consent choice found: ${forbidden}`); + } +} + +console.log("marketing-site-check: passed"); diff --git a/site/marketing/src/components/ConsentBanner.astro b/site/marketing/src/components/ConsentBanner.astro new file mode 100644 index 0000000..9e180ca --- /dev/null +++ b/site/marketing/src/components/ConsentBanner.astro @@ -0,0 +1,172 @@ +--- +import type { Locale } from "@data/site"; + +type Copy = { + title: string; + body: string; + acceptAll: string; + rejectOptional: string; + note: string; +}; + +const copy: Record = { + en: { + title: "Cookie choices", + body: "Evydence uses necessary local preferences for language and cookie choices. If you accept all, we also allow aggregate site measurement.", + acceptAll: "Accept all", + rejectOptional: "Reject optional", + note: "You can change this choice from Cookie preferences in the footer." + }, + fi: { + title: "Evästevalinnat", + body: "Evydence käyttää välttämättömiä paikallisia asetuksia kieleen ja evästevalintoihin. Jos hyväksyt kaikki, sallimme myös kootun sivustomittauksen.", + acceptAll: "Hyväksy kaikki", + rejectOptional: "Hylkää valinnaiset", + note: "Voit muuttaa valintaa alatunnisteen evästeasetuksista." + } +}; + +const { locale } = Astro.props as { locale: Locale }; +const text = copy[locale]; +const measurementId = import.meta.env.PUBLIC_GA_MEASUREMENT_ID ?? ""; +--- + + + + diff --git a/site/marketing/src/components/DossierVisual.astro b/site/marketing/src/components/DossierVisual.astro new file mode 100644 index 0000000..628e5a8 --- /dev/null +++ b/site/marketing/src/components/DossierVisual.astro @@ -0,0 +1,27 @@ +--- +const items = [ + ["01", "Artifact digest"], + ["02", "SBOM inventory"], + ["03", "Vulnerability decision"], + ["04", "Signed package"] +]; +--- + +
+
+ Release dossier + verify-ready +
+
+ {items.map(([number, label]) => ( +
+ {number} +

{label}

+
+ ))} +
+ +
diff --git a/site/marketing/src/data/site.ts b/site/marketing/src/data/site.ts new file mode 100644 index 0000000..ac61293 --- /dev/null +++ b/site/marketing/src/data/site.ts @@ -0,0 +1,1227 @@ +export type Locale = "en" | "fi"; + +export type PageKey = + | "home" + | "product" + | "customerReview" + | "releasePackage" + | "commercial" + | "status" + | "glossary" + | "cra" + | "contact" + | "privacyCookies"; + +type Section = { + eyebrow?: string; + title: string; + body?: string[]; + bullets?: string[]; + cards?: Array<{ title: string; body: string }>; + table?: Array<{ left: string; right: string }>; +}; + +export type PageContent = { + key: PageKey; + title: string; + navTitle: string; + metaTitle: string; + description: string; + eyebrow?: string; + heroTitle: string; + heroBody: string[]; + primaryCta: string; + secondaryCta: string; + demoEyebrow?: string; + demoCta?: string; + demoText?: string; + demoHref?: string; + sections: Section[]; +}; + +export const localeNames: Record = { + en: "English", + fi: "Suomi", +}; + +export const routes: Record> = { + home: { en: "/en/", fi: "/fi/" }, + product: { en: "/en/product/", fi: "/fi/tuote/" }, + customerReview: { + en: "/en/use-cases/customer-security-review/", + fi: "/fi/kayttotapaukset/asiakkaan-tietoturvakatselmus/", + }, + releasePackage: { + en: "/en/use-cases/release-evidence-package/", + fi: "/fi/kayttotapaukset/julkaisun-evidence-paketti/", + }, + commercial: { en: "/en/commercial/", fi: "/fi/kaupallinen/" }, + status: { en: "/en/status/", fi: "/fi/tila/" }, + glossary: { en: "/en/resources/glossary/", fi: "/fi/resurssit/sanasto/" }, + cra: { + en: "/en/resources/cra-readiness-evidence/", + fi: "/fi/resurssit/cra-valmiuden-evidence/", + }, + contact: { en: "/en/contact/", fi: "/fi/yhteys/" }, + privacyCookies: { + en: "/en/privacy-cookies/", + fi: "/fi/yksityisyys-evasteet/", + }, +}; + +export const orderedPageKeys: PageKey[] = [ + "home", + "product", + "customerReview", + "releasePackage", + "commercial", + "status", + "glossary", + "cra", + "contact", + "privacyCookies", +]; + +export const navItems: PageKey[] = [ + "product", + "customerReview", + "commercial", + "status", + "glossary", + "contact", +]; + +export const externalLinks = { + github: "https://github.com/aatuh/evydence", + docs: "https://github.com/aatuh/evydence/tree/master/docs", + customerCveDemo: + "https://github.com/aatuh/evydence/tree/master/examples/customer-cve-review-demo", + linkedin: "https://www.linkedin.com/in/aatu-harju", +}; + +const en: Record = { + home: { + key: "home", + title: "Home", + navTitle: "Home", + metaTitle: "Evydence - release security evidence without the scramble", + description: + "Evydence helps software vendors answer customer release-security questions with organized, verifiable evidence.", + eyebrow: "Self-hosted release evidence", + heroTitle: + "Stop scrambling when customers ask for release security evidence.", + heroBody: [ + "Evydence helps software vendors organize the proof behind a software release: what was shipped, what was inside it, which vulnerabilities were reviewed, what decisions were made, and what can safely be shared with customers.", + "Run it self-hosted, keep technical truth on GitHub, and use commercial terms when your organization needs them.", + ], + primaryCta: "Ask about Commercial Self-Hosted", + secondaryCta: "View GitHub", + demoEyebrow: "Demo", + demoCta: "Run the customer CVE review demo", + demoText: + "One release, one SBOM finding, one decision, one package verifier.", + demoHref: externalLinks.customerCveDemo, + sections: [ + { + eyebrow: "The problem", + title: "Customer security reviews are becoming more detailed.", + body: [ + "A customer may ask what third-party components are inside a release, whether known vulnerabilities are present, whether a CVE affects your product, who reviewed the decision, and what proof can be verified.", + "For many teams, the answer is scattered across scanner exports, CI logs, tickets, chat, spreadsheets, object storage, and one-off PDFs.", + ], + }, + { + eyebrow: "The outcome", + title: "Evydence gives each release an evidence trail.", + bullets: [ + "software component inventory", + "vulnerability scan results", + "vulnerability decisions", + "approvals and exceptions", + "build and artifact proof", + "release-readiness reports", + "signed bundles", + "customer-safe packages", + ], + }, + { + title: "Before and after Evydence", + table: [ + { + left: "Evidence scattered across tools", + right: "Evidence linked to a release", + }, + { + left: "Manual customer answers", + right: "Repeatable customer package", + }, + { + left: "Decisions hidden in tickets or chat", + right: "Decision trail with actor context", + }, + { + left: "Hard to explain not affected", + right: "Reviewable vulnerability decision", + }, + { + left: "One-off PDF work", + right: "Package generated from structured records", + }, + ], + }, + { + title: "What it is not", + body: [ + "Evydence is not a scanner, firewall, legal compliance engine, certification service, or guarantee that a release is secure.", + "It helps organize technical evidence and release-security decisions so teams can answer review questions more clearly and repeatably.", + ], + }, + ], + }, + product: { + key: "product", + title: "Product", + navTitle: "Product", + metaTitle: "Product - Evydence", + description: "A self-hosted evidence system for software releases.", + eyebrow: "Product overview", + heroTitle: "A self-hosted evidence system for software releases.", + heroBody: [ + "Evydence keeps release evidence connected to the product, version, artifact, vulnerability, decision, approval, and customer package it belongs to.", + ], + primaryCta: "Ask about Commercial Self-Hosted", + secondaryCta: "View GitHub", + sections: [ + { + title: "Capture release evidence", + body: [ + "Collect proof material around a release: component inventories, vulnerability scans, build metadata, artifact digests, source records, deployment events, and supporting files.", + ], + }, + { + title: "Record vulnerability decisions", + body: [ + "When a known vulnerability appears, record whether it affects the release, why, who reviewed it, and what evidence supports the decision.", + ], + }, + { + title: "Generate customer-safe packages", + body: [ + "Create packages that include evidence a customer can review without exposing unnecessary internal detail.", + ], + }, + { + title: "Verify the package", + body: [ + "Use manifests, hashes, signatures, and audit-chain records to make the package reviewable instead of just another static document.", + ], + }, + { + title: "Self-hosted by design", + body: [ + "Evydence is designed for teams that want to run the evidence system in their own environment, close to release, security, and CI/CD systems.", + ], + }, + { + title: "Different from adjacent tools", + table: [ + { + left: "Dependency-Track and SBOM inventory", + right: "Evydence packages release-scoped SBOM, scan, decision, bundle, and verification evidence for review.", + }, + { + left: "GUAC-style supply-chain graph tools", + right: "Evydence focuses on release manifests, verification receipts, and customer-safe package evidence.", + }, + { + left: "OpenVEX tooling", + right: "Evydence stores VEX as evidence and links normalized decisions to releases, scans, SBOM context, and packages.", + }, + { + left: "Vanta, Drata, and broad GRC", + right: "Evydence is self-hosted release evidence infrastructure, not a compliance platform or certification service.", + }, + { + left: "Scanners and internal scripts", + right: "Evydence records their outputs, preserves hashes, and adds tenant scope, append-only history, and verification commands.", + }, + ], + }, + ], + }, + customerReview: { + key: "customerReview", + title: "Customer security reviews", + navTitle: "Use cases", + metaTitle: "Customer security reviews - Evydence", + description: + "Turn customer security review questions into a repeatable release evidence workflow.", + eyebrow: "Use case", + heroTitle: "Customer security reviews should not require a scavenger hunt.", + heroBody: [ + "Enterprise customers increasingly ask software vendors to explain what is inside a release, which known vulnerabilities are present, which ones actually affect the product, and what proof supports the answer.", + "Evydence helps turn that review from a manual scramble into a repeatable release evidence workflow.", + ], + primaryCta: "Ask about a release evidence pilot", + secondaryCta: "View GitHub", + sections: [ + { + title: "The question that triggers the scramble", + body: [ + "A customer asks: This CVE appears in your software bill of materials. Are we affected?", + "Without a system, the answer may require checking the SBOM, vulnerability scanner, CI logs, build artifacts, internal tickets, approvals, release notes, and previous customer responses.", + ], + }, + { + title: "A clearer answer", + bullets: [ + "yes, affected and fixed", + "yes, affected and accepted with an exception", + "no, present but not exploitable in this product context", + "unknown, with documented gaps and next steps", + ], + }, + { + title: "Before and after", + table: [ + { + left: "We think this is not exploitable. Let us check with engineering.", + right: + "For this release, the finding was reviewed, marked not affected, linked to supporting evidence, and included in the customer-safe package.", + }, + ], + }, + ], + }, + releasePackage: { + key: "releasePackage", + title: "Release evidence packages", + navTitle: "Packages", + metaTitle: "Release evidence packages - Evydence", + description: "One release, one organized evidence package.", + eyebrow: "Use case", + heroTitle: "One release. One organized evidence package.", + heroBody: [ + "An evidence package is a structured set of proof material for a software release.", + "It can include what was shipped, what components were inside, which known vulnerabilities were found, how those findings were handled, who approved the decision, and what a customer can verify.", + ], + primaryCta: "Ask about Commercial Self-Hosted", + secondaryCta: "View GitHub", + sections: [ + { + title: "Suggested package contents", + bullets: [ + "product and release identity", + "shipped artifact references", + "artifact hashes or digests", + "SBOM/component inventory", + "vulnerability scan summary", + "vulnerability decisions", + "exception or waiver records", + "build/source provenance", + "approval trail", + "package manifest", + "limitations and assumptions", + "customer-safe redactions", + ], + }, + { + title: "Important boundary", + body: [ + "The package does not mean the release is perfectly secure.", + "It means the team can show what was checked, what was decided, what is still unknown, and what evidence supports the answer.", + ], + }, + ], + }, + commercial: { + key: "commercial", + title: "Commercial", + navTitle: "Commercial", + metaTitle: "Commercial Self-Hosted Licensing - Evydence", + description: + "Commercial self-hosted licensing, async support, deployment review, and integration support.", + eyebrow: "Commercial Self-Hosted", + heroTitle: "Commercial Self-Hosted Licensing.", + heroBody: [ + "Use Evydence under AGPL when that fits your organization.", + "Buy Evydence Commercial Self-Hosted when you need private commercial terms, AGPL exception, async support, signed release/evidence delivery, or deployment review.", + ], + primaryCta: "Start async commercial inquiry", + secondaryCta: "View GitHub", + sections: [ + { + title: "Community AGPL vs Commercial Self-Hosted", + table: [ + { + left: "Public AGPL license", + right: "Separate written commercial agreement", + }, + { left: "Public GitHub issues", right: "Private async support" }, + { + left: "Best-effort community help", + right: "Agreed support and upgrade path", + }, + { + left: "Self-evaluation", + right: "Optional deployment-readiness review", + }, + { + left: "AGPL obligations apply", + right: "Extra permission for agreed use cases", + }, + ], + }, + { + title: "Paid options", + cards: [ + { + title: "Commercial Self-Hosted License", + body: "Private commercial terms, procurement-friendly licensing, and an agreed support path.", + }, + { + title: "Async Support", + body: "Private written support for troubleshooting, upgrade planning, deployment questions, and release evidence workflows.", + }, + { + title: "Deployment Readiness Review", + body: "Written review of configuration, backup/restore, evidence handling, deployment architecture, and known limitations.", + }, + { + title: "Release Evidence Package Review", + body: "Written review of one evidence package, including gaps, assumptions, limitations, and customer-safe sharing concerns.", + }, + { + title: "Custom Integration", + body: "Collector adapters, evidence workflows, report templates, CI/CD integration, or deployment hardening.", + }, + ], + }, + { + title: "Release evidence readiness review", + body: [ + "A concrete paid starting point: install or review one self-hosted Evydence deployment profile, configure one product release, connect one CI evidence path, generate the first customer-safe package, verify it offline, and document gaps, assumptions, limitations, operator responsibilities, and external dependencies.", + ], + bullets: [ + "one short readiness summary", + "one deployment-checklist pass with accepted gaps", + "one SBOM, vulnerability, build, artifact, and release-bundle upload path where the operator already has the files or commands", + "one customer-safe package or evidence bundle with manifest, hashes, verification material, limitations, and non-claims", + "one prioritized follow-up list", + ], + }, + { + title: "What is excluded", + bullets: [ + "hosted SaaS operation", + "legal compliance advice or certification", + "audit opinion or regulator acceptance", + "secure-release guarantees", + "complete SBOM proof or authoritative vulnerability coverage", + "unlimited integration work, scanner replacement, broad GRC workflow, or custom customer portal development", + ], + }, + { + title: "Commercial boundary", + body: [ + "Commercial terms do not turn Evydence into a legal compliance guarantee, security certification, vulnerability scanner, or managed SaaS service.", + ], + }, + ], + }, + status: { + key: "status", + title: "Status", + navTitle: "Status", + metaTitle: "Current status and limitations - Evydence", + description: "Current status and supported self-hosted deployment profile.", + eyebrow: "Status and limitations", + heroTitle: "Current status and supported deployment profile.", + heroBody: [ + "Evydence is intended for evaluation, pilots, and controlled internal self-hosted production after operator review.", + "It is not marketed as a hosted SaaS product or a broad regulated-production platform.", + ], + primaryCta: "Ask about deployment fit", + secondaryCta: "View GitHub", + sections: [ + { + title: "Deployment fit", + table: [ + { left: "Local technical evaluation", right: "Good fit" }, + { left: "Pilot with one product/release", right: "Good fit" }, + { + left: "Controlled internal self-hosted use", + right: "Candidate after review", + }, + { left: "Regulated production", right: "Requires extra review" }, + { + left: "Air-gapped production", + right: "Requires transfer controls", + }, + { left: "Hosted SaaS by Evydence", right: "Not currently offered" }, + ], + }, + ], + }, + glossary: { + key: "glossary", + title: "Glossary", + navTitle: "Glossary", + metaTitle: "Plain-English glossary - Evydence", + description: + "Plain-English terms for release evidence, SBOMs, CVEs, VEX, and customer-safe packages.", + eyebrow: "Resources", + heroTitle: "Plain-English glossary.", + heroBody: [ + "Short explanations for people evaluating release evidence workflows without needing every implementation detail.", + ], + primaryCta: "Ask about Commercial Self-Hosted", + secondaryCta: "View GitHub", + sections: [ + { + title: "Terms", + cards: [ + { + title: "Evidence", + body: "Proof material around a software release: scan results, component lists, build records, vulnerability decisions, approvals, artifact hashes, release reports, and customer-safe files.", + }, + { + title: "Evidence package", + body: "An organized bundle of release proof that can be reviewed internally or shared with a customer.", + }, + { + title: "SBOM", + body: "A software ingredients list. It describes the components used inside software.", + }, + { + title: "CVE", + body: "A public identifier for a known software flaw.", + }, + { + title: "VEX", + body: "A structured way to explain whether a known vulnerability actually affects a specific product or release.", + }, + { + title: "Customer-safe package", + body: "A package that contains useful review evidence without exposing unnecessary internal details.", + }, + { + title: "Release readiness", + body: "A structured view of whether the release has expected evidence, decisions, approvals, and known limitations before it is shared or shipped.", + }, + ], + }, + ], + }, + cra: { + key: "cra", + title: "CRA-readiness evidence", + navTitle: "CRA readiness", + metaTitle: "Technical evidence organization for CRA readiness - Evydence", + description: + "Technical evidence organization for CRA-readiness work without legal compliance claims.", + eyebrow: "Resources", + heroTitle: "Technical evidence organization for CRA readiness.", + heroBody: [ + "The EU Cyber Resilience Act is increasing attention on vulnerability handling, technical documentation, and product-security processes.", + "Evydence does not provide legal compliance or certification, but it can help organize technical release evidence that product-security and compliance-readiness teams may need to review.", + ], + primaryCta: "Ask about a release evidence review", + secondaryCta: "View GitHub", + sections: [ + { + title: "Careful wording", + body: [ + "Evydence can support CRA-readiness work by helping teams preserve release-level technical evidence, vulnerability decisions, SBOM records, package manifests, and known limitations.", + ], + }, + { + title: "Wording to avoid", + bullets: [ + "Evydence makes you CRA compliant.", + "Evydence certifies your releases.", + "Evydence guarantees regulatory acceptance.", + "Evydence proves your software is secure.", + ], + }, + ], + }, + contact: { + key: "contact", + title: "Contact", + navTitle: "Contact", + metaTitle: "Contact - Evydence", + description: + "Start a commercial self-hosted conversation through LinkedIn.", + eyebrow: "LinkedIn contact", + heroTitle: "Start a commercial conversation.", + heroBody: [ + "Use LinkedIn for an initial commercial self-hosted conversation, deployment assumptions, release workflow notes, or commercial licensing questions.", + ], + primaryCta: "Open LinkedIn profile", + secondaryCta: "View GitHub", + sections: [ + { + title: "What to include", + bullets: [ + "your intended use", + "target deployment model", + "AGPL or commercial concerns", + "support expectations", + "target release workflow", + "preferred next step", + ], + }, + { + title: "Do not send sensitive evidence", + body: [ + "Do not send raw evidence payloads, bearer tokens, private keys, database URLs, customer data, or unreleased package contents through LinkedIn or any public contact path.", + "Use the first message only for brief, non-sensitive context. A safer private channel can be agreed separately if needed.", + ], + }, + ], + }, + privacyCookies: { + key: "privacyCookies", + title: "Privacy and cookies", + navTitle: "Privacy", + metaTitle: "Privacy and cookies - Evydence", + description: + "How the marketing site handles necessary preferences and optional measurement.", + eyebrow: "Privacy", + heroTitle: "Privacy and cookies.", + heroBody: [ + "The marketing site uses only necessary local preferences until you choose to accept optional site measurement.", + "Optional measurement, when enabled, is limited to aggregate marketing-site events.", + ], + primaryCta: "Manage cookie choices", + secondaryCta: "View GitHub", + sections: [ + { + title: "What may be measured after acceptance", + bullets: [ + "page views", + "outbound GitHub clicks", + "docs clicks", + "contact clicks", + "language switches", + ], + }, + { + title: "What is not measured", + body: [ + "The site must not track raw form text, email addresses, customer names, company names, evidence package data, tokens, query parameters containing secrets, or support content.", + ], + }, + { + title: "Changing your choice", + body: [ + "Use the footer cookie preferences link to reopen the consent panel and change your optional measurement choice.", + ], + }, + ], + }, +}; + +const fi: Record = { + home: { + ...en.home, + title: "Etusivu", + navTitle: "Etusivu", + metaTitle: "Evydence - julkaisun tietoturvaevidence ilman säätöä", + description: + "Evydence auttaa ohjelmistotoimittajia vastaamaan asiakkaiden julkaisun tietoturvakysymyksiin järjestetyllä ja todennettavalla evidencellä.", + eyebrow: "Itse ylläpidettävä julkaisuevidence", + heroTitle: + "Lopeta kiireinen selvittely, kun asiakas pyytää julkaisun tietoturvaevidenceä.", + heroBody: [ + "Evydence auttaa ohjelmistotoimittajia järjestämään ohjelmistojulkaisun taustalla olevan evidencen: mitä toimitettiin, mitä se sisälsi, mitkä haavoittuvuudet arvioitiin, mitä päätöksiä tehtiin ja mitä voidaan jakaa asiakkaalle turvallisesti.", + "Aja järjestelmää itse, pidä tekninen totuus GitHubissa ja käytä kaupallisia ehtoja, kun organisaatiosi tarvitsee niitä.", + ], + primaryCta: "Kysy Commercial Self-Hosted -ehdoista", + secondaryCta: "Avaa GitHub", + demoEyebrow: "Demo", + demoCta: "Aja asiakkaan CVE-katselmusdemo", + demoText: + "Yksi julkaisu, yksi SBOM-löydös, yksi päätös ja paketin todennus.", + demoHref: externalLinks.customerCveDemo, + sections: [ + { + eyebrow: "Ongelma", + title: + "Asiakkaiden tietoturvakatselmukset ovat yhä yksityiskohtaisempia.", + body: [ + "Asiakas voi kysyä, mitä kolmannen osapuolen komponentteja julkaisu sisältää, onko tunnettuja haavoittuvuuksia mukana, vaikuttaako CVE tuotteeseen, kuka arvioi päätöksen ja mitä näyttöä voidaan todentaa.", + "Monessa tiimissä vastaus on hajallaan skannerivienneissä, CI-lokeissa, tiketeissä, keskusteluissa, taulukoissa, objektitallennuksessa ja kertaluonteisissa PDF-tiedostoissa.", + ], + }, + { + eyebrow: "Lopputulos", + title: "Evydence antaa jokaiselle julkaisulle evidence-polun.", + bullets: [ + "ohjelmistokomponenttien inventaario", + "haavoittuvuusskannausten tulokset", + "haavoittuvuuspäätökset", + "hyväksynnät ja poikkeukset", + "koonti- ja artefaktievidence", + "julkaisuvalmiuden raportit", + "allekirjoitetut bundle-paketit", + "asiakkaalle turvalliset paketit", + ], + }, + { + title: "Ennen ja jälkeen Evydencen", + table: [ + { + left: "Evidence on hajallaan työkaluissa", + right: "Evidence on linkitetty julkaisuun", + }, + { + left: "Asiakasvastaukset ovat käsityötä", + right: "Asiakaspaketti voidaan toistaa", + }, + { + left: "Päätökset ovat piilossa tiketeissä tai chatissa", + right: "Päätöspolku sisältää tekijäkontekstin", + }, + { + left: "Not affected -päätöstä on vaikea selittää", + right: "Haavoittuvuuspäätös on katselmoitavissa", + }, + { + left: "PDF-työ tehdään kertaluonteisesti", + right: "Paketti syntyy rakenteisista tietueista", + }, + ], + }, + { + title: "Mitä Evydence ei ole", + body: [ + "Evydence ei ole skanneri, palomuuri, juridinen vaatimustenmukaisuuskone, sertifiointipalvelu tai takuu siitä, että julkaisu on turvallinen.", + "Se auttaa järjestämään teknisen evidencen ja julkaisun tietoturvapäätökset, jotta tiimit voivat vastata katselmuskysymyksiin selkeämmin ja toistettavammin.", + ], + }, + ], + }, + product: { + ...en.product, + title: "Tuote", + navTitle: "Tuote", + metaTitle: "Tuote - Evydence", + description: + "Itse ylläpidettävä evidence-järjestelmä ohjelmistojulkaisuille.", + eyebrow: "Tuotekuvaus", + heroTitle: + "Itse ylläpidettävä evidence-järjestelmä ohjelmistojulkaisuille.", + heroBody: [ + "Evydence pitää julkaisuevidencen kytkettynä tuotteeseen, versioon, artefaktiin, haavoittuvuuteen, päätökseen, hyväksyntään ja asiakaspakettiin.", + ], + primaryCta: "Kysy Commercial Self-Hosted -ehdoista", + secondaryCta: "Avaa GitHub", + sections: [ + { + title: "Kerää julkaisuevidence", + body: [ + "Kerää julkaisun ympärillä oleva todistusaineisto: komponenttilistat, haavoittuvuusskannaukset, koontimetatiedot, artefaktien digestit, lähdekooditietueet, käyttöönotot ja tukevat tiedostot.", + ], + }, + { + title: "Tallenna haavoittuvuuspäätökset", + body: [ + "Kun tunnettu haavoittuvuus löytyy, tallenna vaikuttaako se julkaisuun, miksi, kuka sen arvioi ja mikä evidence tukee päätöstä.", + ], + }, + { + title: "Luo asiakkaalle turvallisia paketteja", + body: [ + "Luo paketteja, jotka sisältävät asiakkaalle hyödyllisen evidencen ilman tarpeetonta sisäistä yksityiskohtaa.", + ], + }, + { + title: "Todenna paketti", + body: [ + "Käytä manifestteja, tiivisteitä, allekirjoituksia ja audit chain -tietueita, jotta paketti on katselmoitava eikä vain uusi staattinen dokumentti.", + ], + }, + { + title: "Itse ylläpidettävä lähtökohta", + body: [ + "Evydence on tarkoitettu tiimeille, jotka haluavat ajaa evidence-järjestelmää omassa ympäristössään lähellä julkaisu-, tietoturva- ja CI/CD-järjestelmiä.", + ], + }, + { + title: "Ero viereisiin työkaluihin", + table: [ + { + left: "Dependency-Track ja SBOM-inventaario", + right: "Evydence paketoi julkaisukohtaisen SBOM-, skannaus-, päätös-, bundle- ja todennusevidencen katselmoitavaksi.", + }, + { + left: "GUAC-tyyliset supply-chain-graafityökalut", + right: "Evydence keskittyy julkaisumanifesteihin, todennuskuittauksiin ja asiakkaalle turvalliseen pakettievidenceen.", + }, + { + left: "OpenVEX-työkalut", + right: "Evydence tallentaa VEXin evidencenä ja linkittää normalisoidut päätökset julkaisuihin, skannauksiin, SBOM-kontekstiin ja paketteihin.", + }, + { + left: "Vanta, Drata ja laaja GRC", + right: "Evydence on itse ylläpidettävä julkaisuevidence-infra, ei compliance-alusta tai sertifiointipalvelu.", + }, + { + left: "Skannerit ja sisäiset skriptit", + right: "Evydence tallentaa niiden tulokset, säilyttää tiivisteet ja lisää tenant-rajauksen, append-only-historian ja todennuskomennot.", + }, + ], + }, + ], + }, + customerReview: { + ...en.customerReview, + title: "Asiakkaan tietoturvakatselmukset", + navTitle: "Käyttötapaukset", + metaTitle: "Asiakkaan tietoturvakatselmukset - Evydence", + description: + "Muuta asiakkaan tietoturvakysymykset toistettavaksi julkaisuevidence-työnkuluksi.", + eyebrow: "Käyttötapaus", + heroTitle: + "Asiakkaan tietoturvakatselmuksen ei pitäisi olla aarteenetsintä.", + heroBody: [ + "Yritysasiakkaat kysyvät yhä useammin, mitä julkaisu sisältää, mitä tunnettuja haavoittuvuuksia löytyy, mitkä niistä todella vaikuttavat tuotteeseen ja mikä evidence tukee vastausta.", + "Evydence auttaa muuttamaan katselmuksen käsityöstä toistettavaksi julkaisuevidence-työnkuluksi.", + ], + primaryCta: "Kysy julkaisuevidence-pilotista", + secondaryCta: "Avaa GitHub", + sections: [ + { + title: "Kysymys, joka käynnistää selvityksen", + body: [ + "Asiakas kysyy: Tämä CVE näkyy ohjelmiston SBOMissa. Vaikuttaako se meihin?", + "Ilman järjestelmää vastaus voi vaatia SBOMin, skannerin, CI-lokien, artefaktien, sisäisten tikettien, hyväksyntöjen, julkaisutietojen ja aiempien asiakasvastausten läpikäyntiä.", + ], + }, + { + title: "Selkeämpi vastaus", + bullets: [ + "kyllä, vaikuttaa ja on korjattu", + "kyllä, vaikuttaa ja on hyväksytty poikkeuksella", + "ei, mukana mutta ei hyödynnettävissä tässä tuotekontekstissa", + "tuntematon, dokumentoiduilla aukoilla ja seuraavilla toimilla", + ], + }, + { + title: "Ennen ja jälkeen", + table: [ + { + left: "Uskomme, ettei tämä ole hyödynnettävissä. Tarkistamme asian kehitystiimiltä.", + right: + "Tälle julkaisulle löydös arvioitiin, merkittiin not affected -tilaan, linkitettiin tukevaan evidenceen ja sisällytettiin asiakaspakettiin.", + }, + ], + }, + ], + }, + releasePackage: { + ...en.releasePackage, + title: "Julkaisun evidence-paketit", + navTitle: "Paketit", + metaTitle: "Julkaisun evidence-paketit - Evydence", + description: "Yksi julkaisu, yksi järjestetty evidence-paketti.", + eyebrow: "Käyttötapaus", + heroTitle: "Yksi julkaisu. Yksi järjestetty evidence-paketti.", + heroBody: [ + "Evidence-paketti on rakenteinen todistusaineisto ohjelmistojulkaisulle.", + "Se voi sisältää mitä toimitettiin, mitä komponentteja oli mukana, mitä tunnettuja haavoittuvuuksia löytyi, miten ne käsiteltiin, kuka hyväksyi päätöksen ja mitä asiakas voi todentaa.", + ], + primaryCta: "Kysy Commercial Self-Hosted -ehdoista", + secondaryCta: "Avaa GitHub", + sections: [ + { + title: "Suositeltu sisältö", + bullets: [ + "tuotteen ja julkaisun identiteetti", + "toimitettujen artefaktien viitteet", + "artefaktien tiivisteet tai digestit", + "SBOM/komponentti-inventaario", + "haavoittuvuusskannauksen yhteenveto", + "haavoittuvuuspäätökset", + "poikkeus- tai waiver-tietueet", + "koonti- ja lähdekoodiprovenanssi", + "hyväksyntäpolku", + "pakettimanifesti", + "rajoitukset ja oletukset", + "asiakkaalle turvalliset redaktiot", + ], + }, + { + title: "Tärkeä raja", + body: [ + "Paketti ei tarkoita, että julkaisu olisi täydellisen turvallinen.", + "Se tarkoittaa, että tiimi voi näyttää, mitä tarkistettiin, mitä päätettiin, mitä on yhä tuntematonta ja mikä evidence tukee vastausta.", + ], + }, + ], + }, + commercial: { + ...en.commercial, + title: "Kaupallinen", + navTitle: "Kaupallinen", + metaTitle: "Commercial Self-Hosted -lisensointi - Evydence", + description: + "Kaupallinen itse ylläpidettävä lisensointi, async-tuki, käyttöönottokatselmus ja integraatiotuki.", + eyebrow: "Commercial Self-Hosted", + heroTitle: "Commercial Self-Hosted -lisensointi.", + heroBody: [ + "Käytä Evydenceä AGPL-lisenssillä, kun se sopii organisaatiollesi.", + "Osta Commercial Self-Hosted, kun tarvitset yksityiset kaupalliset ehdot, AGPL-poikkeuksen, async-tuen, allekirjoitetun julkaisu/evidence-toimituksen tai käyttöönottokatselmuksen.", + ], + primaryCta: "Aloita async-kaupallinen kysely", + secondaryCta: "Avaa GitHub", + sections: [ + { + title: "Community AGPL vs Commercial Self-Hosted", + table: [ + { + left: "Julkinen AGPL-lisenssi", + right: "Erillinen kirjallinen kaupallinen sopimus", + }, + { left: "Julkiset GitHub-issuet", right: "Yksityinen async-tuki" }, + { + left: "Best-effort-yhteisöapu", + right: "Sovittu tuki- ja päivityspolku", + }, + { + left: "Oma arviointi", + right: "Valinnainen deployment readiness -katselmus", + }, + { + left: "AGPL-velvoitteet pätevät", + right: "Lisälupa sovittuihin käyttötapauksiin", + }, + ], + }, + { + title: "Maksulliset vaihtoehdot", + cards: [ + { + title: "Commercial Self-Hosted License", + body: "Yksityiset kaupalliset ehdot, hankintaystävällinen lisensointi ja sovittu tukipolku.", + }, + { + title: "Async Support", + body: "Yksityinen kirjallinen tuki vianrajoitukseen, päivityssuunnitteluun, käyttöönottokysymyksiin ja julkaisuevidence-työnkulkuihin.", + }, + { + title: "Deployment Readiness Review", + body: "Kirjallinen katselmus konfiguraatiosta, varmistuksista, evidencen käsittelystä, arkkitehtuurista ja tunnetuista rajoituksista.", + }, + { + title: "Release Evidence Package Review", + body: "Kirjallinen katselmus yhdestä evidence-paketista, mukaan lukien aukot, oletukset, rajoitukset ja asiakkaalle turvallinen jakaminen.", + }, + { + title: "Custom Integration", + body: "Collector-adapterit, evidence-työnkulut, raporttipohjat, CI/CD-integraatio tai deployment-kovennus.", + }, + ], + }, + { + title: "Julkaisuevidencen readiness review", + body: [ + "Konkreettinen maksullinen aloitus: asenna tai katselmoi yksi itse ylläpidettävä Evydence-käyttöprofiili, konfiguroi yksi tuotteen julkaisu, yhdistä yksi CI-evidence-polku, luo ensimmäinen asiakkaalle turvallinen paketti, todenna se offline-tilassa ja dokumentoi aukot, oletukset, rajoitukset, operaattorin vastuut ja ulkoiset riippuvuudet.", + ], + bullets: [ + "yksi lyhyt readiness-yhteenveto", + "yksi deployment-checklist-kierros hyväksytyillä aukoilla", + "yksi SBOM-, haavoittuvuus-, koonti-, artefakti- ja release bundle -latauspolku, kun operaattorilla on jo tiedostot tai komennot", + "yksi asiakkaalle turvallinen paketti tai evidence bundle, jossa on manifesti, tiivisteet, todennusmateriaali, rajoitukset ja non-claim-teksti", + "yksi priorisoitu jatkolista", + ], + }, + { + title: "Mitä ei sisälly", + bullets: [ + "hostattu SaaS-operointi", + "juridinen vaatimustenmukaisuusneuvonta tai sertifiointi", + "audit-lausunto tai regulaattorihyväksyntä", + "takuu turvallisesta julkaisusta", + "täydellisen SBOMin todiste tai auktoritatiivinen haavoittuvuuskattavuus", + "rajaton integraatiotyö, skannerin korvaaminen, laaja GRC-työnkulku tai räätälöity asiakasportaali", + ], + }, + { + title: "Kaupallinen raja", + body: [ + "Kaupalliset ehdot eivät tee Evydencestä juridista vaatimustenmukaisuustakuuta, tietoturvasertifiointia, haavoittuvuusskanneria tai hallittua SaaS-palvelua.", + ], + }, + ], + }, + status: { + ...en.status, + title: "Tila", + navTitle: "Tila", + metaTitle: "Nykyinen tila ja rajoitukset - Evydence", + description: "Nykyinen tila ja tuettu itse ylläpidettävä käyttöprofiili.", + eyebrow: "Tila ja rajoitukset", + heroTitle: "Nykyinen tila ja tuettu käyttöprofiili.", + heroBody: [ + "Evydence on tarkoitettu arviointiin, pilotteihin ja hallittuun sisäiseen itse ylläpidettävään tuotantokäyttöön operaattorin katselmuksen jälkeen.", + "Sitä ei markkinoida hostattuna SaaS-tuotteena tai laajana reguloidun tuotannon alustana.", + ], + primaryCta: "Kysy käyttöönottosopivuudesta", + secondaryCta: "Avaa GitHub", + sections: [ + { + title: "Käyttöprofiilin sopivuus", + table: [ + { left: "Paikallinen tekninen arviointi", right: "Hyvä sopivuus" }, + { + left: "Pilotti yhdellä tuotteella/julkaisulla", + right: "Hyvä sopivuus", + }, + { + left: "Hallittu sisäinen itse ylläpidettävä käyttö", + right: "Kandidaatti katselmuksen jälkeen", + }, + { left: "Reguloitu tuotanto", right: "Vaatii lisäkatselmuksen" }, + { left: "Air-gapped-tuotanto", right: "Vaatii siirtokontrollit" }, + { + left: "Evydencen hostattu SaaS", + right: "Ei tällä hetkellä tarjolla", + }, + ], + }, + { + title: "Miksi tämä sivu on olemassa", + body: [ + "Tämä sivu suodattaa huonosti sopivat käyttötapaukset ja lisää luottamusta sopivien arvioijien kanssa kertomalla rajoitukset suoraan.", + ], + }, + ], + }, + glossary: { + ...en.glossary, + title: "Sanasto", + navTitle: "Sanasto", + metaTitle: "Selkokielinen sanasto - Evydence", + description: + "Selkokieliset termit julkaisuevidencestä, SBOMeista, CVE-tunnuksista, VEXistä ja asiakaspaketeista.", + eyebrow: "Resurssit", + heroTitle: "Selkokielinen sanasto.", + heroBody: [ + "Lyhyet selitykset ihmisille, jotka arvioivat julkaisuevidence-työnkulkuja ilman kaikkia toteutusyksityiskohtia.", + ], + primaryCta: "Kysy Commercial Self-Hosted -ehdoista", + secondaryCta: "Avaa GitHub", + sections: [ + { + title: "Termit", + cards: [ + { + title: "Evidence", + body: "Julkaisun ympärillä oleva todistusaineisto: skannaustulokset, komponenttilistat, koontitietueet, haavoittuvuuspäätökset, hyväksynnät, artefaktitiivisteet, julkaisuraportit ja asiakkaalle turvalliset tiedostot.", + }, + { + title: "Evidence-paketti", + body: "Järjestetty julkaisun todistusaineistopaketti, jota voidaan katselmoida sisäisesti tai jakaa asiakkaalle.", + }, + { + title: "SBOM", + body: "Ohjelmiston ainesosalista. Se kuvaa ohjelmistossa käytetyt komponentit.", + }, + { + title: "CVE", + body: "Julkinen tunniste tunnetulle ohjelmistovirheelle.", + }, + { + title: "VEX", + body: "Rakenteinen tapa selittää, vaikuttaako tunnettu haavoittuvuus tiettyyn tuotteeseen tai julkaisuun.", + }, + { + title: "Asiakkaalle turvallinen paketti", + body: "Paketti, joka sisältää hyödyllisen katselmusevidencen paljastamatta tarpeettomia sisäisiä yksityiskohtia.", + }, + { + title: "Julkaisuvalmius", + body: "Rakenteinen näkymä siihen, onko julkaisulla odotettu evidence, päätökset, hyväksynnät ja tunnetut rajoitukset ennen jakamista tai toimitusta.", + }, + ], + }, + ], + }, + cra: { + ...en.cra, + title: "CRA-valmiuden evidence", + navTitle: "CRA-valmius", + metaTitle: + "Teknisen evidencen järjestäminen CRA-valmiutta varten - Evydence", + description: + "Teknisen evidencen järjestäminen CRA-valmiustyöhön ilman juridisia vaatimustenmukaisuusväitteitä.", + eyebrow: "Resurssit", + heroTitle: "Teknisen evidencen järjestäminen CRA-valmiutta varten.", + heroBody: [ + "EU:n Cyber Resilience Act lisää huomiota haavoittuvuuksien käsittelyyn, tekniseen dokumentaatioon ja tuoteturvallisuusprosesseihin.", + "Evydence ei tarjoa juridista vaatimustenmukaisuutta tai sertifiointia, mutta se voi auttaa järjestämään teknistä julkaisuevidenceä, jota tuoteturvallisuus- ja compliance readiness -tiimit voivat tarvita katselmuksissa.", + ], + primaryCta: "Kysy release evidence -katselmuksesta", + secondaryCta: "Avaa GitHub", + sections: [ + { + title: "Varovainen sanamuoto", + body: [ + "Evydence voi tukea CRA-valmiustyötä auttamalla tiimejä säilyttämään julkaisutason teknistä evidenceä, haavoittuvuuspäätöksiä, SBOM-tietueita, pakettimanifesteja ja tunnettuja rajoituksia.", + ], + }, + { + title: "Vältettävät sanamuodot", + bullets: [ + "Evydence tekee sinusta CRA-yhteensopivan.", + "Evydence sertifioi julkaisusi.", + "Evydence takaa viranomaisen hyväksynnän.", + "Evydence todistaa, että ohjelmistosi on turvallinen.", + ], + }, + ], + }, + contact: { + ...en.contact, + title: "Yhteys", + navTitle: "Yhteys", + metaTitle: "Yhteys - Evydence", + description: "Aloita Commercial Self-Hosted -keskustelu LinkedInissä.", + eyebrow: "LinkedIn-yhteys", + heroTitle: "Aloita kaupallinen keskustelu.", + heroBody: [ + "Käytä LinkedIniä ensimmäiseen Commercial Self-Hosted -keskusteluun, käyttöoletuksiin, julkaisutyönkulun muistiinpanoihin tai lisensointikysymyksiin.", + ], + primaryCta: "Avaa LinkedIn-profiili", + secondaryCta: "Avaa GitHub", + sections: [ + { + title: "Mitä mukaan", + bullets: [ + "suunniteltu käyttötapa", + "tavoiteltu käyttöönottomalli", + "AGPL- tai kaupalliset huolenaiheet", + "tukiodotukset", + "tavoiteltu julkaisutyönkulku", + "toivottu seuraava askel", + ], + }, + { + title: "Älä lähetä arkaluonteista evidenceä", + body: [ + "Älä lähetä raw evidence -payloadia, bearer-tokeneita, yksityisiä avaimia, tietokanta-URL-osoitteita, asiakasdataa tai julkaisemattomia pakettisisältöjä LinkedInin tai muun julkisen yhteyspolun kautta.", + "Käytä ensimmäistä viestiä vain lyhyeen, ei-arkaluonteiseen kontekstiin. Turvallisempi yksityinen kanava voidaan sopia erikseen tarvittaessa.", + ], + }, + ], + }, + privacyCookies: { + ...en.privacyCookies, + title: "Yksityisyys ja evästeet", + navTitle: "Yksityisyys", + metaTitle: "Yksityisyys ja evästeet - Evydence", + description: + "Miten markkinointisivusto käsittelee välttämättömiä asetuksia ja valinnaista mittausta.", + eyebrow: "Yksityisyys", + heroTitle: "Yksityisyys ja evästeet.", + heroBody: [ + "Markkinointisivusto käyttää vain välttämättömiä paikallisia valintoja, kunnes päätät hyväksyä valinnaisen sivustomittauksen.", + "Kun valinnainen mittaus on käytössä, se rajoittuu koottuihin markkinointisivuston tapahtumiin.", + ], + primaryCta: "Hallitse evästevalintoja", + secondaryCta: "Avaa GitHub", + sections: [ + { + title: "Mitä voidaan mitata hyväksynnän jälkeen", + bullets: [ + "sivunäkymät", + "ulospäin lähtevät GitHub-klikkaukset", + "dokumentaatioklikkaukset", + "yhteydenottoklikkaukset", + "kielenvaihdot", + ], + }, + { + title: "Mitä ei mitata", + body: [ + "Sivusto ei saa seurata lomakkeen raakatekstiä, sähköpostiosoitteita, asiakkaiden nimiä, yritysten nimiä, evidence-pakettien dataa, tokeneita, salaisuuksia sisältäviä kyselyparametreja tai tukisisältöä.", + ], + }, + { + title: "Valinnan muuttaminen", + body: [ + "Avaa suostumuspaneeli uudelleen alatunnisteen evästeasetusten linkistä ja muuta valinnaisen mittauksen valintaa.", + ], + }, + ], + }, +}; + +export const content: Record> = { en, fi }; + +export const ui = { + en: { + skip: "Skip to content", + language: "Language", + docs: "Docs", + github: "GitHub", + footerTagline: "Self-hosted release evidence for software vendors.", + footerBoundary: + "Evydence is not a legal compliance service, certification authority, vulnerability scanner, hosted SaaS, or guarantee of release security.", + cookiePreferences: "Cookie preferences", + outboundGitHub: "View GitHub", + contactHref: externalLinks.linkedin, + }, + fi: { + skip: "Siirry sisältöön", + language: "Kieli", + docs: "Dokumentaatio", + github: "GitHub", + footerTagline: + "Itse ylläpidettävä julkaisuevidence ohjelmistotoimittajille.", + footerBoundary: + "Evydence ei ole juridinen vaatimustenmukaisuuspalvelu, sertifiointitaho, haavoittuvuusskanneri, hostattu SaaS-palvelu tai takuu julkaisun turvallisuudesta.", + cookiePreferences: "Evästeasetukset", + outboundGitHub: "Avaa GitHub", + contactHref: externalLinks.linkedin, + }, +} satisfies Record>; + +export function routeFor(pageKey: PageKey, locale: Locale): string { + return routes[pageKey][locale]; +} + +export function pageFor(locale: Locale, pageKey: PageKey): PageContent { + return content[locale][pageKey]; +} + +export function routeToSlug(path: string): string | undefined { + const slug = path.replace(/^\/+|\/+$/g, ""); + return slug === "" ? undefined : slug; +} + +export function localizedAlternates( + pageKey: PageKey, +): Array<{ locale: Locale; href: string }> { + return (Object.keys(localeNames) as Locale[]).map((locale) => ({ + locale, + href: routeFor(pageKey, locale), + })); +} + +export function staticPages(): Array<{ + locale: Locale; + pageKey: PageKey; + slug: string | undefined; +}> { + return (Object.keys(localeNames) as Locale[]).flatMap((locale) => + orderedPageKeys.map((pageKey) => ({ + locale, + pageKey, + slug: routeToSlug(routeFor(pageKey, locale)), + })), + ); +} diff --git a/site/marketing/src/layouts/BaseLayout.astro b/site/marketing/src/layouts/BaseLayout.astro new file mode 100644 index 0000000..5531275 --- /dev/null +++ b/site/marketing/src/layouts/BaseLayout.astro @@ -0,0 +1,689 @@ +--- +import ConsentBanner from "@components/ConsentBanner.astro"; +import { + content, + externalLinks, + localizedAlternates, + localeNames, + navItems, + pageFor, + routeFor, + type Locale, + type PageKey +} from "@data/site"; + +const { locale, pageKey } = Astro.props as { locale: Locale; pageKey: PageKey }; +const page = pageFor(locale, pageKey); +const copy = content[locale]; +const basePath = import.meta.env.BASE_URL.replace(/\/$/, ""); +const withBase = (path: string) => `${basePath}${path}`.replace(/\/{2,}/g, "/"); +const canonical = new URL(withBase(routeFor(pageKey, locale)), Astro.site).toString(); +const alternates = localizedAlternates(pageKey).map(({ locale: altLocale, href }) => ({ + locale: altLocale, + href: new URL(withBase(href), Astro.site).toString() +})); +const otherLocale: Locale = locale === "en" ? "fi" : "en"; +const ui = { + en: { + skip: "Skip to content", + language: "Language", + docs: "Docs", + apiDocs: "API docs", + github: "GitHub", + menu: "Menu", + footerTagline: "Self-hosted release evidence for software vendors.", + footerBoundary: + "Evydence is not a legal compliance service, certification authority, vulnerability scanner, hosted SaaS, or guarantee of release security.", + cookiePreferences: "Cookie preferences" + }, + fi: { + skip: "Siirry sisältöön", + language: "Kieli", + docs: "Dokumentaatio", + apiDocs: "API-dokumentaatio", + github: "GitHub", + menu: "Valikko", + footerTagline: "Itse ylläpidettävä julkaisuevidence ohjelmistotoimittajille.", + footerBoundary: + "Evydence ei ole juridinen vaatimustenmukaisuuspalvelu, sertifiointitaho, haavoittuvuusskanneri, hostattu SaaS-palvelu tai takuu julkaisun turvallisuudesta.", + cookiePreferences: "Evästeasetukset" + } +}[locale]; +--- + + + + + + + {page.metaTitle} + + + {alternates.map((alternate) => ( + + ))} + + + + + + + + + + +
+ +
+ + + + + + + diff --git a/site/marketing/src/pages/[...slug].astro b/site/marketing/src/pages/[...slug].astro new file mode 100644 index 0000000..263a699 --- /dev/null +++ b/site/marketing/src/pages/[...slug].astro @@ -0,0 +1,15 @@ +--- +import { staticPages } from "@data/site"; +import MarketingPage from "@templates/MarketingPage.astro"; + +export function getStaticPaths() { + return staticPages().map(({ slug, locale, pageKey }) => ({ + params: { slug }, + props: { locale, pageKey } + })); +} + +const { locale, pageKey } = Astro.props; +--- + + diff --git a/site/marketing/src/pages/docs.astro b/site/marketing/src/pages/docs.astro new file mode 100644 index 0000000..0df05dc --- /dev/null +++ b/site/marketing/src/pages/docs.astro @@ -0,0 +1,16 @@ +--- +import { externalLinks } from "@data/site"; +--- + + + + + + + + Evydence docs + + +

Continue to Evydence docs on GitHub.

+ + diff --git a/site/marketing/src/pages/github.astro b/site/marketing/src/pages/github.astro new file mode 100644 index 0000000..fc274cf --- /dev/null +++ b/site/marketing/src/pages/github.astro @@ -0,0 +1,16 @@ +--- +import { externalLinks } from "@data/site"; +--- + + + + + + + + Evydence GitHub + + +

Continue to Evydence on GitHub.

+ + diff --git a/site/marketing/src/pages/index.astro b/site/marketing/src/pages/index.astro new file mode 100644 index 0000000..a11b36b --- /dev/null +++ b/site/marketing/src/pages/index.astro @@ -0,0 +1,66 @@ +--- +import { routeFor } from "@data/site"; + +const basePath = import.meta.env.BASE_URL.replace(/\/$/, ""); +const withBase = (path: string) => `${basePath}${path}`.replace(/\/{2,}/g, "/"); +const englishUrl = withBase(routeFor("home", "en")); +const finnishUrl = withBase(routeFor("home", "fi")); +--- + + + + + + + + + Evydence + + + +
+

Evydence

+

English is the default language. Continue to the English site, or switch to Finnish.

+ Continue in English + Suomeksi +
+ + diff --git a/site/marketing/src/templates/MarketingPage.astro b/site/marketing/src/templates/MarketingPage.astro new file mode 100644 index 0000000..61574b5 --- /dev/null +++ b/site/marketing/src/templates/MarketingPage.astro @@ -0,0 +1,91 @@ +--- +import DossierVisual from "@components/DossierVisual.astro"; +import BaseLayout from "@layouts/BaseLayout.astro"; +import { externalLinks, pageFor, routeFor, type Locale, type PageKey } from "@data/site"; + +const { locale, pageKey } = Astro.props as { locale: Locale; pageKey: PageKey }; +const page = pageFor(locale, pageKey); +const basePath = import.meta.env.BASE_URL.replace(/\/$/, ""); +const withBase = (path: string) => `${basePath}${path}`.replace(/\/{2,}/g, "/"); +const primaryHref = pageKey === "privacyCookies" ? "#" : externalLinks.linkedin; +const primaryAttrs = pageKey === "privacyCookies" ? { "data-cookie-preferences": "" } : { "data-track": "contact_click" }; +--- + + +
+
+ {page.eyebrow &&

{page.eyebrow}

} +

{page.heroTitle}

+ {page.heroBody.map((paragraph) =>

{paragraph}

)} + {page.demoCta && page.demoHref && ( +

+ {page.demoEyebrow && {page.demoEyebrow}} + {page.demoCta} + {page.demoText && {page.demoText}} +

+ )} + +
+ +
+ + {page.sections.map((section) => ( +
+
+ {section.eyebrow &&

{section.eyebrow}

} +

{section.title}

+
+
+ {section.body?.map((paragraph) =>

{paragraph}

)} + {section.bullets && ( +
    + {section.bullets.map((bullet) =>
  • {bullet}
  • )} +
+ )} + {section.cards && ( +
+ {section.cards.map((card) => ( +
+

{card.title}

+

{card.body}

+
+ ))} +
+ )} + {section.table && ( +
+ {section.table.map((row) => ( +
+ {row.left} + {row.right} +
+ ))} +
+ )} +
+
+ ))} + +
+

{locale === "en" ? "Next step" : "Seuraava askel"}

+

{locale === "en" ? "Evaluate the technical truth on GitHub." : "Arvioi tekninen totuus GitHubissa."}

+
+

+ {locale === "en" + ? "The website qualifies the business problem. The repository remains the source of truth for code, docs, release artifacts, limitations, and verification evidence." + : "Verkkosivusto rajaa liiketoimintaongelman. Repositorio pysyy lähteenä koodille, dokumentaatiolle, julkaisuartefakteille, rajoituksille ja todennusevidencelle."} +

+ +
+
+
diff --git a/site/marketing/tsconfig.json b/site/marketing/tsconfig.json new file mode 100644 index 0000000..3715669 --- /dev/null +++ b/site/marketing/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "astro/tsconfigs/strict", + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@components/*": ["src/components/*"], + "@data/*": ["src/data/*"], + "@layouts/*": ["src/layouts/*"], + "@templates/*": ["src/templates/*"] + } + } +} diff --git a/site/package-viewer/index.html b/site/package-viewer/index.html new file mode 100644 index 0000000..0425af5 --- /dev/null +++ b/site/package-viewer/index.html @@ -0,0 +1,1003 @@ + + + + + + Evydence Package Viewer + + + +
+
+
+

Evydence Package Viewer

+

Local review surface for release evidence packages.

+
+
+ + + No file loaded +
+
+ +
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + + +