fix: harden provider and promotion evidence
This commit is contained in:
@@ -156,6 +156,7 @@ jobs:
|
|||||||
artifacts/release/sbom.cdx.json \
|
artifacts/release/sbom.cdx.json \
|
||||||
artifacts/security/dependency-diff.json \
|
artifacts/security/dependency-diff.json \
|
||||||
artifacts/security/license-report.json \
|
artifacts/security/license-report.json \
|
||||||
|
artifacts/security/local-evidence-assessment.json \
|
||||||
artifacts/security/scan.sarif \
|
artifacts/security/scan.sarif \
|
||||||
artifacts/security/supply-chain-coherence.json \
|
artifacts/security/supply-chain-coherence.json \
|
||||||
artifacts/security/supply-chain-verification.json \
|
artifacts/security/supply-chain-verification.json \
|
||||||
@@ -174,11 +175,16 @@ jobs:
|
|||||||
needs: immutable_build
|
needs: immutable_build
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 45
|
timeout-minutes: 45
|
||||||
|
outputs:
|
||||||
|
invocation_nonce: ${{ steps.supervise_vulnerability.outputs.invocation_nonce }}
|
||||||
env:
|
env:
|
||||||
CANDIDATE_ARCHIVE_SHA256: "${{ needs.immutable_build.outputs.archive_sha256 }}"
|
CANDIDATE_ARCHIVE_SHA256: "${{ needs.immutable_build.outputs.archive_sha256 }}"
|
||||||
CANDIDATE_ARCHIVE_PATH: ".release/vulnerability-candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz"
|
CANDIDATE_ARCHIVE_PATH: ".release/vulnerability-candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz"
|
||||||
CANDIDATE_DIST_SHA256: "${{ needs.immutable_build.outputs.dist_sha256 }}"
|
CI_RUN_ID: "${{ gitea.run_id }}"
|
||||||
CANDIDATE_LOCKFILE_PATH: .release/verified-vulnerability/pnpm-lock.yaml
|
CI_RUN_ATTEMPT: "${{ gitea.run_attempt }}"
|
||||||
|
EXPECTED_SOURCE_REVISION: "${{ gitea.sha }}"
|
||||||
|
VULNERABILITY_PUBLIC_KEY_PATH: "${{ vars.VULNERABILITY_PUBLIC_KEY_PATH }}"
|
||||||
|
VULNERABILITY_KEY_ID: "${{ vars.VULNERABILITY_KEY_ID }}"
|
||||||
VULNERABILITY_PROVIDER_COMMAND: "${{ vars.VULNERABILITY_PROVIDER_COMMAND }}"
|
VULNERABILITY_PROVIDER_COMMAND: "${{ vars.VULNERABILITY_PROVIDER_COMMAND }}"
|
||||||
VULNERABILITY_REPORT_PATH: provider-evidence/untrusted/vulnerability-report.json
|
VULNERABILITY_REPORT_PATH: provider-evidence/untrusted/vulnerability-report.json
|
||||||
VALIDATED_PROVIDER_REPORT_PATH: provider-evidence/vulnerability-report.json
|
VALIDATED_PROVIDER_REPORT_PATH: provider-evidence/vulnerability-report.json
|
||||||
@@ -198,9 +204,8 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
name: "release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}"
|
name: "release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}"
|
||||||
path: .release/vulnerability-candidate
|
path: .release/vulnerability-candidate
|
||||||
- name: Verify and extract the candidate through one inode-bound operation
|
|
||||||
run: node scripts/verify-ci-candidate-archive.ts --archive ".release/vulnerability-candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz" --extract-to ".release/verified-vulnerability"
|
|
||||||
- name: Run and validate external vulnerability provider in one trusted supervisor
|
- name: Run and validate external vulnerability provider in one trusted supervisor
|
||||||
|
id: supervise_vulnerability
|
||||||
run: node scripts/run-and-validate-provider.ts --kind vulnerability
|
run: node scripts/run-and-validate-provider.ts --kind vulnerability
|
||||||
- name: Confirm sealed vulnerability provider evidence
|
- name: Confirm sealed vulnerability provider evidence
|
||||||
run: test -s "$VALIDATED_PROVIDER_REPORT_PATH"
|
run: test -s "$VALIDATED_PROVIDER_REPORT_PATH"
|
||||||
@@ -216,11 +221,16 @@ jobs:
|
|||||||
needs: immutable_build
|
needs: immutable_build
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 45
|
timeout-minutes: 45
|
||||||
|
outputs:
|
||||||
|
invocation_nonce: ${{ steps.supervise_provenance.outputs.invocation_nonce }}
|
||||||
env:
|
env:
|
||||||
CANDIDATE_ARCHIVE_SHA256: "${{ needs.immutable_build.outputs.archive_sha256 }}"
|
CANDIDATE_ARCHIVE_SHA256: "${{ needs.immutable_build.outputs.archive_sha256 }}"
|
||||||
CANDIDATE_ARCHIVE_PATH: ".release/provenance-candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz"
|
CANDIDATE_ARCHIVE_PATH: ".release/provenance-candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz"
|
||||||
CANDIDATE_DIST_SHA256: "${{ needs.immutable_build.outputs.dist_sha256 }}"
|
CI_RUN_ID: "${{ gitea.run_id }}"
|
||||||
CANDIDATE_LOCKFILE_PATH: .release/verified-provenance/pnpm-lock.yaml
|
CI_RUN_ATTEMPT: "${{ gitea.run_attempt }}"
|
||||||
|
EXPECTED_SOURCE_REVISION: "${{ gitea.sha }}"
|
||||||
|
PROVENANCE_PUBLIC_KEY_PATH: "${{ vars.PROVENANCE_PUBLIC_KEY_PATH }}"
|
||||||
|
PROVENANCE_KEY_ID: "${{ vars.PROVENANCE_KEY_ID }}"
|
||||||
PROVENANCE_PROVIDER_COMMAND: "${{ vars.PROVENANCE_PROVIDER_COMMAND }}"
|
PROVENANCE_PROVIDER_COMMAND: "${{ vars.PROVENANCE_PROVIDER_COMMAND }}"
|
||||||
PROVENANCE_ATTESTATION_PATH: provider-evidence/untrusted/provenance-attestation.json
|
PROVENANCE_ATTESTATION_PATH: provider-evidence/untrusted/provenance-attestation.json
|
||||||
VALIDATED_PROVIDER_REPORT_PATH: provider-evidence/provenance-attestation.json
|
VALIDATED_PROVIDER_REPORT_PATH: provider-evidence/provenance-attestation.json
|
||||||
@@ -240,9 +250,8 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
name: "release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}"
|
name: "release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}"
|
||||||
path: .release/provenance-candidate
|
path: .release/provenance-candidate
|
||||||
- name: Verify and extract the candidate through one inode-bound operation
|
|
||||||
run: node scripts/verify-ci-candidate-archive.ts --archive ".release/provenance-candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz" --extract-to ".release/verified-provenance"
|
|
||||||
- name: Run and validate external provenance provider in one trusted supervisor
|
- name: Run and validate external provenance provider in one trusted supervisor
|
||||||
|
id: supervise_provenance
|
||||||
run: node scripts/run-and-validate-provider.ts --kind provenance
|
run: node scripts/run-and-validate-provider.ts --kind provenance
|
||||||
- name: Confirm sealed provenance provider evidence
|
- name: Confirm sealed provenance provider evidence
|
||||||
run: test -s "$VALIDATED_PROVIDER_REPORT_PATH"
|
run: test -s "$VALIDATED_PROVIDER_REPORT_PATH"
|
||||||
@@ -261,13 +270,16 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
CANDIDATE_ARCHIVE_SHA256: "${{ needs.immutable_build.outputs.archive_sha256 }}"
|
CANDIDATE_ARCHIVE_SHA256: "${{ needs.immutable_build.outputs.archive_sha256 }}"
|
||||||
CANDIDATE_ARCHIVE_PATH: ".release/candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz"
|
CANDIDATE_ARCHIVE_PATH: ".release/candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz"
|
||||||
CANDIDATE_ROOT: "${{ gitea.workspace }}/.release/verified-candidate"
|
CI_RUN_ID: "${{ gitea.run_id }}"
|
||||||
|
CI_RUN_ATTEMPT: "${{ gitea.run_attempt }}"
|
||||||
VULNERABILITY_REPORT_PATH: "${{ gitea.workspace }}/.release/vulnerability/vulnerability-report.json"
|
VULNERABILITY_REPORT_PATH: "${{ gitea.workspace }}/.release/vulnerability/vulnerability-report.json"
|
||||||
PROVENANCE_ATTESTATION_PATH: "${{ gitea.workspace }}/.release/provenance/provenance-attestation.json"
|
PROVENANCE_ATTESTATION_PATH: "${{ gitea.workspace }}/.release/provenance/provenance-attestation.json"
|
||||||
VULNERABILITY_PUBLIC_KEY_PATH: "${{ vars.VULNERABILITY_PUBLIC_KEY_PATH }}"
|
VULNERABILITY_PUBLIC_KEY_PATH: "${{ vars.VULNERABILITY_PUBLIC_KEY_PATH }}"
|
||||||
VULNERABILITY_KEY_ID: "${{ vars.VULNERABILITY_KEY_ID }}"
|
VULNERABILITY_KEY_ID: "${{ vars.VULNERABILITY_KEY_ID }}"
|
||||||
PROVENANCE_PUBLIC_KEY_PATH: "${{ vars.PROVENANCE_PUBLIC_KEY_PATH }}"
|
PROVENANCE_PUBLIC_KEY_PATH: "${{ vars.PROVENANCE_PUBLIC_KEY_PATH }}"
|
||||||
PROVENANCE_KEY_ID: "${{ vars.PROVENANCE_KEY_ID }}"
|
PROVENANCE_KEY_ID: "${{ vars.PROVENANCE_KEY_ID }}"
|
||||||
|
VULNERABILITY_INVOCATION_NONCE: "${{ needs.vulnerability_provider.outputs.invocation_nonce }}"
|
||||||
|
PROVENANCE_INVOCATION_NONCE: "${{ needs.provenance_provider.outputs.invocation_nonce }}"
|
||||||
steps:
|
steps:
|
||||||
- uses: https://github.com/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
- uses: https://github.com/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
||||||
with:
|
with:
|
||||||
@@ -294,21 +306,31 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
name: "provenance-provider-${{ gitea.run_id }}-${{ gitea.run_attempt }}"
|
name: "provenance-provider-${{ gitea.run_id }}-${{ gitea.run_attempt }}"
|
||||||
path: .release/provenance
|
path: .release/provenance
|
||||||
- name: Verify and extract the candidate through one inode-bound operation
|
|
||||||
run: node scripts/verify-ci-candidate-archive.ts --archive ".release/candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz" --extract-to ".release/verified-candidate"
|
|
||||||
- name: Finalize verified promotion from inode-bound captured inputs
|
- name: Finalize verified promotion from inode-bound captured inputs
|
||||||
|
id: finalize
|
||||||
run: node scripts/stage-verified-promotion.ts
|
run: node scripts/stage-verified-promotion.ts
|
||||||
- name: Upload promoted release
|
- name: Upload promoted release
|
||||||
uses: https://github.com/ChristopherHX/gitea-upload-artifact@81f940d004763f986ba3582c007fd842dd5cb0d7
|
uses: https://github.com/ChristopherHX/gitea-upload-artifact@81f940d004763f986ba3582c007fd842dd5cb0d7
|
||||||
with:
|
with:
|
||||||
name: "promoted-release-${{ gitea.run_id }}-${{ gitea.run_attempt }}"
|
name: "promoted-release-${{ gitea.run_id }}-${{ gitea.run_attempt }}"
|
||||||
path: |
|
path: |
|
||||||
.release/promoted-staging/release-candidate.tar.gz
|
${{ steps.finalize.outputs.staging_root }}/release-candidate.tar.gz
|
||||||
.release/promoted-staging/vulnerability-report.json
|
${{ steps.finalize.outputs.staging_root }}/vulnerability-report.json
|
||||||
.release/promoted-staging/provenance-attestation.json
|
${{ steps.finalize.outputs.staging_root }}/provenance-attestation.json
|
||||||
.release/promoted-staging/provider-verification.json
|
${{ steps.finalize.outputs.staging_root }}/provider-verification.json
|
||||||
.release/promoted-staging/promotion-verification.json
|
${{ steps.finalize.outputs.staging_root }}/promotion-verification.json
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
- name: Always remove private promotion staging
|
||||||
|
if: always()
|
||||||
|
env:
|
||||||
|
PROMOTION_STAGING_ROOT: ${{ steps.finalize.outputs.staging_root }}
|
||||||
|
PROMOTION_CLEANUP_TOKEN: ${{ steps.finalize.outputs.cleanup_token }}
|
||||||
|
PROMOTION_RUNNER_TEMP_DEV: ${{ steps.finalize.outputs.runner_temp_dev }}
|
||||||
|
PROMOTION_RUNNER_TEMP_INO: ${{ steps.finalize.outputs.runner_temp_ino }}
|
||||||
|
run: |
|
||||||
|
if [ -n "$PROMOTION_STAGING_ROOT" ] && [ -n "$PROMOTION_CLEANUP_TOKEN" ]; then
|
||||||
|
node scripts/cleanup-verified-promotion.ts
|
||||||
|
fi
|
||||||
|
|
||||||
production_gate:
|
production_gate:
|
||||||
name: "${{ matrix.gate }} / ${{ matrix.name }}"
|
name: "${{ matrix.gate }} / ${{ matrix.name }}"
|
||||||
|
|||||||
+62
-33
@@ -2180,6 +2180,7 @@
|
|||||||
"artifacts/release/sbom.cdx.json",
|
"artifacts/release/sbom.cdx.json",
|
||||||
"artifacts/security/dependency-diff.json",
|
"artifacts/security/dependency-diff.json",
|
||||||
"artifacts/security/license-report.json",
|
"artifacts/security/license-report.json",
|
||||||
|
"artifacts/security/local-evidence-assessment.json",
|
||||||
"artifacts/security/scan.sarif",
|
"artifacts/security/scan.sarif",
|
||||||
"artifacts/security/supply-chain-coherence.json",
|
"artifacts/security/supply-chain-coherence.json",
|
||||||
"artifacts/security/supply-chain-verification.json",
|
"artifacts/security/supply-chain-verification.json",
|
||||||
@@ -2220,12 +2221,24 @@
|
|||||||
"value": ".release/vulnerability-candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz"
|
"value": ".release/vulnerability-candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "CANDIDATE_DIST_SHA256",
|
"name": "CI_RUN_ID",
|
||||||
"value": "${{ needs.immutable_build.outputs.dist_sha256 }}"
|
"value": "${{ gitea.run_id }}"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "CANDIDATE_LOCKFILE_PATH",
|
"name": "CI_RUN_ATTEMPT",
|
||||||
"value": ".release/verified-vulnerability/pnpm-lock.yaml"
|
"value": "${{ gitea.run_attempt }}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "EXPECTED_SOURCE_REVISION",
|
||||||
|
"value": "${{ gitea.sha }}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "VULNERABILITY_PUBLIC_KEY_PATH",
|
||||||
|
"value": "${{ vars.VULNERABILITY_PUBLIC_KEY_PATH }}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "VULNERABILITY_KEY_ID",
|
||||||
|
"value": "${{ vars.VULNERABILITY_KEY_ID }}"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "VULNERABILITY_PROVIDER_COMMAND",
|
"name": "VULNERABILITY_PROVIDER_COMMAND",
|
||||||
@@ -2255,14 +2268,10 @@
|
|||||||
"transferId": "release-candidate",
|
"transferId": "release-candidate",
|
||||||
"path": ".release/vulnerability-candidate"
|
"path": ".release/vulnerability-candidate"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"kind": "extract",
|
|
||||||
"archivePath": ".release/vulnerability-candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz",
|
|
||||||
"targetRoot": ".release/verified-vulnerability"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"kind": "run-provider",
|
"kind": "run-provider",
|
||||||
"provider": "vulnerability"
|
"provider": "vulnerability",
|
||||||
|
"stepId": "supervise_vulnerability"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"kind": "validate-provider-evidence",
|
"kind": "validate-provider-evidence",
|
||||||
@@ -2299,12 +2308,24 @@
|
|||||||
"value": ".release/provenance-candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz"
|
"value": ".release/provenance-candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "CANDIDATE_DIST_SHA256",
|
"name": "CI_RUN_ID",
|
||||||
"value": "${{ needs.immutable_build.outputs.dist_sha256 }}"
|
"value": "${{ gitea.run_id }}"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "CANDIDATE_LOCKFILE_PATH",
|
"name": "CI_RUN_ATTEMPT",
|
||||||
"value": ".release/verified-provenance/pnpm-lock.yaml"
|
"value": "${{ gitea.run_attempt }}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "EXPECTED_SOURCE_REVISION",
|
||||||
|
"value": "${{ gitea.sha }}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "PROVENANCE_PUBLIC_KEY_PATH",
|
||||||
|
"value": "${{ vars.PROVENANCE_PUBLIC_KEY_PATH }}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "PROVENANCE_KEY_ID",
|
||||||
|
"value": "${{ vars.PROVENANCE_KEY_ID }}"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "PROVENANCE_PROVIDER_COMMAND",
|
"name": "PROVENANCE_PROVIDER_COMMAND",
|
||||||
@@ -2334,14 +2355,10 @@
|
|||||||
"transferId": "release-candidate",
|
"transferId": "release-candidate",
|
||||||
"path": ".release/provenance-candidate"
|
"path": ".release/provenance-candidate"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"kind": "extract",
|
|
||||||
"archivePath": ".release/provenance-candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz",
|
|
||||||
"targetRoot": ".release/verified-provenance"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"kind": "run-provider",
|
"kind": "run-provider",
|
||||||
"provider": "provenance"
|
"provider": "provenance",
|
||||||
|
"stepId": "supervise_provenance"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"kind": "validate-provider-evidence",
|
"kind": "validate-provider-evidence",
|
||||||
@@ -2380,8 +2397,12 @@
|
|||||||
"value": ".release/candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz"
|
"value": ".release/candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "CANDIDATE_ROOT",
|
"name": "CI_RUN_ID",
|
||||||
"value": "${{ gitea.workspace }}/.release/verified-candidate"
|
"value": "${{ gitea.run_id }}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "CI_RUN_ATTEMPT",
|
||||||
|
"value": "${{ gitea.run_attempt }}"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "VULNERABILITY_REPORT_PATH",
|
"name": "VULNERABILITY_REPORT_PATH",
|
||||||
@@ -2406,6 +2427,14 @@
|
|||||||
{
|
{
|
||||||
"name": "PROVENANCE_KEY_ID",
|
"name": "PROVENANCE_KEY_ID",
|
||||||
"value": "${{ vars.PROVENANCE_KEY_ID }}"
|
"value": "${{ vars.PROVENANCE_KEY_ID }}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "VULNERABILITY_INVOCATION_NONCE",
|
||||||
|
"value": "${{ needs.vulnerability_provider.outputs.invocation_nonce }}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "PROVENANCE_INVOCATION_NONCE",
|
||||||
|
"value": "${{ needs.provenance_provider.outputs.invocation_nonce }}"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"steps": [
|
"steps": [
|
||||||
@@ -2434,24 +2463,24 @@
|
|||||||
"path": ".release/provenance"
|
"path": ".release/provenance"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"kind": "extract",
|
"kind": "verify-promotion",
|
||||||
"archivePath": ".release/candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz",
|
"stepId": "finalize"
|
||||||
"targetRoot": ".release/verified-candidate"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"kind": "verify-promotion"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"kind": "upload",
|
"kind": "upload",
|
||||||
"transferId": "promoted-release",
|
"transferId": "promoted-release",
|
||||||
"name": "promoted-release-${{ gitea.run_id }}-${{ gitea.run_attempt }}",
|
"name": "promoted-release-${{ gitea.run_id }}-${{ gitea.run_attempt }}",
|
||||||
"paths": [
|
"paths": [
|
||||||
".release/promoted-staging/release-candidate.tar.gz",
|
"${{ steps.finalize.outputs.staging_root }}/release-candidate.tar.gz",
|
||||||
".release/promoted-staging/vulnerability-report.json",
|
"${{ steps.finalize.outputs.staging_root }}/vulnerability-report.json",
|
||||||
".release/promoted-staging/provenance-attestation.json",
|
"${{ steps.finalize.outputs.staging_root }}/provenance-attestation.json",
|
||||||
".release/promoted-staging/provider-verification.json",
|
"${{ steps.finalize.outputs.staging_root }}/provider-verification.json",
|
||||||
".release/promoted-staging/promotion-verification.json"
|
"${{ steps.finalize.outputs.staging_root }}/promotion-verification.json"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "cleanup-promotion",
|
||||||
|
"finalizerStepId": "finalize"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -196,6 +196,14 @@ If any external provider command, report, trust path, or key ID is absent,
|
|||||||
promotion remains unavailable with `FAIL_UNVERIFIED`; there is no local
|
promotion remains unavailable with `FAIL_UNVERIFIED`; there is no local
|
||||||
generator/restore fallback.
|
generator/restore fallback.
|
||||||
|
|
||||||
|
Each provider command must atomically emit strict provider evidence v2 from
|
||||||
|
the supervisor bindings for evidence type, issued/expires timestamps, run
|
||||||
|
ID/attempt, `PROVIDER_INVOCATION_NONCE`, source identity, candidate digests,
|
||||||
|
key ID, and DER-SPKI key fingerprint. Promotion receives the two supervisor
|
||||||
|
job outputs as `VULNERABILITY_INVOCATION_NONCE` and
|
||||||
|
`PROVENANCE_INVOCATION_NONCE`; do not replace them with constants or values
|
||||||
|
parsed from provider reports.
|
||||||
|
|
||||||
Promotion verification/staging step과 promoted-release upload action 사이에는
|
Promotion verification/staging step과 promoted-release upload action 사이에는
|
||||||
어떤 step도 둘 수 없고 upload에는 `if: always()`를 사용할 수 없다. 이 인접성은
|
어떤 step도 둘 수 없고 upload에는 `if: always()`를 사용할 수 없다. 이 인접성은
|
||||||
실패한 검증의 publication을 막지만 staging path를 upload action에
|
실패한 검증의 publication을 막지만 staging path를 upload action에
|
||||||
@@ -207,6 +215,20 @@ consumer도 artifact service나 transfer action을 신뢰 경계 밖으로 보
|
|||||||
manifest와 signed provider evidence에 바인딩된 digest를 다운로드 후 다시
|
manifest와 signed provider evidence에 바인딩된 digest를 다운로드 후 다시
|
||||||
검증해야 한다. 현재 producer-side adjacency 자체는 consumer-side digest
|
검증해야 한다. 현재 producer-side adjacency 자체는 consumer-side digest
|
||||||
revalidation을 대신하지 않는다.
|
revalidation을 대신하지 않는다.
|
||||||
|
Finalizer output은 `RUNNER_TEMP` 아래 random private directory이며 exact-five
|
||||||
|
upload는 `${{ steps.finalize.outputs.staging_root }}`만 사용한다. 바로 다음
|
||||||
|
`always()` cleanup은 finalizer의 token과 runner-temp device/inode를 모두
|
||||||
|
요구한다. stable `.release/promoted-staging` directory를 만들거나 재사용하지
|
||||||
|
않는다. exact five는 captured archive/report 두 개와 process 안에서 생성한
|
||||||
|
provider/promotion verification v3 두 개이며 promotion record는 provider record,
|
||||||
|
local assessment, report hashes와 run/source/candidate/nonces/key identities/
|
||||||
|
trust-policy hash를 함께 bind한다. 이 descriptor-relative 정리는 ancestor 교체와 symlink leaf를
|
||||||
|
fail-closed로 처리하지만 upload action의 same-UID pathname reopen 또는 atomic
|
||||||
|
`renameat2` handoff를 보장하지 않는다. staging Gitea smoke/native adapter 확인
|
||||||
|
전에는 그 경계를 닫았다고 보고하지 않는다. 실제 smoke는 exact-five
|
||||||
|
upload-download와 success, validation failure, upload failure, cancellation 각각의
|
||||||
|
cleanup을 관찰해야 한다. 현재 repository에는 native uploader나 `renameat2`
|
||||||
|
보장이 없다.
|
||||||
|
|
||||||
Branch protection must mark each `FE-GATE-* / <name>` check required for its
|
Branch protection must mark each `FE-GATE-* / <name>` check required for its
|
||||||
declared tier. This repository cannot configure server-side protection by
|
declared tier. This repository cannot configure server-side protection by
|
||||||
|
|||||||
@@ -106,6 +106,27 @@ or direct access to the sealed evidence path. Missing sandbox support, stale or
|
|||||||
misplaced outputs, command failure/timeout, and post-command candidate drift
|
misplaced outputs, command failure/timeout, and post-command candidate drift
|
||||||
all stop publication.
|
all stop publication.
|
||||||
|
|
||||||
|
Provider documents are strict schema v2. Their Ed25519 signature covers the
|
||||||
|
supervisor-supplied evidence type, validity window, run ID/attempt, independent
|
||||||
|
32-byte invocation nonce, archived source identity, and all four candidate
|
||||||
|
digests. Each provider job exposes its supervisor-generated nonce as a job
|
||||||
|
output; promotion treats those outputs as the independent expected values and
|
||||||
|
never lets a report define its own expected nonce. A report from another
|
||||||
|
attempt, source, archive, nonce, or key fingerprint is fail-closed even when it
|
||||||
|
has been correctly re-signed.
|
||||||
|
|
||||||
|
The immutable archive contains a strict producer-local assessment. Promotion
|
||||||
|
revalidates it from captured archive members without reopening checkout policy
|
||||||
|
or source paths. The finalizer captures the archive, both reports, and both
|
||||||
|
public keys once, generates both verification v3 records in memory, and writes
|
||||||
|
exactly five mode-`0400` files beneath a random mode-`0700` directory in
|
||||||
|
`RUNNER_TEMP`. The exact five are the captured archive, captured vulnerability
|
||||||
|
report, captured provenance attestation, generated provider-verification v3,
|
||||||
|
and generated promotion-verification v3. The promotion record binds the exact
|
||||||
|
provider-record hash, local-assessment hash, both report hashes, run/source/
|
||||||
|
candidate identities, both nonces, both key IDs/fingerprints, and canonical
|
||||||
|
trust-policy hash. It never creates or reuses `.release/promoted-staging`.
|
||||||
|
|
||||||
The final promotion verification/staging step must be immediately adjacent to
|
The final promotion verification/staging step must be immediately adjacent to
|
||||||
the promoted-release upload, and that upload must not use `always()`. This
|
the promoted-release upload, and that upload must not use `always()`. This
|
||||||
reduces the post-verification mutation window but does not seal a pathname
|
reduces the post-verification mutation window but does not seal a pathname
|
||||||
@@ -116,6 +137,13 @@ service and transfer actions also remain outside the candidate's cryptographic
|
|||||||
identity: every downstream consumer must revalidate the downloaded archive,
|
identity: every downstream consumer must revalidate the downloaded archive,
|
||||||
manifest member digests and signed provider evidence. Producer-side adjacency
|
manifest member digests and signed provider evidence. Producer-side adjacency
|
||||||
does not provide consumer-side digest revalidation.
|
does not provide consumer-side digest revalidation.
|
||||||
|
The immediately following upload action still reopens pathnames. The
|
||||||
|
descriptor-relative staging and cleanup code does not claim an atomic
|
||||||
|
`renameat2` handoff or close a malicious same-UID Gitea upload adapter; the
|
||||||
|
staging Gitea smoke/native platform adapter remains the required closure for
|
||||||
|
that boundary. That smoke must exercise exact-five upload and download plus
|
||||||
|
cleanup on success, validation failure, upload failure, and cancellation. No
|
||||||
|
native uploader or `renameat2` guarantee exists in this repository today.
|
||||||
|
|
||||||
Approved vulnerability exceptions require vulnerability/package identity,
|
Approved vulnerability exceptions require vulnerability/package identity,
|
||||||
owner, a different reviewer, reason and expiry. Expired or self-approved
|
owner, a different reviewer, reason and expiry. Expired or self-approved
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# Security Finalizer Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Finalize a captured immutable candidate into one private random exact-five staging directory with strict v3 verification records and deterministic cleanup.
|
||||||
|
|
||||||
|
**Architecture:** `finalizeVerifiedPromotion` captures the archive, provider reports, and public keys before validation, evaluates only those captured bytes against archived local evidence, generates both v3 records in memory, and publishes five read-only files under a descriptor-relative runner-temp directory. The generated workflow consumes the returned staging path immediately and always invokes the token-bound cleanup CLI.
|
||||||
|
|
||||||
|
**Tech Stack:** Node.js 24, TypeScript, Zod, Vitest, bubblewrap-independent filesystem primitives, generated Gitea Actions YAML.
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- Never accept pre-existing provider- or promotion-verification JSON as an input.
|
||||||
|
- Stage exactly archive, vulnerability report, provenance attestation, provider verification v3, and promotion verification v3.
|
||||||
|
- Use injected time and randomness for deterministic tests.
|
||||||
|
- Use a random runner-temp directory at mode `0700`, files at `0400`, and `O_EXCL | O_NOFOLLOW` creation.
|
||||||
|
- Do not claim that TypeScript closes the Gitea upload action pathname-reopen issue or guarantees `renameat2` semantics.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Exact-five finalizer contract
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `tests/unit/ci-artifact-contract.test.ts`
|
||||||
|
- Modify: `scripts/lib/promotion-stager.ts`
|
||||||
|
- Modify: `scripts/contracts/promotion-artifacts.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `finalizeVerifiedPromotion(input, dependencies)` with captured archive/report/key inputs.
|
||||||
|
- Produces: `{ stagingRoot, cleanupToken, files }` where `files` is the canonical exact-five name/digest list.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing tests** for no pre-existing records, strict distinct v3 roles, exact provider-record and local-assessment hashes, full run/source/candidate/nonces/key/trust bindings, key rotation, captured-source mutation, and no output on failures.
|
||||||
|
- [ ] **Step 2: Run RED:** `corepack pnpm exec vitest run tests/unit/ci-artifact-contract.test.ts -t "verified promotion finalizer" --maxWorkers=1` and retain the first contract failure.
|
||||||
|
- [ ] **Step 3: Implement minimal finalizer changes** so all validation and record generation consume captured bytes and both PASS records are created only after local/provider PASS.
|
||||||
|
- [ ] **Step 4: Run GREEN:** rerun the focused Vitest command and require zero failures.
|
||||||
|
|
||||||
|
### Task 2: Private staging and cleanup
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `tests/unit/ci-artifact-contract.test.ts`
|
||||||
|
- Modify: `scripts/lib/promotion-stager.ts`
|
||||||
|
- Modify: `scripts/cleanup-verified-promotion.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: injected `randomBytes`, runner-temp root, cleanup token.
|
||||||
|
- Produces: descriptor-relative random staging at `0700`, exact files at `0400`, and token-bound cleanup.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing tests** for deterministic naming, modes, stable-path absence, exclusive no-follow creation, parent/leaf substitution, success cleanup, and failure cleanup.
|
||||||
|
- [ ] **Step 2: Run RED:** use the Task 1 focused Vitest command and retain the first filesystem-boundary failure.
|
||||||
|
- [ ] **Step 3: Implement minimal private publication and cleanup changes** using `/proc/self/fd` where available, bounded writes, identity rechecks, and removal of owned partial roots.
|
||||||
|
- [ ] **Step 4: Run GREEN:** rerun the focused Vitest command and require zero failures.
|
||||||
|
|
||||||
|
### Task 3: Workflow handoff
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `config/ci/gates.json`
|
||||||
|
- Modify: `scripts/contracts/ci-gates.ts`
|
||||||
|
- Modify: `scripts/stage-verified-promotion.ts`
|
||||||
|
- Modify: `.gitea/workflows/quality-gates.yml`
|
||||||
|
- Modify: `tests/unit/ci-workflow-generation.test.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: finalizer step outputs `staging_root` and `cleanup_token`.
|
||||||
|
- Produces: setup, three downloads, finalizer, immediate non-`always()` exact-five upload, and `always()` cleanup ordering.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write/update failing workflow assertions** that reject standalone extraction, stable staging paths, missing `--ignore-scripts`, upload indirection, or cleanup ordering drift.
|
||||||
|
- [ ] **Step 2: Run RED:** `node scripts/generate-ci-workflow.ts --check` and the workflow snapshot test.
|
||||||
|
- [ ] **Step 3: Update the CI contract/config and regenerate YAML** with the finalizer output path and cleanup environment.
|
||||||
|
- [ ] **Step 4: Run GREEN:** require workflow byte check and snapshot test PASS.
|
||||||
|
|
||||||
|
### Task 4: Full verification and durable report
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `.superpowers/sdd/2026-08-01-quality-architecture-remediation/task-3-report.md`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: focused finalizer, provider, workflow, type, and lint evidence.
|
||||||
|
- Produces: durable RED/GREEN evidence and a commit-ready report without overclaiming platform handoff guarantees.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run verification:** focused finalizer/provider tests, `check:supply-chain:provider-fixtures`, workflow `--check`, `check:types`, and `lint`.
|
||||||
|
- [ ] **Step 2: Append exact RED/GREEN commands and outcomes** to the task report, including the remaining Gitea upload and `renameat2` limitations.
|
||||||
|
- [ ] **Step 3: Inspect diff/status** and report completion before committing.
|
||||||
@@ -3,15 +3,31 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
import { providerEvidenceSignaturePayload } from "./lib/provider-evidence.ts";
|
import {
|
||||||
|
providerEvidenceSignaturePayload,
|
||||||
|
providerPublicKeyFingerprint,
|
||||||
|
} from "./lib/provider-evidence.ts";
|
||||||
|
import { localEvidenceAssessmentArtifactSchema } from "./contracts/release-artifacts.ts";
|
||||||
import { verifyPromotionInputs } from "./lib/promotion-verifier.ts";
|
import { verifyPromotionInputs } from "./lib/promotion-verifier.ts";
|
||||||
import {
|
import {
|
||||||
createReleaseCandidateManifest,
|
createReleaseCandidateManifest,
|
||||||
|
LOCAL_EVIDENCE_ASSESSMENT_PATH,
|
||||||
RELEASE_CANDIDATE_EVIDENCE_PATHS,
|
RELEASE_CANDIDATE_EVIDENCE_PATHS,
|
||||||
RELEASE_CANDIDATE_MANIFEST_PATH,
|
RELEASE_CANDIDATE_MANIFEST_PATH,
|
||||||
releaseCandidateManifestSchema,
|
releaseCandidateManifestSchema,
|
||||||
} from "./lib/release-candidate.ts";
|
} from "./lib/release-candidate.ts";
|
||||||
|
|
||||||
|
const NOW = Date.parse("2026-08-02T01:00:00.000Z");
|
||||||
|
const FIXTURE_SOURCE = Object.freeze({
|
||||||
|
revision: "a".repeat(40),
|
||||||
|
sourceSetSha256: "b".repeat(64),
|
||||||
|
});
|
||||||
|
const FIXTURE_LOCAL_IDENTITY = Object.freeze({
|
||||||
|
sourceRevision: FIXTURE_SOURCE.revision,
|
||||||
|
sourceSetSha256: FIXTURE_SOURCE.sourceSetSha256,
|
||||||
|
assessmentSha256: "c".repeat(64),
|
||||||
|
});
|
||||||
|
|
||||||
const fixtureRoot = await mkdtemp(
|
const fixtureRoot = await mkdtemp(
|
||||||
path.join(tmpdir(), "supply-chain-provider-fixture-"),
|
path.join(tmpdir(), "supply-chain-provider-fixture-"),
|
||||||
);
|
);
|
||||||
@@ -25,18 +41,27 @@ try {
|
|||||||
),
|
),
|
||||||
) as unknown,
|
) as unknown,
|
||||||
);
|
);
|
||||||
|
const actualAssessment = localEvidenceAssessmentArtifactSchema.parse(
|
||||||
|
JSON.parse(
|
||||||
|
await readFile(path.join(repositoryRoot, LOCAL_EVIDENCE_ASSESSMENT_PATH), "utf8"),
|
||||||
|
) as unknown,
|
||||||
|
);
|
||||||
const actualProviderEnvironment = absoluteProviderEnvironment(
|
const actualProviderEnvironment = absoluteProviderEnvironment(
|
||||||
fixtureRoot,
|
fixtureRoot,
|
||||||
await writeProviderEnvironment(
|
await writeProviderEnvironment(
|
||||||
fixtureRoot,
|
fixtureRoot,
|
||||||
"actual",
|
"actual",
|
||||||
actualCandidate.distSha256,
|
actualCandidate,
|
||||||
actualCandidate.lockfileSha256,
|
{
|
||||||
|
revision: actualAssessment.source.revision,
|
||||||
|
sourceSetSha256: actualAssessment.source.sourceSetSha256,
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const actualDefaultVerifier = await verifyPromotionInputs({
|
const actualDefaultVerifier = await verifyPromotionInputs({
|
||||||
artifactType: "provider-verification",
|
artifactType: "provider-verification",
|
||||||
environment: actualProviderEnvironment,
|
environment: actualProviderEnvironment,
|
||||||
|
nowEpochMs: () => NOW,
|
||||||
});
|
});
|
||||||
|
|
||||||
const rawLockfile = "lockfileVersion: '9.0'\n";
|
const rawLockfile = "lockfileVersion: '9.0'\n";
|
||||||
@@ -69,17 +94,19 @@ try {
|
|||||||
const validEnvironment = await writeProviderEnvironment(
|
const validEnvironment = await writeProviderEnvironment(
|
||||||
fixtureRoot,
|
fixtureRoot,
|
||||||
"valid",
|
"valid",
|
||||||
candidate.distSha256,
|
candidate,
|
||||||
candidate.lockfileSha256,
|
FIXTURE_SOURCE,
|
||||||
);
|
);
|
||||||
const wrongEnvironment = await writeProviderEnvironment(
|
const wrongEnvironment = await writeProviderEnvironment(
|
||||||
fixtureRoot,
|
fixtureRoot,
|
||||||
"wrong",
|
"wrong",
|
||||||
"3".repeat(64),
|
candidate,
|
||||||
candidate.lockfileSha256,
|
FIXTURE_SOURCE,
|
||||||
|
{ distSha256: "3".repeat(64) },
|
||||||
);
|
);
|
||||||
const acceptLocalEvidence = async () => ({
|
const acceptLocalEvidence = async () => ({
|
||||||
status: "PASS" as const,
|
status: "PASS" as const,
|
||||||
|
identity: FIXTURE_LOCAL_IDENTITY,
|
||||||
failures: [] as const,
|
failures: [] as const,
|
||||||
});
|
});
|
||||||
const fixtures = {
|
const fixtures = {
|
||||||
@@ -88,18 +115,21 @@ try {
|
|||||||
repositoryRoot: fixtureRoot,
|
repositoryRoot: fixtureRoot,
|
||||||
environment: {},
|
environment: {},
|
||||||
verifyLocalEvidence: acceptLocalEvidence,
|
verifyLocalEvidence: acceptLocalEvidence,
|
||||||
|
nowEpochMs: () => NOW,
|
||||||
}),
|
}),
|
||||||
validImmutable: await verifyPromotionInputs({
|
validImmutable: await verifyPromotionInputs({
|
||||||
artifactType: "provider-verification",
|
artifactType: "provider-verification",
|
||||||
repositoryRoot: fixtureRoot,
|
repositoryRoot: fixtureRoot,
|
||||||
environment: validEnvironment,
|
environment: validEnvironment,
|
||||||
verifyLocalEvidence: acceptLocalEvidence,
|
verifyLocalEvidence: acceptLocalEvidence,
|
||||||
|
nowEpochMs: () => NOW,
|
||||||
}),
|
}),
|
||||||
wrongDigest: await verifyPromotionInputs({
|
wrongDigest: await verifyPromotionInputs({
|
||||||
artifactType: "provider-verification",
|
artifactType: "provider-verification",
|
||||||
repositoryRoot: fixtureRoot,
|
repositoryRoot: fixtureRoot,
|
||||||
environment: wrongEnvironment,
|
environment: wrongEnvironment,
|
||||||
verifyLocalEvidence: acceptLocalEvidence,
|
verifyLocalEvidence: acceptLocalEvidence,
|
||||||
|
nowEpochMs: () => NOW,
|
||||||
}),
|
}),
|
||||||
postAttestationMutation: null as Awaited<
|
postAttestationMutation: null as Awaited<
|
||||||
ReturnType<typeof verifyPromotionInputs>
|
ReturnType<typeof verifyPromotionInputs>
|
||||||
@@ -111,6 +141,7 @@ try {
|
|||||||
repositoryRoot: fixtureRoot,
|
repositoryRoot: fixtureRoot,
|
||||||
environment: validEnvironment,
|
environment: validEnvironment,
|
||||||
verifyLocalEvidence: acceptLocalEvidence,
|
verifyLocalEvidence: acceptLocalEvidence,
|
||||||
|
nowEpochMs: () => NOW,
|
||||||
});
|
});
|
||||||
|
|
||||||
const passed =
|
const passed =
|
||||||
@@ -179,40 +210,63 @@ function absoluteProviderEnvironment(
|
|||||||
async function writeProviderEnvironment(
|
async function writeProviderEnvironment(
|
||||||
repositoryRoot: string,
|
repositoryRoot: string,
|
||||||
name: string,
|
name: string,
|
||||||
distDigest: string,
|
candidate: Awaited<ReturnType<typeof createReleaseCandidateManifest>>,
|
||||||
lockfileSha256: string,
|
source: Readonly<{ revision: string; sourceSetSha256: string }>,
|
||||||
|
overrides: Readonly<{ distSha256?: string }> = {},
|
||||||
): Promise<NodeJS.ProcessEnv> {
|
): Promise<NodeJS.ProcessEnv> {
|
||||||
const vulnerabilityKeys = generateKeyPairSync("ed25519");
|
const vulnerabilityKeys = generateKeyPairSync("ed25519");
|
||||||
const provenanceKeys = generateKeyPairSync("ed25519");
|
const provenanceKeys = generateKeyPairSync("ed25519");
|
||||||
const directory = `provider/${name}`;
|
const directory = `provider/${name}`;
|
||||||
|
const archiveBytes = `fixture archive ${name}\n`;
|
||||||
|
const archiveSha256 = createHash("sha256").update(archiveBytes).digest("hex");
|
||||||
|
const candidateIdentity = {
|
||||||
|
archiveSha256,
|
||||||
|
bundleSha256: candidate.bundleSha256,
|
||||||
|
distSha256: overrides.distSha256 ?? candidate.distSha256,
|
||||||
|
lockfileSha256: candidate.lockfileSha256,
|
||||||
|
};
|
||||||
|
const sourceIdentity = {
|
||||||
|
revision: source.revision,
|
||||||
|
sourceSetSha256: source.sourceSetSha256,
|
||||||
|
};
|
||||||
await mkdir(path.join(repositoryRoot, directory), { recursive: true });
|
await mkdir(path.join(repositoryRoot, directory), { recursive: true });
|
||||||
const vulnerability = signedEvidence(
|
const vulnerability = signedEvidence(
|
||||||
{
|
{
|
||||||
schemaVersion: 1,
|
schemaVersion: 2,
|
||||||
|
evidenceType: "vulnerability-report",
|
||||||
provider: "fixture-vulnerability-provider",
|
provider: "fixture-vulnerability-provider",
|
||||||
generatedAt: "2026-08-01T00:00:00.000Z",
|
issuedAt: "2026-08-02T01:00:00.000Z",
|
||||||
scannedLockfileSha256: lockfileSha256,
|
expiresAt: "2026-08-02T02:00:00.000Z",
|
||||||
scannedDistSha256: distDigest,
|
run: { id: "fixture-run", attempt: 1, invocationNonce: "1".repeat(64) },
|
||||||
|
source: sourceIdentity,
|
||||||
|
candidate: candidateIdentity,
|
||||||
findings: [],
|
findings: [],
|
||||||
},
|
},
|
||||||
"fixture-vulnerability-key",
|
"fixture-vulnerability-key",
|
||||||
|
vulnerabilityKeys.publicKey,
|
||||||
vulnerabilityKeys.privateKey,
|
vulnerabilityKeys.privateKey,
|
||||||
);
|
);
|
||||||
const provenance = signedEvidence(
|
const provenance = signedEvidence(
|
||||||
{
|
{
|
||||||
schemaVersion: 1,
|
schemaVersion: 2,
|
||||||
|
evidenceType: "provenance-attestation",
|
||||||
provider: "fixture-provenance-provider",
|
provider: "fixture-provenance-provider",
|
||||||
signer: "fixture-workload-identity",
|
signer: "fixture-workload-identity",
|
||||||
generatedAt: "2026-08-01T00:00:00.000Z",
|
issuedAt: "2026-08-02T01:00:00.000Z",
|
||||||
subject: { name: "dist", digest: { sha256: distDigest } },
|
expiresAt: "2026-08-02T02:00:00.000Z",
|
||||||
|
run: { id: "fixture-run", attempt: 1, invocationNonce: "2".repeat(64) },
|
||||||
|
source: sourceIdentity,
|
||||||
|
candidate: candidateIdentity,
|
||||||
|
subject: { name: "dist", digest: { sha256: candidateIdentity.distSha256 } },
|
||||||
},
|
},
|
||||||
"fixture-provenance-key",
|
"fixture-provenance-key",
|
||||||
|
provenanceKeys.publicKey,
|
||||||
provenanceKeys.privateKey,
|
provenanceKeys.privateKey,
|
||||||
);
|
);
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
writeFile(
|
writeFile(
|
||||||
path.join(repositoryRoot, directory, "candidate.tar.gz"),
|
path.join(repositoryRoot, directory, "candidate.tar.gz"),
|
||||||
"fixture archive\n",
|
archiveBytes,
|
||||||
),
|
),
|
||||||
writeFile(
|
writeFile(
|
||||||
path.join(repositoryRoot, directory, "vulnerability.json"),
|
path.join(repositoryRoot, directory, "vulnerability.json"),
|
||||||
@@ -237,9 +291,12 @@ async function writeProviderEnvironment(
|
|||||||
]);
|
]);
|
||||||
return {
|
return {
|
||||||
CANDIDATE_ARCHIVE_PATH: `${directory}/candidate.tar.gz`,
|
CANDIDATE_ARCHIVE_PATH: `${directory}/candidate.tar.gz`,
|
||||||
CANDIDATE_ARCHIVE_SHA256: createHash("sha256")
|
CANDIDATE_ARCHIVE_SHA256: archiveSha256,
|
||||||
.update("fixture archive\n")
|
CI_RUN_ID: "fixture-run",
|
||||||
.digest("hex"),
|
CI_RUN_ATTEMPT: "1",
|
||||||
|
EXPECTED_SOURCE_REVISION: source.revision,
|
||||||
|
VULNERABILITY_INVOCATION_NONCE: "1".repeat(64),
|
||||||
|
PROVENANCE_INVOCATION_NONCE: "2".repeat(64),
|
||||||
VULNERABILITY_REPORT_PATH: `${directory}/vulnerability.json`,
|
VULNERABILITY_REPORT_PATH: `${directory}/vulnerability.json`,
|
||||||
PROVENANCE_ATTESTATION_PATH: `${directory}/provenance.json`,
|
PROVENANCE_ATTESTATION_PATH: `${directory}/provenance.json`,
|
||||||
VULNERABILITY_PUBLIC_KEY_PATH: `${directory}/vulnerability.pem`,
|
VULNERABILITY_PUBLIC_KEY_PATH: `${directory}/vulnerability.pem`,
|
||||||
@@ -252,6 +309,7 @@ async function writeProviderEnvironment(
|
|||||||
function signedEvidence(
|
function signedEvidence(
|
||||||
value: Record<string, unknown>,
|
value: Record<string, unknown>,
|
||||||
keyId: string,
|
keyId: string,
|
||||||
|
publicKey: ReturnType<typeof generateKeyPairSync>["publicKey"],
|
||||||
privateKey: ReturnType<typeof generateKeyPairSync>["privateKey"],
|
privateKey: ReturnType<typeof generateKeyPairSync>["privateKey"],
|
||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
@@ -259,6 +317,7 @@ function signedEvidence(
|
|||||||
signature: {
|
signature: {
|
||||||
algorithm: "Ed25519",
|
algorithm: "Ed25519",
|
||||||
keyId,
|
keyId,
|
||||||
|
publicKeyFingerprint: providerPublicKeyFingerprint(publicKey),
|
||||||
value: sign(
|
value: sign(
|
||||||
null,
|
null,
|
||||||
providerEvidenceSignaturePayload(value),
|
providerEvidenceSignaturePayload(value),
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { cleanupFinalizedPromotion } from "./lib/promotion-stager.ts";
|
||||||
|
|
||||||
|
const required = (name: string): string => {
|
||||||
|
const value = process.env[name];
|
||||||
|
if (!value) throw new TypeError(`promotion cleanup environment is missing ${name}`);
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
const requiredIdentity = (name: string): number => {
|
||||||
|
const value = Number(required(name));
|
||||||
|
if (!Number.isSafeInteger(value) || value <= 0) {
|
||||||
|
throw new TypeError(`promotion cleanup environment has invalid ${name}`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
await cleanupFinalizedPromotion({
|
||||||
|
runnerTempRoot: required("RUNNER_TEMP"),
|
||||||
|
stagingRoot: required("PROMOTION_STAGING_ROOT"),
|
||||||
|
cleanupToken: required("PROMOTION_CLEANUP_TOKEN"),
|
||||||
|
runnerTempIdentity: {
|
||||||
|
dev: requiredIdentity("PROMOTION_RUNNER_TEMP_DEV"),
|
||||||
|
ino: requiredIdentity("PROMOTION_RUNNER_TEMP_INO"),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
process.stdout.write("Promotion staging cleanup: PASS\n");
|
||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
RELEASE_CANDIDATE_MANIFEST_PATH,
|
RELEASE_CANDIDATE_MANIFEST_PATH,
|
||||||
} from "../lib/release-candidate.ts";
|
} from "../lib/release-candidate.ts";
|
||||||
import { validatePackageScriptGraph } from "../lib/package-script-graph.ts";
|
import { validatePackageScriptGraph } from "../lib/package-script-graph.ts";
|
||||||
import { PROMOTED_STAGING_PATHS } from "./promotion-artifacts.ts";
|
import { PROMOTED_UPLOAD_PATHS } from "./promotion-artifacts.ts";
|
||||||
|
|
||||||
const ciActionRegistrationSchema = z
|
const ciActionRegistrationSchema = z
|
||||||
.object({
|
.object({
|
||||||
@@ -332,7 +332,11 @@ const extractStep = z
|
|||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
const providerStep = z
|
const providerStep = z
|
||||||
.object({ kind: z.literal("run-provider"), provider: z.enum(["vulnerability", "provenance"]) })
|
.object({
|
||||||
|
kind: z.literal("run-provider"),
|
||||||
|
provider: z.enum(["vulnerability", "provenance"]),
|
||||||
|
stepId: id,
|
||||||
|
})
|
||||||
.strict();
|
.strict();
|
||||||
const validateProviderStep = z
|
const validateProviderStep = z
|
||||||
.object({
|
.object({
|
||||||
@@ -340,7 +344,12 @@ const validateProviderStep = z
|
|||||||
provider: z.enum(["vulnerability", "provenance"]),
|
provider: z.enum(["vulnerability", "provenance"]),
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
const promotionStep = z.object({ kind: z.literal("verify-promotion") }).strict();
|
const promotionStep = z
|
||||||
|
.object({ kind: z.literal("verify-promotion"), stepId: id })
|
||||||
|
.strict();
|
||||||
|
const cleanupPromotionStep = z
|
||||||
|
.object({ kind: z.literal("cleanup-promotion"), finalizerStepId: id })
|
||||||
|
.strict();
|
||||||
|
|
||||||
const jobStepSchema = z.discriminatedUnion("kind", [
|
const jobStepSchema = z.discriminatedUnion("kind", [
|
||||||
checkoutStep,
|
checkoutStep,
|
||||||
@@ -356,6 +365,7 @@ const jobStepSchema = z.discriminatedUnion("kind", [
|
|||||||
providerStep,
|
providerStep,
|
||||||
validateProviderStep,
|
validateProviderStep,
|
||||||
promotionStep,
|
promotionStep,
|
||||||
|
cleanupPromotionStep,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const jobSchema = z
|
const jobSchema = z
|
||||||
@@ -756,9 +766,9 @@ function validateContractSemantics(
|
|||||||
merge_gate: ["checkout", "setup-node", "frozen-install", "browser-install", "run-gate", "upload"],
|
merge_gate: ["checkout", "setup-node", "frozen-install", "browser-install", "run-gate", "upload"],
|
||||||
release_gate: ["checkout", "setup-node", "frozen-install", "browser-install", "run-gate", "upload"],
|
release_gate: ["checkout", "setup-node", "frozen-install", "browser-install", "run-gate", "upload"],
|
||||||
immutable_build: ["checkout", "setup-node", "frozen-install", "run-gate", "archive-candidate", "upload"],
|
immutable_build: ["checkout", "setup-node", "frozen-install", "run-gate", "archive-candidate", "upload"],
|
||||||
vulnerability_provider: ["checkout", "setup-node", "frozen-install", "download", "extract", "run-provider", "validate-provider-evidence", "upload"],
|
vulnerability_provider: ["checkout", "setup-node", "frozen-install", "download", "run-provider", "validate-provider-evidence", "upload"],
|
||||||
provenance_provider: ["checkout", "setup-node", "frozen-install", "download", "extract", "run-provider", "validate-provider-evidence", "upload"],
|
provenance_provider: ["checkout", "setup-node", "frozen-install", "download", "run-provider", "validate-provider-evidence", "upload"],
|
||||||
promotion: ["checkout", "setup-node", "frozen-install", "download", "download", "download", "extract", "verify-promotion", "upload"],
|
promotion: ["checkout", "setup-node", "frozen-install", "download", "download", "download", "verify-promotion", "upload", "cleanup-promotion"],
|
||||||
production_gate: ["checkout", "setup-node", "frozen-install", "run-gate", "upload"],
|
production_gate: ["checkout", "setup-node", "frozen-install", "run-gate", "upload"],
|
||||||
field_gate: ["checkout", "setup-node", "frozen-install", "run-gate", "upload"],
|
field_gate: ["checkout", "setup-node", "frozen-install", "run-gate", "upload"],
|
||||||
documentation_gate: ["checkout", "setup-node", "frozen-install", "run-gate", "upload"],
|
documentation_gate: ["checkout", "setup-node", "frozen-install", "run-gate", "upload"],
|
||||||
@@ -776,8 +786,11 @@ function validateContractSemantics(
|
|||||||
vulnerability_provider: [
|
vulnerability_provider: [
|
||||||
{ name: "CANDIDATE_ARCHIVE_SHA256", value: "${{ needs.immutable_build.outputs.archive_sha256 }}" },
|
{ name: "CANDIDATE_ARCHIVE_SHA256", value: "${{ needs.immutable_build.outputs.archive_sha256 }}" },
|
||||||
{ name: "CANDIDATE_ARCHIVE_PATH", value: ".release/vulnerability-candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz" },
|
{ name: "CANDIDATE_ARCHIVE_PATH", value: ".release/vulnerability-candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz" },
|
||||||
{ name: "CANDIDATE_DIST_SHA256", value: "${{ needs.immutable_build.outputs.dist_sha256 }}" },
|
{ name: "CI_RUN_ID", value: "${{ gitea.run_id }}" },
|
||||||
{ name: "CANDIDATE_LOCKFILE_PATH", value: ".release/verified-vulnerability/pnpm-lock.yaml" },
|
{ name: "CI_RUN_ATTEMPT", value: "${{ gitea.run_attempt }}" },
|
||||||
|
{ name: "EXPECTED_SOURCE_REVISION", value: "${{ gitea.sha }}" },
|
||||||
|
{ name: "VULNERABILITY_PUBLIC_KEY_PATH", value: "${{ vars.VULNERABILITY_PUBLIC_KEY_PATH }}" },
|
||||||
|
{ name: "VULNERABILITY_KEY_ID", value: "${{ vars.VULNERABILITY_KEY_ID }}" },
|
||||||
{ name: "VULNERABILITY_PROVIDER_COMMAND", value: "${{ vars.VULNERABILITY_PROVIDER_COMMAND }}" },
|
{ name: "VULNERABILITY_PROVIDER_COMMAND", value: "${{ vars.VULNERABILITY_PROVIDER_COMMAND }}" },
|
||||||
{ name: "VULNERABILITY_REPORT_PATH", value: "provider-evidence/untrusted/vulnerability-report.json" },
|
{ name: "VULNERABILITY_REPORT_PATH", value: "provider-evidence/untrusted/vulnerability-report.json" },
|
||||||
{ name: "VALIDATED_PROVIDER_REPORT_PATH", value: "provider-evidence/vulnerability-report.json" },
|
{ name: "VALIDATED_PROVIDER_REPORT_PATH", value: "provider-evidence/vulnerability-report.json" },
|
||||||
@@ -785,8 +798,11 @@ function validateContractSemantics(
|
|||||||
provenance_provider: [
|
provenance_provider: [
|
||||||
{ name: "CANDIDATE_ARCHIVE_SHA256", value: "${{ needs.immutable_build.outputs.archive_sha256 }}" },
|
{ name: "CANDIDATE_ARCHIVE_SHA256", value: "${{ needs.immutable_build.outputs.archive_sha256 }}" },
|
||||||
{ name: "CANDIDATE_ARCHIVE_PATH", value: ".release/provenance-candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz" },
|
{ name: "CANDIDATE_ARCHIVE_PATH", value: ".release/provenance-candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz" },
|
||||||
{ name: "CANDIDATE_DIST_SHA256", value: "${{ needs.immutable_build.outputs.dist_sha256 }}" },
|
{ name: "CI_RUN_ID", value: "${{ gitea.run_id }}" },
|
||||||
{ name: "CANDIDATE_LOCKFILE_PATH", value: ".release/verified-provenance/pnpm-lock.yaml" },
|
{ name: "CI_RUN_ATTEMPT", value: "${{ gitea.run_attempt }}" },
|
||||||
|
{ name: "EXPECTED_SOURCE_REVISION", value: "${{ gitea.sha }}" },
|
||||||
|
{ name: "PROVENANCE_PUBLIC_KEY_PATH", value: "${{ vars.PROVENANCE_PUBLIC_KEY_PATH }}" },
|
||||||
|
{ name: "PROVENANCE_KEY_ID", value: "${{ vars.PROVENANCE_KEY_ID }}" },
|
||||||
{ name: "PROVENANCE_PROVIDER_COMMAND", value: "${{ vars.PROVENANCE_PROVIDER_COMMAND }}" },
|
{ name: "PROVENANCE_PROVIDER_COMMAND", value: "${{ vars.PROVENANCE_PROVIDER_COMMAND }}" },
|
||||||
{ name: "PROVENANCE_ATTESTATION_PATH", value: "provider-evidence/untrusted/provenance-attestation.json" },
|
{ name: "PROVENANCE_ATTESTATION_PATH", value: "provider-evidence/untrusted/provenance-attestation.json" },
|
||||||
{ name: "VALIDATED_PROVIDER_REPORT_PATH", value: "provider-evidence/provenance-attestation.json" },
|
{ name: "VALIDATED_PROVIDER_REPORT_PATH", value: "provider-evidence/provenance-attestation.json" },
|
||||||
@@ -794,13 +810,16 @@ function validateContractSemantics(
|
|||||||
promotion: [
|
promotion: [
|
||||||
{ name: "CANDIDATE_ARCHIVE_SHA256", value: "${{ needs.immutable_build.outputs.archive_sha256 }}" },
|
{ name: "CANDIDATE_ARCHIVE_SHA256", value: "${{ needs.immutable_build.outputs.archive_sha256 }}" },
|
||||||
{ name: "CANDIDATE_ARCHIVE_PATH", value: ".release/candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz" },
|
{ name: "CANDIDATE_ARCHIVE_PATH", value: ".release/candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz" },
|
||||||
{ name: "CANDIDATE_ROOT", value: "${{ gitea.workspace }}/.release/verified-candidate" },
|
{ name: "CI_RUN_ID", value: "${{ gitea.run_id }}" },
|
||||||
|
{ name: "CI_RUN_ATTEMPT", value: "${{ gitea.run_attempt }}" },
|
||||||
{ name: "VULNERABILITY_REPORT_PATH", value: "${{ gitea.workspace }}/.release/vulnerability/vulnerability-report.json" },
|
{ name: "VULNERABILITY_REPORT_PATH", value: "${{ gitea.workspace }}/.release/vulnerability/vulnerability-report.json" },
|
||||||
{ name: "PROVENANCE_ATTESTATION_PATH", value: "${{ gitea.workspace }}/.release/provenance/provenance-attestation.json" },
|
{ name: "PROVENANCE_ATTESTATION_PATH", value: "${{ gitea.workspace }}/.release/provenance/provenance-attestation.json" },
|
||||||
{ name: "VULNERABILITY_PUBLIC_KEY_PATH", value: "${{ vars.VULNERABILITY_PUBLIC_KEY_PATH }}" },
|
{ name: "VULNERABILITY_PUBLIC_KEY_PATH", value: "${{ vars.VULNERABILITY_PUBLIC_KEY_PATH }}" },
|
||||||
{ name: "VULNERABILITY_KEY_ID", value: "${{ vars.VULNERABILITY_KEY_ID }}" },
|
{ name: "VULNERABILITY_KEY_ID", value: "${{ vars.VULNERABILITY_KEY_ID }}" },
|
||||||
{ name: "PROVENANCE_PUBLIC_KEY_PATH", value: "${{ vars.PROVENANCE_PUBLIC_KEY_PATH }}" },
|
{ name: "PROVENANCE_PUBLIC_KEY_PATH", value: "${{ vars.PROVENANCE_PUBLIC_KEY_PATH }}" },
|
||||||
{ name: "PROVENANCE_KEY_ID", value: "${{ vars.PROVENANCE_KEY_ID }}" },
|
{ name: "PROVENANCE_KEY_ID", value: "${{ vars.PROVENANCE_KEY_ID }}" },
|
||||||
|
{ name: "VULNERABILITY_INVOCATION_NONCE", value: "${{ needs.vulnerability_provider.outputs.invocation_nonce }}" },
|
||||||
|
{ name: "PROVENANCE_INVOCATION_NONCE", value: "${{ needs.provenance_provider.outputs.invocation_nonce }}" },
|
||||||
],
|
],
|
||||||
production_gate: [],
|
production_gate: [],
|
||||||
field_gate: [
|
field_gate: [
|
||||||
@@ -875,12 +894,14 @@ function validateContractSemantics(
|
|||||||
if (upload?.kind === "upload" && upload.always) {
|
if (upload?.kind === "upload" && upload.always) {
|
||||||
issue("promotion upload must not use always");
|
issue("promotion upload must not use always");
|
||||||
}
|
}
|
||||||
|
const cleanupIndex = order.indexOf("cleanup-promotion");
|
||||||
if (
|
if (
|
||||||
order.indexOf("extract") < order.lastIndexOf("download") ||
|
order.includes("extract") ||
|
||||||
order.indexOf("verify-promotion") < order.indexOf("extract") ||
|
verificationIndex < order.lastIndexOf("download") ||
|
||||||
order.indexOf("upload") < order.indexOf("verify-promotion")
|
uploadIndex < verificationIndex ||
|
||||||
|
cleanupIndex !== uploadIndex + 1
|
||||||
) {
|
) {
|
||||||
issue("promotion formula order must download, verify, then upload");
|
issue("promotion formula order must download, finalize, upload, then cleanup without extraction");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const immutable = contract.jobs.find(({ id }) => id === "immutable_build");
|
const immutable = contract.jobs.find(({ id }) => id === "immutable_build");
|
||||||
@@ -905,7 +926,7 @@ function validateContractSemantics(
|
|||||||
const promotionUpload = promotion?.steps.find(
|
const promotionUpload = promotion?.steps.find(
|
||||||
(step) => step.kind === "upload" && step.transferId === "promoted-release",
|
(step) => step.kind === "upload" && step.transferId === "promoted-release",
|
||||||
);
|
);
|
||||||
if (!promotionUpload || promotionUpload.kind !== "upload" || JSON.stringify(promotionUpload.paths) !== JSON.stringify(PROMOTED_STAGING_PATHS)) {
|
if (!promotionUpload || promotionUpload.kind !== "upload" || JSON.stringify(promotionUpload.paths) !== JSON.stringify(PROMOTED_UPLOAD_PATHS)) {
|
||||||
issue("promotion upload bundle must contain the exact five typed paths");
|
issue("promotion upload bundle must contain the exact five typed paths");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -934,10 +955,9 @@ function validateCanonicalStepFields(
|
|||||||
const providerExpectations = {
|
const providerExpectations = {
|
||||||
vulnerability_provider: {
|
vulnerability_provider: {
|
||||||
provider: "vulnerability",
|
provider: "vulnerability",
|
||||||
|
stepId: "supervise_vulnerability",
|
||||||
downloadPath: ".release/vulnerability-candidate",
|
downloadPath: ".release/vulnerability-candidate",
|
||||||
archivePath: ".release/vulnerability-candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz",
|
archivePath: ".release/vulnerability-candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz",
|
||||||
targetRoot: ".release/verified-vulnerability",
|
|
||||||
lockfilePath: ".release/verified-vulnerability/pnpm-lock.yaml",
|
|
||||||
rawPath: "provider-evidence/untrusted/vulnerability-report.json",
|
rawPath: "provider-evidence/untrusted/vulnerability-report.json",
|
||||||
rawName: "VULNERABILITY_REPORT_PATH",
|
rawName: "VULNERABILITY_REPORT_PATH",
|
||||||
sealedPath: "provider-evidence/vulnerability-report.json",
|
sealedPath: "provider-evidence/vulnerability-report.json",
|
||||||
@@ -945,10 +965,9 @@ function validateCanonicalStepFields(
|
|||||||
},
|
},
|
||||||
provenance_provider: {
|
provenance_provider: {
|
||||||
provider: "provenance",
|
provider: "provenance",
|
||||||
|
stepId: "supervise_provenance",
|
||||||
downloadPath: ".release/provenance-candidate",
|
downloadPath: ".release/provenance-candidate",
|
||||||
archivePath: ".release/provenance-candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz",
|
archivePath: ".release/provenance-candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz",
|
||||||
targetRoot: ".release/verified-provenance",
|
|
||||||
lockfilePath: ".release/verified-provenance/pnpm-lock.yaml",
|
|
||||||
rawPath: "provider-evidence/untrusted/provenance-attestation.json",
|
rawPath: "provider-evidence/untrusted/provenance-attestation.json",
|
||||||
rawName: "PROVENANCE_ATTESTATION_PATH",
|
rawName: "PROVENANCE_ATTESTATION_PATH",
|
||||||
sealedPath: "provider-evidence/provenance-attestation.json",
|
sealedPath: "provider-evidence/provenance-attestation.json",
|
||||||
@@ -959,7 +978,6 @@ function validateCanonicalStepFields(
|
|||||||
const job = contract.jobs.find(({ id }) => id === jobId);
|
const job = contract.jobs.find(({ id }) => id === jobId);
|
||||||
const environment = new Map(job?.environment.map(({ name, value }) => [name, value]));
|
const environment = new Map(job?.environment.map(({ name, value }) => [name, value]));
|
||||||
const download = job?.steps.find(({ kind }) => kind === "download");
|
const download = job?.steps.find(({ kind }) => kind === "download");
|
||||||
const extract = job?.steps.find(({ kind }) => kind === "extract");
|
|
||||||
const runProvider = job?.steps.find(({ kind }) => kind === "run-provider");
|
const runProvider = job?.steps.find(({ kind }) => kind === "run-provider");
|
||||||
const validateProvider = job?.steps.find(({ kind }) => kind === "validate-provider-evidence");
|
const validateProvider = job?.steps.find(({ kind }) => kind === "validate-provider-evidence");
|
||||||
const upload = job?.steps.find(
|
const upload = job?.steps.find(
|
||||||
@@ -967,11 +985,9 @@ function validateCanonicalStepFields(
|
|||||||
);
|
);
|
||||||
if (
|
if (
|
||||||
!download || download.kind !== "download" || download.transferId !== "release-candidate" || download.path !== expected.downloadPath ||
|
!download || download.kind !== "download" || download.transferId !== "release-candidate" || download.path !== expected.downloadPath ||
|
||||||
!extract || extract.kind !== "extract" || extract.archivePath !== expected.archivePath || extract.targetRoot !== expected.targetRoot ||
|
!runProvider || runProvider.kind !== "run-provider" || runProvider.provider !== expected.provider || runProvider.stepId !== expected.stepId ||
|
||||||
!runProvider || runProvider.kind !== "run-provider" || runProvider.provider !== expected.provider ||
|
|
||||||
!validateProvider || validateProvider.kind !== "validate-provider-evidence" || validateProvider.provider !== expected.provider ||
|
!validateProvider || validateProvider.kind !== "validate-provider-evidence" || validateProvider.provider !== expected.provider ||
|
||||||
environment.get("CANDIDATE_ARCHIVE_PATH") !== expected.archivePath ||
|
environment.get("CANDIDATE_ARCHIVE_PATH") !== expected.archivePath ||
|
||||||
environment.get("CANDIDATE_LOCKFILE_PATH") !== expected.lockfilePath ||
|
|
||||||
environment.get(expected.rawName) !== expected.rawPath ||
|
environment.get(expected.rawName) !== expected.rawPath ||
|
||||||
environment.get("VALIDATED_PROVIDER_REPORT_PATH") !== expected.sealedPath ||
|
environment.get("VALIDATED_PROVIDER_REPORT_PATH") !== expected.sealedPath ||
|
||||||
!upload || upload.kind !== "upload" || JSON.stringify(upload.paths) !== JSON.stringify([expected.sealedPath])
|
!upload || upload.kind !== "upload" || JSON.stringify(upload.paths) !== JSON.stringify([expected.sealedPath])
|
||||||
@@ -987,15 +1003,18 @@ function validateCanonicalStepFields(
|
|||||||
{ kind: "download", transferId: "vulnerability-provider-evidence", path: ".release/vulnerability" },
|
{ kind: "download", transferId: "vulnerability-provider-evidence", path: ".release/vulnerability" },
|
||||||
{ kind: "download", transferId: "provenance-provider-evidence", path: ".release/provenance" },
|
{ kind: "download", transferId: "provenance-provider-evidence", path: ".release/provenance" },
|
||||||
];
|
];
|
||||||
const promotionExtract = promotion?.steps.find(({ kind }) => kind === "extract");
|
const promotionFinalizer = promotion?.steps.find(({ kind }) => kind === "verify-promotion");
|
||||||
|
const promotionCleanup = promotion?.steps.find(({ kind }) => kind === "cleanup-promotion");
|
||||||
if (
|
if (
|
||||||
JSON.stringify(promotionDownloads) !== JSON.stringify(expectedDownloads) ||
|
JSON.stringify(promotionDownloads) !== JSON.stringify(expectedDownloads) ||
|
||||||
!promotionExtract ||
|
!promotionFinalizer ||
|
||||||
promotionExtract.kind !== "extract" ||
|
promotionFinalizer.kind !== "verify-promotion" ||
|
||||||
promotionExtract.archivePath !== ".release/candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz" ||
|
promotionFinalizer.stepId !== "finalize" ||
|
||||||
promotionExtract.targetRoot !== ".release/verified-candidate"
|
!promotionCleanup ||
|
||||||
|
promotionCleanup.kind !== "cleanup-promotion" ||
|
||||||
|
promotionCleanup.finalizerStepId !== "finalize"
|
||||||
) {
|
) {
|
||||||
issue("promotion download and extraction fields must remain linked");
|
issue("promotion download fields and finalizer/cleanup step identities must remain linked");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1008,7 +1027,7 @@ function validateJobStepKinds(
|
|||||||
"gate-single": new Set(["checkout", "setup-node", "frozen-install", "run-gate", "upload"]),
|
"gate-single": new Set(["checkout", "setup-node", "frozen-install", "run-gate", "upload"]),
|
||||||
immutable: new Set(["checkout", "setup-node", "frozen-install", "run-gate", "archive-candidate", "upload"]),
|
immutable: new Set(["checkout", "setup-node", "frozen-install", "run-gate", "archive-candidate", "upload"]),
|
||||||
provider: new Set(["checkout", "setup-node", "frozen-install", "download", "validate-candidate-archive", "extract", "run-provider", "validate-provider-evidence", "upload"]),
|
provider: new Set(["checkout", "setup-node", "frozen-install", "download", "validate-candidate-archive", "extract", "run-provider", "validate-provider-evidence", "upload"]),
|
||||||
promotion: new Set(["checkout", "setup-node", "frozen-install", "download", "validate-candidate-archive", "extract", "verify-promotion", "upload"]),
|
promotion: new Set(["checkout", "setup-node", "frozen-install", "download", "verify-promotion", "upload", "cleanup-promotion"]),
|
||||||
};
|
};
|
||||||
for (const step of job.steps) {
|
for (const step of job.steps) {
|
||||||
if (!allowed[job.kind].has(step.kind)) {
|
if (!allowed[job.kind].has(step.kind)) {
|
||||||
@@ -1016,16 +1035,12 @@ function validateJobStepKinds(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const kinds = job.steps.map(({ kind }) => kind);
|
const kinds = job.steps.map(({ kind }) => kind);
|
||||||
const extractIndex = kinds.indexOf("extract");
|
|
||||||
if ((job.kind === "provider" || job.kind === "promotion") && extractIndex < 0) {
|
|
||||||
issue(`verified extraction step is missing: ${job.id}`);
|
|
||||||
}
|
|
||||||
if (job.kind === "provider") {
|
if (job.kind === "provider") {
|
||||||
const providerIndex = kinds.indexOf("run-provider");
|
const providerIndex = kinds.indexOf("run-provider");
|
||||||
const validateProviderIndex = kinds.indexOf("validate-provider-evidence");
|
const validateProviderIndex = kinds.indexOf("validate-provider-evidence");
|
||||||
const uploadIndex = kinds.indexOf("upload");
|
const uploadIndex = kinds.indexOf("upload");
|
||||||
if (
|
if (
|
||||||
providerIndex < extractIndex ||
|
providerIndex < kinds.lastIndexOf("download") ||
|
||||||
validateProviderIndex < providerIndex ||
|
validateProviderIndex < providerIndex ||
|
||||||
uploadIndex < validateProviderIndex
|
uploadIndex < validateProviderIndex
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
export const PROMOTED_STAGING_PATHS = Object.freeze([
|
export const PROMOTED_FILE_NAMES = Object.freeze([
|
||||||
".release/promoted-staging/release-candidate.tar.gz",
|
"release-candidate.tar.gz",
|
||||||
".release/promoted-staging/vulnerability-report.json",
|
"vulnerability-report.json",
|
||||||
".release/promoted-staging/provenance-attestation.json",
|
"provenance-attestation.json",
|
||||||
".release/promoted-staging/provider-verification.json",
|
"provider-verification.json",
|
||||||
".release/promoted-staging/promotion-verification.json",
|
"promotion-verification.json",
|
||||||
] as const);
|
] as const);
|
||||||
|
|
||||||
|
export type PromotedFileName = (typeof PROMOTED_FILE_NAMES)[number];
|
||||||
|
|
||||||
|
export const PROMOTED_UPLOAD_PATHS = Object.freeze(
|
||||||
|
PROMOTED_FILE_NAMES.map((name) => `\${{ steps.finalize.outputs.staging_root }}/${name}`),
|
||||||
|
);
|
||||||
|
|||||||
@@ -7,6 +7,131 @@ const timestamp = z.iso.datetime();
|
|||||||
const sha256 = z.string().regex(/^[a-f0-9]{64}$/u);
|
const sha256 = z.string().regex(/^[a-f0-9]{64}$/u);
|
||||||
const jsonObject = z.record(z.string(), z.json());
|
const jsonObject = z.record(z.string(), z.json());
|
||||||
|
|
||||||
|
const canonicalTimestamp = z
|
||||||
|
.string()
|
||||||
|
.regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u)
|
||||||
|
.refine((value) => new Date(value).toISOString() === value, {
|
||||||
|
message: "must be a canonical ISO-8601 UTC timestamp",
|
||||||
|
});
|
||||||
|
const safeRepositoryPath = z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.max(1_024)
|
||||||
|
.refine(
|
||||||
|
(value) =>
|
||||||
|
!value.startsWith("-") &&
|
||||||
|
!value.startsWith("/") &&
|
||||||
|
!value.includes("\\") &&
|
||||||
|
!value.split("/").some((segment) => segment === "" || segment === "." || segment === "..") &&
|
||||||
|
![...value].some((character) => {
|
||||||
|
const codePoint = character.codePointAt(0)!;
|
||||||
|
return codePoint <= 0x1f || codePoint === 0x7f;
|
||||||
|
}),
|
||||||
|
{ message: "must be a safe canonical repository-relative path" },
|
||||||
|
);
|
||||||
|
const assessmentInputRowSchema = z
|
||||||
|
.object({
|
||||||
|
path: safeRepositoryPath,
|
||||||
|
bytes: z.int().nonnegative().max(268_435_456),
|
||||||
|
sha256,
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
const assessmentStatusSchema = z.enum(["PASS", "FAIL"]);
|
||||||
|
|
||||||
|
function addCanonicalInputIssues(
|
||||||
|
rows: readonly Readonly<{ path: string }>[],
|
||||||
|
pathPrefix: "policyInputs" | "evidenceInputs",
|
||||||
|
context: z.RefinementCtx,
|
||||||
|
): void {
|
||||||
|
const paths = rows.map(({ path }) => path);
|
||||||
|
const canonical = [...paths].sort((left, right) =>
|
||||||
|
left < right ? -1 : left > right ? 1 : 0,
|
||||||
|
);
|
||||||
|
if (JSON.stringify(paths) !== JSON.stringify(canonical)) {
|
||||||
|
context.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
path: [pathPrefix],
|
||||||
|
message: "must be in canonical ASCII path order",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (new Set(paths).size !== paths.length) {
|
||||||
|
context.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
path: [pathPrefix],
|
||||||
|
message: "must not contain duplicate paths",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const localEvidenceAssessmentArtifactSchema = z
|
||||||
|
.object({
|
||||||
|
schemaVersion: z.literal(1),
|
||||||
|
artifactType: z.literal("local-evidence-assessment"),
|
||||||
|
generatedAt: canonicalTimestamp,
|
||||||
|
status: assessmentStatusSchema,
|
||||||
|
verifier: z
|
||||||
|
.object({
|
||||||
|
id: nonEmptyString,
|
||||||
|
version: nonEmptyString,
|
||||||
|
sourceSha256: sha256,
|
||||||
|
})
|
||||||
|
.strict(),
|
||||||
|
source: z
|
||||||
|
.object({
|
||||||
|
revision: z.string().regex(/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/u),
|
||||||
|
sourceSetSha256: sha256,
|
||||||
|
})
|
||||||
|
.strict(),
|
||||||
|
candidate: z
|
||||||
|
.object({ distSha256: sha256, lockfileSha256: sha256, sbomSha256: sha256 })
|
||||||
|
.strict(),
|
||||||
|
policyInputs: z.array(assessmentInputRowSchema).min(1).max(256),
|
||||||
|
evidenceInputs: z.array(assessmentInputRowSchema).min(1).max(4_096),
|
||||||
|
checks: z
|
||||||
|
.object({
|
||||||
|
release: assessmentStatusSchema,
|
||||||
|
supplyChain: assessmentStatusSchema,
|
||||||
|
dependencyPolicy: assessmentStatusSchema,
|
||||||
|
licensePolicy: assessmentStatusSchema,
|
||||||
|
vulnerabilityPolicy: assessmentStatusSchema,
|
||||||
|
secretScan: assessmentStatusSchema,
|
||||||
|
})
|
||||||
|
.strict(),
|
||||||
|
failures: z.array(z.string()),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.superRefine((assessment, context) => {
|
||||||
|
addCanonicalInputIssues(assessment.policyInputs, "policyInputs", context);
|
||||||
|
addCanonicalInputIssues(assessment.evidenceInputs, "evidenceInputs", context);
|
||||||
|
const failedChecks = Object.values(assessment.checks).filter(
|
||||||
|
(status) => status === "FAIL",
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
assessment.status === "PASS" &&
|
||||||
|
(failedChecks.length > 0 || assessment.failures.length > 0)
|
||||||
|
) {
|
||||||
|
context.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
path: ["status"],
|
||||||
|
message: "PASS requires all six checks PASS and no failures",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
assessment.status === "FAIL" &&
|
||||||
|
(failedChecks.length === 0 || assessment.failures.length === 0)
|
||||||
|
) {
|
||||||
|
context.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
path: ["status"],
|
||||||
|
message: "FAIL requires a failed check and a failure diagnostic",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export type LocalEvidenceAssessment = z.infer<
|
||||||
|
typeof localEvidenceAssessmentArtifactSchema
|
||||||
|
>;
|
||||||
|
|
||||||
export const moduleInventoryArtifactSchema = z
|
export const moduleInventoryArtifactSchema = z
|
||||||
.object({
|
.object({
|
||||||
schemaVersion: z.literal(1),
|
schemaVersion: z.literal(1),
|
||||||
|
|||||||
@@ -1,12 +1,22 @@
|
|||||||
import { mkdir } from "node:fs/promises";
|
import { mkdir } from "node:fs/promises";
|
||||||
|
|
||||||
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
|
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
|
||||||
|
import { localEvidenceAssessmentArtifactSchema } from "./contracts/release-artifacts.ts";
|
||||||
|
import { createLocalEvidenceAssessment } from "./lib/local-release-evidence.ts";
|
||||||
import {
|
import {
|
||||||
createReleaseCandidateManifest,
|
createReleaseCandidateManifest,
|
||||||
|
LOCAL_EVIDENCE_ASSESSMENT_PATH,
|
||||||
RELEASE_CANDIDATE_MANIFEST_PATH,
|
RELEASE_CANDIDATE_MANIFEST_PATH,
|
||||||
releaseCandidateManifestSchema,
|
releaseCandidateManifestSchema,
|
||||||
} from "./lib/release-candidate.ts";
|
} from "./lib/release-candidate.ts";
|
||||||
|
|
||||||
|
const assessment = await createLocalEvidenceAssessment();
|
||||||
|
await mkdir("artifacts/security", { recursive: true });
|
||||||
|
await writeValidatedJsonArtifact({
|
||||||
|
path: LOCAL_EVIDENCE_ASSESSMENT_PATH,
|
||||||
|
schema: localEvidenceAssessmentArtifactSchema,
|
||||||
|
value: assessment,
|
||||||
|
});
|
||||||
const manifest = await createReleaseCandidateManifest();
|
const manifest = await createReleaseCandidateManifest();
|
||||||
await mkdir("artifacts/release", { recursive: true });
|
await mkdir("artifacts/release", { recursive: true });
|
||||||
await writeValidatedJsonArtifact({
|
await writeValidatedJsonArtifact({
|
||||||
|
|||||||
@@ -131,6 +131,16 @@ function renderJob(
|
|||||||
` ${archive.archiveOutputName}: \${{ steps.${archive.stepId}.outputs.${archive.archiveOutputName} }}`,
|
` ${archive.archiveOutputName}: \${{ steps.${archive.stepId}.outputs.${archive.archiveOutputName} }}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (job.kind === "provider") {
|
||||||
|
const supervisor = job.steps.find((step) => step.kind === "run-provider");
|
||||||
|
if (!supervisor || supervisor.kind !== "run-provider") {
|
||||||
|
throw new TypeError("provider job lacks supervisor step");
|
||||||
|
}
|
||||||
|
lines.push(
|
||||||
|
" outputs:",
|
||||||
|
` invocation_nonce: \${{ steps.${supervisor.stepId}.outputs.invocation_nonce }}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
if (job.environment.length > 0) {
|
if (job.environment.length > 0) {
|
||||||
lines.push(" env:");
|
lines.push(" env:");
|
||||||
for (const binding of job.environment) {
|
for (const binding of job.environment) {
|
||||||
@@ -229,6 +239,7 @@ function renderStep(
|
|||||||
case "run-provider": {
|
case "run-provider": {
|
||||||
return [
|
return [
|
||||||
` - name: Run and validate external ${step.provider} provider in one trusted supervisor`,
|
` - name: Run and validate external ${step.provider} provider in one trusted supervisor`,
|
||||||
|
` id: ${yamlKey(step.stepId)}`,
|
||||||
` run: node scripts/run-and-validate-provider.ts --kind ${step.provider}`,
|
` run: node scripts/run-and-validate-provider.ts --kind ${step.provider}`,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -240,8 +251,23 @@ function renderStep(
|
|||||||
case "verify-promotion":
|
case "verify-promotion":
|
||||||
return [
|
return [
|
||||||
" - name: Finalize verified promotion from inode-bound captured inputs",
|
" - name: Finalize verified promotion from inode-bound captured inputs",
|
||||||
|
` id: ${yamlKey(step.stepId)}`,
|
||||||
" run: node scripts/stage-verified-promotion.ts",
|
" run: node scripts/stage-verified-promotion.ts",
|
||||||
];
|
];
|
||||||
|
case "cleanup-promotion":
|
||||||
|
return [
|
||||||
|
" - name: Always remove private promotion staging",
|
||||||
|
" if: always()",
|
||||||
|
" env:",
|
||||||
|
` PROMOTION_STAGING_ROOT: \${{ steps.${step.finalizerStepId}.outputs.staging_root }}`,
|
||||||
|
` PROMOTION_CLEANUP_TOKEN: \${{ steps.${step.finalizerStepId}.outputs.cleanup_token }}`,
|
||||||
|
` PROMOTION_RUNNER_TEMP_DEV: \${{ steps.${step.finalizerStepId}.outputs.runner_temp_dev }}`,
|
||||||
|
` PROMOTION_RUNNER_TEMP_INO: \${{ steps.${step.finalizerStepId}.outputs.runner_temp_ino }}`,
|
||||||
|
" run: |",
|
||||||
|
' if [ -n "$PROMOTION_STAGING_ROOT" ] && [ -n "$PROMOTION_CLEANUP_TOKEN" ]; then',
|
||||||
|
" node scripts/cleanup-verified-promotion.ts",
|
||||||
|
" fi",
|
||||||
|
];
|
||||||
case "upload": {
|
case "upload": {
|
||||||
const lines = [
|
const lines = [
|
||||||
` - name: Upload ${humanize(step.transferId)}`,
|
` - name: Upload ${humanize(step.transferId)}`,
|
||||||
|
|||||||
@@ -36,6 +36,62 @@ const MAX_MEMBER_PATH_BYTES = 1_024;
|
|||||||
const TAR_EXECUTABLE = "/usr/bin/tar";
|
const TAR_EXECUTABLE = "/usr/bin/tar";
|
||||||
const TAR_ENVIRONMENT = Object.freeze({ PATH: "/usr/bin:/bin", LC_ALL: "C", LANG: "C" });
|
const TAR_ENVIRONMENT = Object.freeze({ PATH: "/usr/bin:/bin", LC_ALL: "C", LANG: "C" });
|
||||||
|
|
||||||
|
export type CapturedCandidateArchive = Readonly<{
|
||||||
|
bytes: Buffer;
|
||||||
|
archiveSha256: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export async function captureCiCandidateArchive(input: Readonly<{
|
||||||
|
archivePath: string;
|
||||||
|
expectedSha256: string;
|
||||||
|
}>): Promise<CapturedCandidateArchive> {
|
||||||
|
if (!/^[a-f0-9]{64}$/u.test(input.expectedSha256)) {
|
||||||
|
throw new TypeError("expected candidate archive SHA-256 is invalid");
|
||||||
|
}
|
||||||
|
const absolute = path.resolve(input.archivePath);
|
||||||
|
const before = await lstat(absolute);
|
||||||
|
if (!before.isFile() || before.isSymbolicLink()) {
|
||||||
|
throw new TypeError("candidate archive must be a regular non-symlink file");
|
||||||
|
}
|
||||||
|
if (before.size <= 0 || before.size > MAX_ARCHIVE_BYTES) {
|
||||||
|
throw new RangeError(`candidate archive size is outside 1..${MAX_ARCHIVE_BYTES}`);
|
||||||
|
}
|
||||||
|
const handle = await open(absolute, constants.O_RDONLY | constants.O_NOFOLLOW);
|
||||||
|
let bytes: Buffer;
|
||||||
|
try {
|
||||||
|
assertSameIdentity(before, await handle.stat());
|
||||||
|
bytes = await readCapturedArchive(handle, before.size);
|
||||||
|
assertSameIdentity(before, await handle.stat());
|
||||||
|
} finally {
|
||||||
|
await handle.close();
|
||||||
|
}
|
||||||
|
const archiveSha256 = createHash("sha256").update(bytes).digest("hex");
|
||||||
|
if (archiveSha256 !== input.expectedSha256) {
|
||||||
|
throw new Error("candidate archive SHA-256 mismatch");
|
||||||
|
}
|
||||||
|
return Object.freeze({ bytes, archiveSha256 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function withVerifiedCapturedCandidate<T>(input: Readonly<{
|
||||||
|
captured: CapturedCandidateArchive;
|
||||||
|
verify: (view: Readonly<{
|
||||||
|
extractionRoot: string;
|
||||||
|
manifest: ReleaseCandidateManifest;
|
||||||
|
}>) => Promise<T>;
|
||||||
|
}>): Promise<T> {
|
||||||
|
let result: T | undefined;
|
||||||
|
await verifyCapturedCiCandidateArchive(
|
||||||
|
input.captured.bytes,
|
||||||
|
input.captured.archiveSha256,
|
||||||
|
{
|
||||||
|
verifyExtracted: async (extractionRoot, manifest) => {
|
||||||
|
result = await input.verify({ extractionRoot, manifest });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return result as T;
|
||||||
|
}
|
||||||
|
|
||||||
export async function verifyCiCandidateArchive(
|
export async function verifyCiCandidateArchive(
|
||||||
input: Readonly<{
|
input: Readonly<{
|
||||||
archivePath: string;
|
archivePath: string;
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
dependencyDiffArtifactSchema,
|
dependencyDiffArtifactSchema,
|
||||||
dependencyInventoryArtifactSchema,
|
dependencyInventoryArtifactSchema,
|
||||||
licenseReportArtifactSchema,
|
licenseReportArtifactSchema,
|
||||||
|
localEvidenceAssessmentArtifactSchema,
|
||||||
provenanceArtifactSchema,
|
provenanceArtifactSchema,
|
||||||
releaseManifestArtifactSchema,
|
releaseManifestArtifactSchema,
|
||||||
releaseVerificationArtifactSchema,
|
releaseVerificationArtifactSchema,
|
||||||
@@ -28,8 +29,15 @@ import {
|
|||||||
verifyBuildManifestOutputs,
|
verifyBuildManifestOutputs,
|
||||||
} from "./build-manifest-outputs.ts";
|
} from "./build-manifest-outputs.ts";
|
||||||
import { assertMatchesJsonSchema } from "./json-schema.ts";
|
import { assertMatchesJsonSchema } from "./json-schema.ts";
|
||||||
import type { ReleaseCandidateManifest } from "./release-candidate.ts";
|
import {
|
||||||
import { collectDistOutputs, distSha256 } from "./release-candidate.ts";
|
LOCAL_EVIDENCE_ASSESSMENT_PATH,
|
||||||
|
RELEASE_CANDIDATE_EVIDENCE_PATHS,
|
||||||
|
RELEASE_CANDIDATE_MANIFEST_PATH,
|
||||||
|
collectDistOutputs,
|
||||||
|
distSha256,
|
||||||
|
releaseCandidateManifestSchema,
|
||||||
|
type ReleaseCandidateManifest,
|
||||||
|
} from "./release-candidate.ts";
|
||||||
import { verifyReleaseRuntimeCoherence } from "./release-runtime-coherence.ts";
|
import { verifyReleaseRuntimeCoherence } from "./release-runtime-coherence.ts";
|
||||||
import { digestReleaseInputFiles } from "./release-input-evidence.ts";
|
import { digestReleaseInputFiles } from "./release-input-evidence.ts";
|
||||||
import {
|
import {
|
||||||
@@ -290,7 +298,503 @@ export async function verifyLocalSupplyChainEvidence(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const LOCAL_EVIDENCE_VERIFIER_ID =
|
||||||
|
"clean-architecture-frontend-template/local-evidence-verifier";
|
||||||
|
export const LOCAL_EVIDENCE_VERIFIER_VERSION = "1";
|
||||||
|
export const LOCAL_EVIDENCE_VERIFIER_SOURCE_PATHS = Object.freeze([
|
||||||
|
"scripts/contracts/release-artifacts.ts",
|
||||||
|
"scripts/create-release-candidate.ts",
|
||||||
|
"scripts/generate-supply-chain.ts",
|
||||||
|
"scripts/lib/build-manifest-outputs.ts",
|
||||||
|
"scripts/lib/json-schema.ts",
|
||||||
|
"scripts/lib/local-policy-evidence.ts",
|
||||||
|
"scripts/lib/local-release-evidence.ts",
|
||||||
|
"scripts/lib/release-candidate.ts",
|
||||||
|
"scripts/lib/release-input-evidence.ts",
|
||||||
|
"scripts/lib/release-runtime-coherence.ts",
|
||||||
|
"scripts/lib/repository-file-inventory.ts",
|
||||||
|
"scripts/lib/secret-scan-evaluator.ts",
|
||||||
|
"scripts/lib/secret-scan-policy.ts",
|
||||||
|
"scripts/lib/supply-chain.ts",
|
||||||
|
"scripts/lib/validated-json-artifact.ts",
|
||||||
|
"src/contracts/release-artifacts.ts",
|
||||||
|
] as const);
|
||||||
|
export const LOCAL_EVIDENCE_POLICY_INPUT_PATHS = Object.freeze([
|
||||||
|
"config/security/dependency-baseline.approval.json",
|
||||||
|
"config/security/dependency-baseline.json",
|
||||||
|
"config/security/dependency-change-evidence.json",
|
||||||
|
"config/security/dependency-policy.json",
|
||||||
|
"config/security/secret-scan-policy.json",
|
||||||
|
"config/security/vulnerability-exceptions.json",
|
||||||
|
"config/security/vulnerability-policy.json",
|
||||||
|
"schemas/artifacts/build-manifest.schema.json",
|
||||||
|
"schemas/artifacts/dependency-inventory.schema.json",
|
||||||
|
"schemas/artifacts/supply-chain-verification.schema.json",
|
||||||
|
...LOCAL_EVIDENCE_VERIFIER_SOURCE_PATHS,
|
||||||
|
] as const);
|
||||||
|
|
||||||
|
export async function createLocalEvidenceAssessment(
|
||||||
|
repositoryRoot = process.cwd(),
|
||||||
|
): Promise<z.infer<typeof localEvidenceAssessmentArtifactSchema>> {
|
||||||
|
const root = path.resolve(repositoryRoot);
|
||||||
|
const outputs = await collectDistOutputs(root);
|
||||||
|
const evidencePaths = RELEASE_CANDIDATE_EVIDENCE_PATHS.filter(
|
||||||
|
(memberPath) => memberPath !== LOCAL_EVIDENCE_ASSESSMENT_PATH,
|
||||||
|
);
|
||||||
|
const evidenceInputs = (
|
||||||
|
await Promise.all([
|
||||||
|
...outputs.map(async ({ path: memberPath }) => digestInput(root, memberPath)),
|
||||||
|
...evidencePaths.map((memberPath) => digestInput(root, memberPath)),
|
||||||
|
])
|
||||||
|
).sort((left, right) => asciiCompare(left.path, right.path));
|
||||||
|
const lockfile = evidenceInputs.find(({ path: memberPath }) => memberPath === "pnpm-lock.yaml");
|
||||||
|
const sbom = evidenceInputs.find(
|
||||||
|
({ path: memberPath }) => memberPath === "artifacts/release/sbom.cdx.json",
|
||||||
|
);
|
||||||
|
if (!lockfile || !sbom) throw new Error("local assessment candidate inputs are incomplete");
|
||||||
|
const candidate: ReleaseCandidateManifest = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
distSha256: distSha256(outputs),
|
||||||
|
lockfileSha256: lockfile.sha256,
|
||||||
|
bundleSha256: supplyChainDigest(evidenceInputs),
|
||||||
|
files: evidenceInputs,
|
||||||
|
};
|
||||||
|
const evaluated = await evaluateProducerLocalChecks(root, candidate);
|
||||||
|
const [build, release, provenance, supply, sbomDocument, policyInputs] = await Promise.all([
|
||||||
|
readJson(root, "artifacts/release/build-manifest.json").then((value) =>
|
||||||
|
buildManifestArtifactSchema.parse(value),
|
||||||
|
),
|
||||||
|
readJson(root, "dist/release-manifest.json").then((value) =>
|
||||||
|
releaseManifestArtifactSchema.parse(value),
|
||||||
|
),
|
||||||
|
readJson(root, "artifacts/release/provenance.json").then((value) =>
|
||||||
|
provenanceArtifactSchema.parse(value),
|
||||||
|
),
|
||||||
|
readJson(root, "artifacts/security/supply-chain-verification.json").then((value) =>
|
||||||
|
supplyChainVerificationArtifactSchema.parse(value),
|
||||||
|
),
|
||||||
|
readJson(root, "artifacts/release/sbom.cdx.json").then((value) =>
|
||||||
|
sbomArtifactSchema.parse(value),
|
||||||
|
),
|
||||||
|
Promise.all(
|
||||||
|
LOCAL_EVIDENCE_POLICY_INPUT_PATHS.map((policyPath) =>
|
||||||
|
digestInput(root, policyPath),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
const identityFailures: string[] = [];
|
||||||
|
if (build.commitSha !== release.commitSha) {
|
||||||
|
identityFailures.push("producer build/release source revision mismatch");
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
provenance.predicate.materials.sourceSetSha256 !== supply.sourceSetSha256
|
||||||
|
) {
|
||||||
|
identityFailures.push("producer provenance/supply source-set mismatch");
|
||||||
|
}
|
||||||
|
if (supply.distSha256 !== candidate.distSha256) {
|
||||||
|
identityFailures.push("producer supply/candidate dist digest mismatch");
|
||||||
|
}
|
||||||
|
if (supply.lockfileSha256 !== candidate.lockfileSha256) {
|
||||||
|
identityFailures.push("producer supply/candidate lockfile digest mismatch");
|
||||||
|
}
|
||||||
|
if (supply.sbomSha256 !== supplyChainDigest(sbomDocument)) {
|
||||||
|
identityFailures.push("producer supply/candidate SBOM digest mismatch");
|
||||||
|
}
|
||||||
|
const checks = {
|
||||||
|
...evaluated.checks,
|
||||||
|
...(identityFailures.some((failure) => failure.includes("build/release"))
|
||||||
|
? { release: "FAIL" as const }
|
||||||
|
: {}),
|
||||||
|
...(identityFailures.some((failure) => !failure.includes("build/release"))
|
||||||
|
? { supplyChain: "FAIL" as const }
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
const failures = [...evaluated.failures, ...identityFailures];
|
||||||
|
const status = failures.length === 0 && Object.values(checks).every(
|
||||||
|
(check) => check === "PASS",
|
||||||
|
)
|
||||||
|
? ("PASS" as const)
|
||||||
|
: ("FAIL" as const);
|
||||||
|
const verifierSources = policyInputs.filter(({ path: policyPath }) =>
|
||||||
|
(LOCAL_EVIDENCE_VERIFIER_SOURCE_PATHS as readonly string[]).includes(policyPath),
|
||||||
|
);
|
||||||
|
if (verifierSources.length !== LOCAL_EVIDENCE_VERIFIER_SOURCE_PATHS.length) {
|
||||||
|
throw new Error("local assessment verifier source set is incomplete");
|
||||||
|
}
|
||||||
|
return localEvidenceAssessmentArtifactSchema.parse({
|
||||||
|
schemaVersion: 1,
|
||||||
|
artifactType: "local-evidence-assessment",
|
||||||
|
generatedAt: build.generatedAt,
|
||||||
|
status,
|
||||||
|
verifier: {
|
||||||
|
id: LOCAL_EVIDENCE_VERIFIER_ID,
|
||||||
|
version: LOCAL_EVIDENCE_VERIFIER_VERSION,
|
||||||
|
sourceSha256: supplyChainDigest(verifierSources),
|
||||||
|
},
|
||||||
|
source: {
|
||||||
|
revision: build.commitSha,
|
||||||
|
sourceSetSha256: supply.sourceSetSha256,
|
||||||
|
},
|
||||||
|
candidate: {
|
||||||
|
distSha256: candidate.distSha256,
|
||||||
|
lockfileSha256: candidate.lockfileSha256,
|
||||||
|
sbomSha256: sbom.sha256,
|
||||||
|
},
|
||||||
|
policyInputs,
|
||||||
|
evidenceInputs,
|
||||||
|
checks,
|
||||||
|
failures,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
type LocalCheckName =
|
||||||
|
| "release"
|
||||||
|
| "supplyChain"
|
||||||
|
| "dependencyPolicy"
|
||||||
|
| "licensePolicy"
|
||||||
|
| "vulnerabilityPolicy"
|
||||||
|
| "secretScan";
|
||||||
|
|
||||||
|
async function evaluateProducerLocalChecks(
|
||||||
|
root: string,
|
||||||
|
candidate: ReleaseCandidateManifest,
|
||||||
|
): Promise<Readonly<{
|
||||||
|
checks: Readonly<Record<LocalCheckName, "PASS" | "FAIL">>;
|
||||||
|
failures: readonly string[];
|
||||||
|
}>> {
|
||||||
|
const checks: Record<LocalCheckName, "PASS" | "FAIL"> = {
|
||||||
|
release: "PASS",
|
||||||
|
supplyChain: "PASS",
|
||||||
|
dependencyPolicy: "PASS",
|
||||||
|
licensePolicy: "PASS",
|
||||||
|
vulnerabilityPolicy: "PASS",
|
||||||
|
secretScan: "PASS",
|
||||||
|
};
|
||||||
|
const failures: string[] = [];
|
||||||
|
const evaluate = async (
|
||||||
|
check: LocalCheckName,
|
||||||
|
operation: () => Promise<readonly string[]>,
|
||||||
|
): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const diagnostics = await operation();
|
||||||
|
if (diagnostics.length > 0) {
|
||||||
|
checks[check] = "FAIL";
|
||||||
|
failures.push(...diagnostics.map((failure) => `${check}:${failure}`));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
checks[check] = "FAIL";
|
||||||
|
failures.push(
|
||||||
|
`${check}:${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await evaluate("release", async () => {
|
||||||
|
const [build, release, stored] = await Promise.all([
|
||||||
|
readJson(root, "artifacts/release/build-manifest.json").then((value) =>
|
||||||
|
buildManifestArtifactSchema.parse(value),
|
||||||
|
),
|
||||||
|
readJson(root, "dist/release-manifest.json").then((value) =>
|
||||||
|
releaseManifestArtifactSchema.parse(value),
|
||||||
|
),
|
||||||
|
readJson(root, "artifacts/release/verification.json").then((value) =>
|
||||||
|
releaseVerificationArtifactSchema.parse(value),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
const diagnostics: string[] = [];
|
||||||
|
if (
|
||||||
|
build.commitSha !== release.commitSha ||
|
||||||
|
build.buildId !== release.buildId ||
|
||||||
|
build.releaseId !== release.releaseId ||
|
||||||
|
build.generatedAt !== release.builtAt
|
||||||
|
) {
|
||||||
|
diagnostics.push("build/release identity mismatch");
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!stored.passed ||
|
||||||
|
!stored.artifact.checked ||
|
||||||
|
!stored.artifact.compatible ||
|
||||||
|
stored.artifact.mismatches.length > 0 ||
|
||||||
|
stored.artifact.releaseId !== release.releaseId ||
|
||||||
|
stored.generatedAt !== release.builtAt ||
|
||||||
|
stored.fixtures.length === 0 ||
|
||||||
|
stored.fixtures.some((fixture) => !fixture.passed)
|
||||||
|
) {
|
||||||
|
diagnostics.push("stored release verification is not a coherent PASS");
|
||||||
|
}
|
||||||
|
return diagnostics;
|
||||||
|
});
|
||||||
|
await evaluate("supplyChain", async () => {
|
||||||
|
const [supply, coherence] = await Promise.all([
|
||||||
|
readJson(root, "artifacts/security/supply-chain-verification.json").then((value) =>
|
||||||
|
supplyChainVerificationArtifactSchema.parse(value),
|
||||||
|
),
|
||||||
|
readJson(root, "artifacts/security/supply-chain-coherence.json").then((value) =>
|
||||||
|
supplyChainCoherenceReportSchema.parse(value),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
const diagnostics: string[] = [];
|
||||||
|
if (
|
||||||
|
supply.localStatus !== "PASS" ||
|
||||||
|
supply.failures.length > 0 ||
|
||||||
|
supply.distSha256 !== candidate.distSha256 ||
|
||||||
|
supply.lockfileSha256 !== candidate.lockfileSha256 ||
|
||||||
|
coherence.status !== "PASS" ||
|
||||||
|
coherence.failures.length > 0 ||
|
||||||
|
coherence.distSha256 !== candidate.distSha256 ||
|
||||||
|
coherence.lockfileSha256 !== candidate.lockfileSha256
|
||||||
|
) {
|
||||||
|
diagnostics.push("stored supply-chain evidence is not a coherent PASS");
|
||||||
|
}
|
||||||
|
return diagnostics;
|
||||||
|
});
|
||||||
|
await evaluate("dependencyPolicy", async () => {
|
||||||
|
const [inventory, stored] = await Promise.all([
|
||||||
|
readJson(root, "artifacts/release/dependency-inventory.json").then((value) =>
|
||||||
|
dependencyInventoryArtifactSchema.parse(value),
|
||||||
|
),
|
||||||
|
readJson(root, "artifacts/security/dependency-diff.json").then((value) =>
|
||||||
|
dependencyDiffArtifactSchema.parse(value),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
const recomputed = recomputeDependencyEvidence({
|
||||||
|
inventory,
|
||||||
|
baseline: await optionalReadJson(root, "config/security/dependency-baseline.json"),
|
||||||
|
baselineApproval: await optionalReadJson(
|
||||||
|
root,
|
||||||
|
"config/security/dependency-baseline.approval.json",
|
||||||
|
),
|
||||||
|
dependencyChangeEvidence: await readJson(
|
||||||
|
root,
|
||||||
|
"config/security/dependency-change-evidence.json",
|
||||||
|
),
|
||||||
|
});
|
||||||
|
return compareStoredDependencyEvidence(recomputed, stored);
|
||||||
|
});
|
||||||
|
await evaluate("licensePolicy", async () => {
|
||||||
|
const [inventory, stored, policy] = await Promise.all([
|
||||||
|
readJson(root, "artifacts/release/dependency-inventory.json").then((value) =>
|
||||||
|
dependencyInventoryArtifactSchema.parse(value),
|
||||||
|
),
|
||||||
|
readJson(root, "artifacts/security/license-report.json").then((value) =>
|
||||||
|
licenseReportArtifactSchema.parse(value),
|
||||||
|
),
|
||||||
|
readJson(root, "config/security/dependency-policy.json"),
|
||||||
|
]);
|
||||||
|
return compareStoredLicenseEvidence(
|
||||||
|
recomputeLicenseEvidence({ inventory, policy }),
|
||||||
|
stored,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await evaluate("vulnerabilityPolicy", async () => {
|
||||||
|
const vulnerability = vulnerabilityReportArtifactSchema.parse(
|
||||||
|
await readJson(root, "artifacts/security/vulnerability-report.json"),
|
||||||
|
);
|
||||||
|
return compareStoredLocalVulnerabilityReport(
|
||||||
|
candidate.lockfileSha256,
|
||||||
|
vulnerability,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await evaluate("secretScan", async () => {
|
||||||
|
const evaluation = await evaluateRepositorySecretScan({ repositoryRoot: root });
|
||||||
|
return verifyStoredSecretScan(
|
||||||
|
evaluation,
|
||||||
|
await readJson(root, "artifacts/security/scan.sarif"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return Object.freeze({ checks: Object.freeze(checks), failures: Object.freeze(failures) });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function digestInput(
|
||||||
|
repositoryRoot: string,
|
||||||
|
memberPath: string,
|
||||||
|
): Promise<Readonly<{ path: string; bytes: number; sha256: string }>> {
|
||||||
|
const absolute = path.resolve(repositoryRoot, memberPath);
|
||||||
|
const relative = path.relative(repositoryRoot, absolute);
|
||||||
|
if (
|
||||||
|
relative === "" ||
|
||||||
|
relative === ".." ||
|
||||||
|
relative.startsWith(`..${path.sep}`) ||
|
||||||
|
path.isAbsolute(relative)
|
||||||
|
) {
|
||||||
|
throw new TypeError(`local assessment input escapes repository: ${memberPath}`);
|
||||||
|
}
|
||||||
|
const bytes = await readFile(absolute);
|
||||||
|
return Object.freeze({
|
||||||
|
path: memberPath,
|
||||||
|
bytes: bytes.byteLength,
|
||||||
|
sha256: createHash("sha256").update(bytes).digest("hex"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function asciiCompare(left: string, right: string): number {
|
||||||
|
return left < right ? -1 : left > right ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
export async function verifyArchivedLocalEvidence(input: Readonly<{
|
export async function verifyArchivedLocalEvidence(input: Readonly<{
|
||||||
|
extractionRoot: string;
|
||||||
|
expectedManifest: ReleaseCandidateManifest;
|
||||||
|
}>): Promise<Readonly<{
|
||||||
|
status: "PASS" | "FAIL";
|
||||||
|
identity: null | Readonly<{
|
||||||
|
sourceRevision: string;
|
||||||
|
sourceSetSha256: string;
|
||||||
|
assessmentSha256: string;
|
||||||
|
}>;
|
||||||
|
failures: readonly string[];
|
||||||
|
}>> {
|
||||||
|
const extractionRoot = path.resolve(input.extractionRoot);
|
||||||
|
const failures: string[] = [];
|
||||||
|
let extractedManifest: ReleaseCandidateManifest | null = null;
|
||||||
|
try {
|
||||||
|
extractedManifest = releaseCandidateManifestSchema.parse(
|
||||||
|
await readJson(extractionRoot, RELEASE_CANDIDATE_MANIFEST_PATH),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
failures.push("extracted release candidate manifest is missing or invalid");
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
extractedManifest &&
|
||||||
|
JSON.stringify(extractedManifest) !== JSON.stringify(input.expectedManifest)
|
||||||
|
) {
|
||||||
|
failures.push("caller expectedManifest differs from extracted manifest");
|
||||||
|
}
|
||||||
|
|
||||||
|
let assessment: z.infer<typeof localEvidenceAssessmentArtifactSchema> | null = null;
|
||||||
|
let assessmentSha256 = "";
|
||||||
|
let assessmentBytes: Buffer | null = null;
|
||||||
|
try {
|
||||||
|
assessmentBytes = await readFile(
|
||||||
|
path.join(extractionRoot, LOCAL_EVIDENCE_ASSESSMENT_PATH),
|
||||||
|
);
|
||||||
|
assessmentSha256 = createHash("sha256").update(assessmentBytes).digest("hex");
|
||||||
|
assessment = localEvidenceAssessmentArtifactSchema.parse(
|
||||||
|
JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(assessmentBytes)) as unknown,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
failures.push("local evidence assessment is missing or invalid");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (assessment && extractedManifest) {
|
||||||
|
const assessmentMember = extractedManifest.files.find(
|
||||||
|
({ path: memberPath }) => memberPath === LOCAL_EVIDENCE_ASSESSMENT_PATH,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
!assessmentMember ||
|
||||||
|
assessmentMember.bytes !== assessmentBytes?.byteLength ||
|
||||||
|
assessmentMember.sha256 !== assessmentSha256
|
||||||
|
) {
|
||||||
|
failures.push("local assessment manifest binding mismatch");
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
assessment.verifier.id !== LOCAL_EVIDENCE_VERIFIER_ID ||
|
||||||
|
assessment.verifier.version !== LOCAL_EVIDENCE_VERIFIER_VERSION
|
||||||
|
) {
|
||||||
|
failures.push("local assessment verifier identity mismatch");
|
||||||
|
}
|
||||||
|
const policyPaths = assessment.policyInputs.map(({ path: policyPath }) => policyPath);
|
||||||
|
if (JSON.stringify(policyPaths) !== JSON.stringify(LOCAL_EVIDENCE_POLICY_INPUT_PATHS)) {
|
||||||
|
failures.push("local assessment policyInputs exact set mismatch");
|
||||||
|
}
|
||||||
|
const verifierSources = assessment.policyInputs.filter(({ path: policyPath }) =>
|
||||||
|
(LOCAL_EVIDENCE_VERIFIER_SOURCE_PATHS as readonly string[]).includes(policyPath),
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
verifierSources.length !== LOCAL_EVIDENCE_VERIFIER_SOURCE_PATHS.length ||
|
||||||
|
supplyChainDigest(verifierSources) !== assessment.verifier.sourceSha256
|
||||||
|
) {
|
||||||
|
failures.push("local assessment verifier-source digest mismatch");
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedEvidenceInputs = extractedManifest.files.filter(
|
||||||
|
({ path: memberPath }) => memberPath !== LOCAL_EVIDENCE_ASSESSMENT_PATH,
|
||||||
|
);
|
||||||
|
if (JSON.stringify(assessment.evidenceInputs) !== JSON.stringify(expectedEvidenceInputs)) {
|
||||||
|
failures.push("local assessment evidenceInputs exact member binding mismatch");
|
||||||
|
}
|
||||||
|
const sbom = extractedManifest.files.find(
|
||||||
|
({ path: memberPath }) => memberPath === "artifacts/release/sbom.cdx.json",
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
assessment.candidate.distSha256 !== extractedManifest.distSha256 ||
|
||||||
|
assessment.candidate.lockfileSha256 !== extractedManifest.lockfileSha256 ||
|
||||||
|
!sbom ||
|
||||||
|
assessment.candidate.sbomSha256 !== sbom.sha256
|
||||||
|
) {
|
||||||
|
failures.push("local assessment candidate digest binding mismatch");
|
||||||
|
}
|
||||||
|
if (assessment.status !== "PASS" || Object.values(assessment.checks).includes("FAIL")) {
|
||||||
|
failures.push("local evidence assessment is not PASS");
|
||||||
|
}
|
||||||
|
|
||||||
|
const identities = await readArchivedIdentities(extractionRoot, failures);
|
||||||
|
if (
|
||||||
|
identities.buildRevision !== assessment.source.revision ||
|
||||||
|
identities.releaseRevision !== assessment.source.revision
|
||||||
|
) {
|
||||||
|
failures.push("local assessment source revision identity mismatch");
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
identities.provenanceSourceSetSha256 !== assessment.source.sourceSetSha256 ||
|
||||||
|
identities.supplySourceSetSha256 !== assessment.source.sourceSetSha256
|
||||||
|
) {
|
||||||
|
failures.push("local assessment source-set identity mismatch");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const uniqueFailures = Object.freeze([...new Set(failures)]);
|
||||||
|
const passingAssessment = uniqueFailures.length === 0 ? assessment : null;
|
||||||
|
return Object.freeze({
|
||||||
|
status: passingAssessment ? "PASS" : "FAIL",
|
||||||
|
identity: passingAssessment
|
||||||
|
? Object.freeze({
|
||||||
|
sourceRevision: passingAssessment.source.revision,
|
||||||
|
sourceSetSha256: passingAssessment.source.sourceSetSha256,
|
||||||
|
assessmentSha256,
|
||||||
|
})
|
||||||
|
: null,
|
||||||
|
failures: uniqueFailures,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readArchivedIdentities(
|
||||||
|
extractionRoot: string,
|
||||||
|
failures: string[],
|
||||||
|
): Promise<Readonly<{
|
||||||
|
buildRevision: unknown;
|
||||||
|
releaseRevision: unknown;
|
||||||
|
provenanceSourceSetSha256: unknown;
|
||||||
|
supplySourceSetSha256: unknown;
|
||||||
|
}>> {
|
||||||
|
try {
|
||||||
|
const [buildDocument, releaseDocument, provenanceDocument, supplyDocument] = await Promise.all([
|
||||||
|
readJson(extractionRoot, "artifacts/release/build-manifest.json"),
|
||||||
|
readJson(extractionRoot, "dist/release-manifest.json"),
|
||||||
|
readJson(extractionRoot, "artifacts/release/provenance.json"),
|
||||||
|
readJson(extractionRoot, "artifacts/security/supply-chain-verification.json"),
|
||||||
|
]);
|
||||||
|
const build = buildManifestArtifactSchema.parse(buildDocument);
|
||||||
|
const release = releaseManifestArtifactSchema.parse(releaseDocument);
|
||||||
|
const provenance = provenanceArtifactSchema.parse(provenanceDocument);
|
||||||
|
const supply = supplyChainVerificationArtifactSchema.parse(supplyDocument);
|
||||||
|
return Object.freeze({
|
||||||
|
buildRevision: build.commitSha,
|
||||||
|
releaseRevision: release.commitSha,
|
||||||
|
provenanceSourceSetSha256: provenance.predicate.materials.sourceSetSha256,
|
||||||
|
supplySourceSetSha256: supply.sourceSetSha256,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
failures.push("archived source/build/provenance identities are missing or invalid");
|
||||||
|
return Object.freeze({
|
||||||
|
buildRevision: null,
|
||||||
|
releaseRevision: null,
|
||||||
|
provenanceSourceSetSha256: null,
|
||||||
|
supplySourceSetSha256: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function assessLocalEvidenceForProducer(input: Readonly<{
|
||||||
repositoryRoot?: string;
|
repositoryRoot?: string;
|
||||||
candidate: ReleaseCandidateManifest;
|
candidate: ReleaseCandidateManifest;
|
||||||
}>): Promise<Readonly<{
|
}>): Promise<Readonly<{
|
||||||
|
|||||||
+360
-219
@@ -1,40 +1,54 @@
|
|||||||
import { createHash, createPublicKey, randomUUID } from "node:crypto";
|
import {
|
||||||
|
createHash,
|
||||||
|
createPublicKey,
|
||||||
|
randomBytes as cryptoRandomBytes,
|
||||||
|
} from "node:crypto";
|
||||||
import { constants } from "node:fs";
|
import { constants } from "node:fs";
|
||||||
import { lstat, mkdtemp, open, rename, rm } from "node:fs/promises";
|
import {
|
||||||
|
lstat,
|
||||||
|
mkdir,
|
||||||
|
open,
|
||||||
|
rm,
|
||||||
|
stat,
|
||||||
|
} from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
evaluatePromotionEvidence,
|
PROMOTED_FILE_NAMES,
|
||||||
providerVerificationArtifactSchema,
|
type PromotedFileName,
|
||||||
provenanceProviderAttestationSchema,
|
} from "../contracts/promotion-artifacts.ts";
|
||||||
vulnerabilityProviderReportSchema,
|
|
||||||
} from "./provider-evidence.ts";
|
|
||||||
import { verifyCapturedCiCandidateArchive } from "./ci-candidate-archive.ts";
|
|
||||||
import { verifyArchivedLocalEvidence } from "./local-release-evidence.ts";
|
|
||||||
import { verifyReleaseCandidate } from "./release-candidate.ts";
|
|
||||||
import { readBoundedRegularFile } from "./ci-artifact-validator.ts";
|
|
||||||
import {
|
import {
|
||||||
assertSafePublishLeaf,
|
evaluatePromotionEvidence,
|
||||||
ensureSafePublishDirectory,
|
providerPublicKeyFingerprint,
|
||||||
} from "./ci-gate-log.ts";
|
providerVerificationArtifactSchema,
|
||||||
import { PROMOTED_STAGING_PATHS } from "../contracts/promotion-artifacts.ts";
|
PROMOTION_VERIFIER_ID,
|
||||||
|
PROMOTION_VERIFIER_VERSION,
|
||||||
export { PROMOTED_STAGING_PATHS };
|
provenanceProviderAttestationSchema,
|
||||||
|
trustPolicySha256,
|
||||||
type PromotionSource = Readonly<{
|
vulnerabilityProviderReportSchema,
|
||||||
sourcePath: string;
|
type ProviderTrust,
|
||||||
destinationName: string;
|
} from "./provider-evidence.ts";
|
||||||
maxBytes: number;
|
import {
|
||||||
validate: (bytes: Buffer) => void;
|
captureCiCandidateArchive,
|
||||||
}>;
|
withVerifiedCapturedCandidate,
|
||||||
|
} from "./ci-candidate-archive.ts";
|
||||||
|
import { verifyArchivedLocalEvidence } from "./local-release-evidence.ts";
|
||||||
|
import { readBoundedRegularFile } from "./ci-artifact-validator.ts";
|
||||||
|
|
||||||
type StagedFile = Readonly<{
|
type StagedFile = Readonly<{
|
||||||
destinationName: string;
|
name: PromotedFileName;
|
||||||
bytes: Buffer;
|
bytes: Buffer;
|
||||||
digest: string;
|
sha256: string;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export async function stageVerifiedPromotion(input: Readonly<{
|
export type FinalizedPromotion = Readonly<{
|
||||||
|
stagingRoot: string;
|
||||||
|
cleanupToken: string;
|
||||||
|
runnerTempIdentity: Readonly<{ dev: number; ino: number }>;
|
||||||
|
files: readonly Readonly<{ name: PromotedFileName; sha256: string }>[];
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export async function finalizeVerifiedPromotion(input: Readonly<{
|
||||||
repositoryRoot: string;
|
repositoryRoot: string;
|
||||||
archivePath: string;
|
archivePath: string;
|
||||||
expectedArchiveSha256: string;
|
expectedArchiveSha256: string;
|
||||||
@@ -44,203 +58,330 @@ export async function stageVerifiedPromotion(input: Readonly<{
|
|||||||
vulnerabilityKeyId: string;
|
vulnerabilityKeyId: string;
|
||||||
provenancePublicKeyPath: string;
|
provenancePublicKeyPath: string;
|
||||||
provenanceKeyId: string;
|
provenanceKeyId: string;
|
||||||
|
expectedRun: Readonly<{ id: string; attempt: number; sourceRevision: string }>;
|
||||||
|
vulnerabilityInvocationNonce: string;
|
||||||
|
provenanceInvocationNonce: string;
|
||||||
|
runnerTempRoot: string;
|
||||||
}>, dependencies: Readonly<{
|
}>, dependencies: Readonly<{
|
||||||
verifyLocalEvidence?: typeof verifyArchivedLocalEvidence;
|
captureArchive?: typeof captureCiCandidateArchive;
|
||||||
|
nowEpochMs?: () => number;
|
||||||
|
randomBytes?: (bytes: number) => Buffer;
|
||||||
afterCapture?: () => Promise<void>;
|
afterCapture?: () => Promise<void>;
|
||||||
beforePublishRename?: () => Promise<void>;
|
beforePublish?: () => Promise<void>;
|
||||||
}> = {}): Promise<ReadonlyArray<Readonly<{ path: string; sha256: string }>>> {
|
afterStagingWrite?: () => Promise<void>;
|
||||||
|
}> = {}): Promise<FinalizedPromotion> {
|
||||||
const root = path.resolve(input.repositoryRoot);
|
const root = path.resolve(input.repositoryRoot);
|
||||||
if (!/^[a-f0-9]{64}$/u.test(input.expectedArchiveSha256)) {
|
const capturedArchive = await (dependencies.captureArchive ?? captureCiCandidateArchive)({
|
||||||
throw new TypeError("promotion archive SHA-256 is invalid");
|
archivePath: input.archivePath,
|
||||||
}
|
expectedSha256: input.expectedArchiveSha256,
|
||||||
const sources: PromotionSource[] = [
|
});
|
||||||
{
|
const [vulnerabilityBytes, provenanceBytes, vulnerabilityKeyBytes, provenanceKeyBytes] =
|
||||||
sourcePath: input.archivePath,
|
await Promise.all([
|
||||||
destinationName: "release-candidate.tar.gz",
|
capture(root, input.vulnerabilityReportPath, 16_777_216),
|
||||||
maxBytes: 268_435_456,
|
capture(root, input.provenanceAttestationPath, 16_777_216),
|
||||||
validate: (bytes) => {
|
capture(root, input.vulnerabilityPublicKeyPath, 1_048_576),
|
||||||
if (sha256(bytes) !== input.expectedArchiveSha256) {
|
capture(root, input.provenancePublicKeyPath, 1_048_576),
|
||||||
throw new Error("promotion archive SHA-256 changed before staging");
|
]);
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
sourcePath: input.vulnerabilityReportPath,
|
|
||||||
destinationName: "vulnerability-report.json",
|
|
||||||
maxBytes: 16_777_216,
|
|
||||||
validate: (bytes) => vulnerabilityProviderReportSchema.parse(parseJson(bytes)),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
sourcePath: input.provenanceAttestationPath,
|
|
||||||
destinationName: "provenance-attestation.json",
|
|
||||||
maxBytes: 16_777_216,
|
|
||||||
validate: (bytes) => provenanceProviderAttestationSchema.parse(parseJson(bytes)),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
sourcePath: "artifacts/security/provider-verification.json",
|
|
||||||
destinationName: "provider-verification.json",
|
|
||||||
maxBytes: 4_194_304,
|
|
||||||
validate: (bytes) => providerVerificationArtifactSchema.parse(parseJson(bytes)),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
sourcePath: "artifacts/security/promotion-verification.json",
|
|
||||||
destinationName: "promotion-verification.json",
|
|
||||||
maxBytes: 4_194_304,
|
|
||||||
validate: (bytes) => providerVerificationArtifactSchema.parse(parseJson(bytes)),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
const [captured, vulnerabilityPublicKey, provenancePublicKey] = await Promise.all([
|
|
||||||
Promise.all(
|
|
||||||
sources.map(async (source) => {
|
|
||||||
const relativePath = repositoryRelative(root, source.sourcePath);
|
|
||||||
const bytes = await readBoundedRegularFile({
|
|
||||||
root,
|
|
||||||
relativePath,
|
|
||||||
maxBytes: source.maxBytes,
|
|
||||||
});
|
|
||||||
source.validate(bytes);
|
|
||||||
return Object.freeze({ ...source, bytes, digest: sha256(bytes) });
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
capture(root, input.vulnerabilityPublicKeyPath, 1_048_576),
|
|
||||||
capture(root, input.provenancePublicKeyPath, 1_048_576),
|
|
||||||
]);
|
|
||||||
await dependencies.afterCapture?.();
|
await dependencies.afterCapture?.();
|
||||||
let capturedLocalStatus: "PASS" | "FAIL" = "FAIL";
|
|
||||||
const archive = await verifyCapturedCiCandidateArchive(
|
const vulnerabilityTrust = capturedTrust(
|
||||||
captured[0]!.bytes,
|
input.vulnerabilityKeyId,
|
||||||
input.expectedArchiveSha256,
|
vulnerabilityKeyBytes,
|
||||||
{
|
|
||||||
verifyExtracted: async (extractionRoot, manifest) => {
|
|
||||||
const candidate = await verifyReleaseCandidate(manifest, extractionRoot);
|
|
||||||
if (candidate.failures.length > 0) {
|
|
||||||
throw new Error(`captured candidate failed final verification: ${candidate.failures.join(", ")}`);
|
|
||||||
}
|
|
||||||
const local = await (dependencies.verifyLocalEvidence ?? verifyArchivedLocalEvidence)({
|
|
||||||
repositoryRoot: extractionRoot,
|
|
||||||
candidate: manifest,
|
|
||||||
});
|
|
||||||
if (local.status !== "PASS" || local.failures.length > 0) {
|
|
||||||
throw new Error(`captured local evidence failed final verification: ${local.failures.join(", ")}`);
|
|
||||||
}
|
|
||||||
capturedLocalStatus = local.status;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
const vulnerability = vulnerabilityProviderReportSchema.parse(parseJson(captured[1]!.bytes));
|
const provenanceTrust = capturedTrust(
|
||||||
const provenance = provenanceProviderAttestationSchema.parse(parseJson(captured[2]!.bytes));
|
input.provenanceKeyId,
|
||||||
const reevaluated = evaluatePromotionEvidence({
|
provenanceKeyBytes,
|
||||||
candidate: archive.manifest,
|
);
|
||||||
currentDistSha256: archive.manifest.distSha256,
|
const vulnerabilityReport = vulnerabilityProviderReportSchema.parse(
|
||||||
localStatus: capturedLocalStatus,
|
parseJson(vulnerabilityBytes),
|
||||||
vulnerabilityReport: vulnerability,
|
);
|
||||||
provenanceAttestation: provenance,
|
const provenanceAttestation = provenanceProviderAttestationSchema.parse(
|
||||||
vulnerabilityTrust: {
|
parseJson(provenanceBytes),
|
||||||
keyId: input.vulnerabilityKeyId,
|
);
|
||||||
publicKey: createPublicKey(
|
const now = (dependencies.nowEpochMs ?? Date.now)();
|
||||||
new TextDecoder("utf-8", { fatal: true }).decode(vulnerabilityPublicKey),
|
const verifiedAt = new Date(now).toISOString();
|
||||||
),
|
|
||||||
},
|
const generated = await withVerifiedCapturedCandidate({
|
||||||
provenanceTrust: {
|
captured: capturedArchive,
|
||||||
keyId: input.provenanceKeyId,
|
verify: async ({ extractionRoot, manifest }) => {
|
||||||
publicKey: createPublicKey(
|
const local = await verifyArchivedLocalEvidence({
|
||||||
new TextDecoder("utf-8", { fatal: true }).decode(provenancePublicKey),
|
extractionRoot,
|
||||||
),
|
expectedManifest: manifest,
|
||||||
|
});
|
||||||
|
if (local.status !== "PASS" || !local.identity) {
|
||||||
|
throw new Error(
|
||||||
|
`captured local evidence failed final verification: ${local.failures.join(", ")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (local.identity.sourceRevision !== input.expectedRun.sourceRevision) {
|
||||||
|
throw new Error("captured source revision differs from expected promotion revision");
|
||||||
|
}
|
||||||
|
const expected = {
|
||||||
|
run: { id: input.expectedRun.id, attempt: input.expectedRun.attempt },
|
||||||
|
source: {
|
||||||
|
revision: local.identity.sourceRevision,
|
||||||
|
sourceSetSha256: local.identity.sourceSetSha256,
|
||||||
|
},
|
||||||
|
candidate: {
|
||||||
|
archiveSha256: capturedArchive.archiveSha256,
|
||||||
|
bundleSha256: manifest.bundleSha256,
|
||||||
|
distSha256: manifest.distSha256,
|
||||||
|
lockfileSha256: manifest.lockfileSha256,
|
||||||
|
},
|
||||||
|
vulnerabilityInvocationNonce: input.vulnerabilityInvocationNonce,
|
||||||
|
provenanceInvocationNonce: input.provenanceInvocationNonce,
|
||||||
|
} as const;
|
||||||
|
const reevaluated = evaluatePromotionEvidence({
|
||||||
|
expected,
|
||||||
|
localStatus: local.status,
|
||||||
|
vulnerabilityReport,
|
||||||
|
provenanceAttestation,
|
||||||
|
vulnerabilityTrust,
|
||||||
|
provenanceTrust,
|
||||||
|
nowEpochMs: () => now,
|
||||||
|
});
|
||||||
|
if (reevaluated.status !== "PASS") {
|
||||||
|
throw new Error(
|
||||||
|
`captured provider evidence failed trusted revalidation: ${reevaluated.failures.join(", ")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const providerEvidence = {
|
||||||
|
vulnerabilityReportSha256: sha256(vulnerabilityBytes),
|
||||||
|
provenanceAttestationSha256: sha256(provenanceBytes),
|
||||||
|
vulnerabilityInvocationNonce: input.vulnerabilityInvocationNonce,
|
||||||
|
provenanceInvocationNonce: input.provenanceInvocationNonce,
|
||||||
|
vulnerabilityKeyId: vulnerabilityTrust.keyId,
|
||||||
|
vulnerabilityKeyFingerprint: vulnerabilityTrust.publicKeyFingerprint,
|
||||||
|
provenanceKeyId: provenanceTrust.keyId,
|
||||||
|
provenanceKeyFingerprint: provenanceTrust.publicKeyFingerprint,
|
||||||
|
} as const;
|
||||||
|
const trustDigest = trustPolicySha256({ vulnerabilityTrust, provenanceTrust });
|
||||||
|
const common = {
|
||||||
|
schemaVersion: 3 as const,
|
||||||
|
verifiedAt,
|
||||||
|
status: "PASS" as const,
|
||||||
|
verifier: {
|
||||||
|
id: PROMOTION_VERIFIER_ID,
|
||||||
|
version: PROMOTION_VERIFIER_VERSION,
|
||||||
|
},
|
||||||
|
run: expected.run,
|
||||||
|
source: expected.source,
|
||||||
|
candidate: expected.candidate,
|
||||||
|
providerEvidence,
|
||||||
|
trustPolicySha256: trustDigest,
|
||||||
|
failures: [] as const,
|
||||||
|
};
|
||||||
|
const providerRecord = providerVerificationArtifactSchema.parse({
|
||||||
|
...common,
|
||||||
|
artifactType: "provider-verification",
|
||||||
|
vulnerabilityStatus: reevaluated.vulnerabilityStatus,
|
||||||
|
provenanceAttestationStatus: reevaluated.provenanceAttestationStatus,
|
||||||
|
});
|
||||||
|
const providerRecordBytes = canonicalJsonBytes(providerRecord);
|
||||||
|
const promotionRecord = providerVerificationArtifactSchema.parse({
|
||||||
|
...common,
|
||||||
|
artifactType: "promotion-verification",
|
||||||
|
localEvidenceStatus: local.status,
|
||||||
|
localEvidenceAssessmentSha256: local.identity.assessmentSha256,
|
||||||
|
providerVerificationSha256: sha256(providerRecordBytes),
|
||||||
|
});
|
||||||
|
return Object.freeze({
|
||||||
|
providerRecordBytes,
|
||||||
|
promotionRecordBytes: canonicalJsonBytes(promotionRecord),
|
||||||
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (reevaluated.status !== "PASS" || reevaluated.failures.length > 0) {
|
|
||||||
throw new Error(`captured provider evidence failed trusted revalidation: ${reevaluated.failures.join(", ")}`);
|
|
||||||
}
|
|
||||||
const expectedBindings = {
|
|
||||||
candidateArchiveSha256: captured[0]!.digest,
|
|
||||||
vulnerabilityReportSha256: captured[1]!.digest,
|
|
||||||
provenanceAttestationSha256: captured[2]!.digest,
|
|
||||||
};
|
|
||||||
for (const [index, expectedArtifactType] of [
|
|
||||||
[3, "provider-verification"],
|
|
||||||
[4, "promotion-verification"],
|
|
||||||
] as const) {
|
|
||||||
const verification = providerVerificationArtifactSchema.parse(parseJson(captured[index]!.bytes));
|
|
||||||
if (verification.artifactType !== expectedArtifactType) {
|
|
||||||
throw new Error(
|
|
||||||
`${captured[index]!.destinationName} artifactType role mismatch: expected ${expectedArtifactType}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
verification.status !== reevaluated.status ||
|
|
||||||
verification.vulnerabilityStatus !== reevaluated.vulnerabilityStatus ||
|
|
||||||
verification.provenanceAttestationStatus !== reevaluated.provenanceAttestationStatus ||
|
|
||||||
verification.failures.length > 0
|
|
||||||
) {
|
|
||||||
throw new Error(`${captured[index]!.destinationName} status disagrees with trusted revalidation`);
|
|
||||||
}
|
|
||||||
if (verification.lockfileSha256 !== archive.manifest.lockfileSha256) {
|
|
||||||
throw new Error(`${captured[index]!.destinationName} lockfileSha256 digest mismatch`);
|
|
||||||
}
|
|
||||||
if (verification.distSha256 !== archive.manifest.distSha256) {
|
|
||||||
throw new Error(`${captured[index]!.destinationName} distSha256 digest mismatch`);
|
|
||||||
}
|
|
||||||
for (const [binding, expectedDigest] of Object.entries(expectedBindings) as ReadonlyArray<
|
|
||||||
readonly [keyof typeof expectedBindings, string]
|
|
||||||
>) {
|
|
||||||
if (verification[binding] !== expectedDigest) {
|
|
||||||
throw new Error(`${captured[index]!.destinationName} ${binding} digest mismatch`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const stagedFiles: readonly StagedFile[] = captured;
|
|
||||||
|
|
||||||
const releaseRoot = path.join(root, ".release");
|
const stagedFiles: readonly StagedFile[] = Object.freeze([
|
||||||
const releaseIdentity = await ensureSafePublishDirectory(root, releaseRoot);
|
staged("release-candidate.tar.gz", capturedArchive.bytes),
|
||||||
const stagingRoot = path.join(releaseRoot, "promoted-staging");
|
staged("vulnerability-report.json", vulnerabilityBytes),
|
||||||
await assertSafePublishLeaf(stagingRoot, ".release/promoted-staging");
|
staged("provenance-attestation.json", provenanceBytes),
|
||||||
if (await exists(stagingRoot)) throw new Error("promotion staging target already exists");
|
staged("provider-verification.json", generated.providerRecordBytes),
|
||||||
const temporary = await mkdtemp(path.join(root, `.promoted-staging.${randomUUID()}.`));
|
staged("promotion-verification.json", generated.promotionRecordBytes),
|
||||||
let ownsTemporary = true;
|
]);
|
||||||
|
if (
|
||||||
|
JSON.stringify(stagedFiles.map(({ name }) => name)) !==
|
||||||
|
JSON.stringify(PROMOTED_FILE_NAMES)
|
||||||
|
) {
|
||||||
|
throw new Error("promotion exact-five canonical file order drift");
|
||||||
|
}
|
||||||
|
await dependencies.beforePublish?.();
|
||||||
|
return publishPrivateStaging(
|
||||||
|
input.runnerTempRoot,
|
||||||
|
input.expectedRun,
|
||||||
|
stagedFiles,
|
||||||
|
dependencies.randomBytes ?? cryptoRandomBytes,
|
||||||
|
dependencies.afterStagingWrite,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const stageVerifiedPromotion = finalizeVerifiedPromotion;
|
||||||
|
|
||||||
|
export async function cleanupFinalizedPromotion(input: Readonly<{
|
||||||
|
runnerTempRoot: string;
|
||||||
|
stagingRoot: string;
|
||||||
|
cleanupToken: string;
|
||||||
|
runnerTempIdentity: Readonly<{ dev: number; ino: number }>;
|
||||||
|
}>, dependencies: Readonly<{
|
||||||
|
beforeRemove?: () => Promise<void>;
|
||||||
|
}> = {}): Promise<void> {
|
||||||
|
const parent = path.resolve(input.runnerTempRoot);
|
||||||
|
const expected = path.join(parent, input.cleanupToken);
|
||||||
|
if (
|
||||||
|
!/^[A-Za-z0-9._-]+-[a-f0-9]{32}$/u.test(input.cleanupToken) ||
|
||||||
|
path.resolve(input.stagingRoot) !== expected ||
|
||||||
|
!Number.isSafeInteger(input.runnerTempIdentity.dev) ||
|
||||||
|
input.runnerTempIdentity.dev <= 0 ||
|
||||||
|
!Number.isSafeInteger(input.runnerTempIdentity.ino) ||
|
||||||
|
input.runnerTempIdentity.ino <= 0
|
||||||
|
) {
|
||||||
|
throw new TypeError("promotion cleanup root/token mismatch");
|
||||||
|
}
|
||||||
|
const parentHandle = await open(
|
||||||
|
parent,
|
||||||
|
constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW,
|
||||||
|
);
|
||||||
try {
|
try {
|
||||||
for (const source of stagedFiles) {
|
const openedParent = await parentHandle.stat();
|
||||||
|
assertRunnerTempIdentity(openedParent, input.runnerTempIdentity);
|
||||||
|
const descriptorRoot = `/proc/self/fd/${parentHandle.fd}`;
|
||||||
|
const descriptorMetadata = await stat(descriptorRoot);
|
||||||
|
if (!descriptorMetadata.isDirectory()) {
|
||||||
|
throw new Error("descriptor-relative cleanup is unavailable");
|
||||||
|
}
|
||||||
|
const descriptorExpected = path.join(descriptorRoot, input.cleanupToken);
|
||||||
|
let metadata;
|
||||||
|
try {
|
||||||
|
metadata = await lstat(descriptorExpected);
|
||||||
|
} catch (error) {
|
||||||
|
if (hasErrorCode(error, "ENOENT")) return;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
if (metadata.isSymbolicLink() || !metadata.isDirectory()) {
|
||||||
|
throw new TypeError("promotion cleanup leaf is unsafe");
|
||||||
|
}
|
||||||
|
await dependencies.beforeRemove?.();
|
||||||
|
const visibleParent = await lstat(parent);
|
||||||
|
assertRunnerTempIdentity(visibleParent, input.runnerTempIdentity);
|
||||||
|
await rm(descriptorExpected, { recursive: true, force: true });
|
||||||
|
const afterParent = await lstat(parent);
|
||||||
|
assertRunnerTempIdentity(afterParent, input.runnerTempIdentity);
|
||||||
|
} finally {
|
||||||
|
await parentHandle.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function publishPrivateStaging(
|
||||||
|
runnerTempRoot: string,
|
||||||
|
run: Readonly<{ id: string; attempt: number }>,
|
||||||
|
files: readonly StagedFile[],
|
||||||
|
randomBytes: (bytes: number) => Buffer,
|
||||||
|
afterStagingWrite?: () => Promise<void>,
|
||||||
|
): Promise<FinalizedPromotion> {
|
||||||
|
const parentPath = path.resolve(runnerTempRoot);
|
||||||
|
const before = await lstat(parentPath);
|
||||||
|
if (!before.isDirectory() || before.isSymbolicLink()) {
|
||||||
|
throw new TypeError("runner temporary root must be a real directory");
|
||||||
|
}
|
||||||
|
const parentHandle = await open(
|
||||||
|
parentPath,
|
||||||
|
constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW,
|
||||||
|
);
|
||||||
|
const tokenBytes = randomBytes(16);
|
||||||
|
if (tokenBytes.byteLength !== 16) {
|
||||||
|
await parentHandle.close();
|
||||||
|
throw new TypeError("promotion staging nonce must contain exactly 128 random bits");
|
||||||
|
}
|
||||||
|
const safeRun = run.id.replaceAll(/[^A-Za-z0-9._-]/gu, "_").slice(0, 64) || "run";
|
||||||
|
const cleanupToken = `promotion-${safeRun}-${run.attempt}-${tokenBytes.toString("hex")}`;
|
||||||
|
const descriptorRoot = `/proc/self/fd/${parentHandle.fd}`;
|
||||||
|
const descriptorStaging = path.join(descriptorRoot, cleanupToken);
|
||||||
|
const visibleStaging = path.join(parentPath, cleanupToken);
|
||||||
|
let ownsStaging = false;
|
||||||
|
try {
|
||||||
|
const procMetadata = await stat(descriptorRoot);
|
||||||
|
if (!procMetadata.isDirectory()) throw new Error("descriptor-relative staging is unavailable");
|
||||||
|
await mkdir(descriptorStaging, { mode: 0o700 });
|
||||||
|
ownsStaging = true;
|
||||||
|
for (const file of files) {
|
||||||
const handle = await open(
|
const handle = await open(
|
||||||
path.join(temporary, source.destinationName),
|
path.join(descriptorStaging, file.name),
|
||||||
constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW,
|
constants.O_WRONLY |
|
||||||
0o600,
|
constants.O_CREAT |
|
||||||
|
constants.O_EXCL |
|
||||||
|
constants.O_NOFOLLOW,
|
||||||
|
0o400,
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
await handle.writeFile(source.bytes);
|
await handle.writeFile(file.bytes);
|
||||||
await handle.sync();
|
await handle.sync();
|
||||||
} finally {
|
} finally {
|
||||||
await handle.close();
|
await handle.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await syncDirectory(temporary);
|
await syncDirectory(descriptorStaging);
|
||||||
await dependencies.beforePublishRename?.();
|
await syncHandle(parentHandle);
|
||||||
const currentReleaseIdentity = await ensureSafePublishDirectory(root, releaseRoot);
|
await afterStagingWrite?.();
|
||||||
|
const after = await lstat(parentPath);
|
||||||
if (
|
if (
|
||||||
releaseIdentity.dev <= 0 ||
|
after.dev !== before.dev ||
|
||||||
releaseIdentity.ino <= 0 ||
|
after.ino !== before.ino ||
|
||||||
currentReleaseIdentity.dev !== releaseIdentity.dev ||
|
after.isSymbolicLink() ||
|
||||||
currentReleaseIdentity.ino !== releaseIdentity.ino
|
!after.isDirectory()
|
||||||
) {
|
) {
|
||||||
throw new Error("promotion staging parent identity changed");
|
throw new Error("runner temporary parent identity changed during staging");
|
||||||
}
|
}
|
||||||
await assertSafePublishLeaf(stagingRoot, ".release/promoted-staging");
|
const visible = await lstat(visibleStaging);
|
||||||
if (await exists(stagingRoot)) throw new Error("promotion staging target already exists");
|
if (!visible.isDirectory() || visible.isSymbolicLink()) {
|
||||||
await rename(temporary, stagingRoot);
|
throw new Error("promotion staging visibility identity mismatch");
|
||||||
ownsTemporary = false;
|
}
|
||||||
await syncDirectory(releaseRoot);
|
ownsStaging = false;
|
||||||
|
return Object.freeze({
|
||||||
|
stagingRoot: visibleStaging,
|
||||||
|
cleanupToken,
|
||||||
|
runnerTempIdentity: Object.freeze({ dev: before.dev, ino: before.ino }),
|
||||||
|
files: Object.freeze(
|
||||||
|
files.map(({ name, sha256: digest }) => Object.freeze({ name, sha256: digest })),
|
||||||
|
),
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
if (ownsTemporary) await rm(temporary, { recursive: true, force: true });
|
if (ownsStaging) {
|
||||||
|
await rm(descriptorStaging, { recursive: true, force: true }).catch(() => undefined);
|
||||||
|
}
|
||||||
|
await parentHandle.close();
|
||||||
}
|
}
|
||||||
return Object.freeze(
|
}
|
||||||
stagedFiles.map(({ destinationName, digest }) =>
|
|
||||||
Object.freeze({ path: `.release/promoted-staging/${destinationName}`, sha256: digest }),
|
function assertRunnerTempIdentity(
|
||||||
),
|
metadata: Readonly<{ dev: number; ino: number; isDirectory: () => boolean; isSymbolicLink?: () => boolean }>,
|
||||||
|
expected: Readonly<{ dev: number; ino: number }>,
|
||||||
|
): void {
|
||||||
|
if (
|
||||||
|
metadata.dev !== expected.dev ||
|
||||||
|
metadata.ino !== expected.ino ||
|
||||||
|
!metadata.isDirectory() ||
|
||||||
|
metadata.isSymbolicLink?.()
|
||||||
|
) {
|
||||||
|
throw new Error("runner temporary parent identity changed during cleanup");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function capturedTrust(keyId: string, bytes: Buffer): ProviderTrust {
|
||||||
|
const publicKey = createPublicKey(
|
||||||
|
new TextDecoder("utf-8", { fatal: true }).decode(bytes),
|
||||||
);
|
);
|
||||||
|
return Object.freeze({
|
||||||
|
keyId,
|
||||||
|
publicKey,
|
||||||
|
publicKeyFingerprint: providerPublicKeyFingerprint(publicKey),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function capture(root: string, configuredPath: string, maxBytes: number): Promise<Buffer> {
|
async function capture(root: string, configuredPath: string, maxBytes: number): Promise<Buffer> {
|
||||||
const absolute = path.resolve(root, configuredPath);
|
const absolute = path.resolve(root, configuredPath);
|
||||||
const relative = path.relative(root, absolute);
|
const relative = path.relative(root, absolute);
|
||||||
const outside = relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative);
|
const outside =
|
||||||
|
relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative);
|
||||||
return readBoundedRegularFile({
|
return readBoundedRegularFile({
|
||||||
root: outside ? path.dirname(absolute) : root,
|
root: outside ? path.dirname(absolute) : root,
|
||||||
relativePath: outside ? path.basename(absolute) : relative.replaceAll(path.sep, "/"),
|
relativePath: outside ? path.basename(absolute) : relative.replaceAll(path.sep, "/"),
|
||||||
@@ -248,43 +389,43 @@ async function capture(root: string, configuredPath: string, maxBytes: number):
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseJson(bytes: Buffer): unknown {
|
function staged(name: PromotedFileName, bytes: Buffer): StagedFile {
|
||||||
return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)) as unknown;
|
return Object.freeze({ name, bytes, sha256: sha256(bytes) });
|
||||||
}
|
}
|
||||||
|
|
||||||
function repositoryRelative(root: string, configuredPath: string): string {
|
function canonicalJsonBytes(value: unknown): Buffer {
|
||||||
const absolute = path.resolve(root, configuredPath);
|
return Buffer.from(`${JSON.stringify(value, null, 2)}\n`, "utf8");
|
||||||
const relative = path.relative(root, absolute);
|
}
|
||||||
if (relative === "" || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
|
||||||
throw new TypeError(`promotion source escapes repository: ${configuredPath}`);
|
function parseJson(bytes: Buffer): unknown {
|
||||||
|
try {
|
||||||
|
return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)) as unknown;
|
||||||
|
} catch {
|
||||||
|
throw new TypeError("captured provider evidence is not valid UTF-8 JSON");
|
||||||
}
|
}
|
||||||
return relative.replaceAll(path.sep, "/");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function sha256(bytes: Buffer): string {
|
function sha256(bytes: Buffer): string {
|
||||||
return createHash("sha256").update(bytes).digest("hex");
|
return createHash("sha256").update(bytes).digest("hex");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function exists(target: string): Promise<boolean> {
|
async function syncDirectory(directory: string): Promise<void> {
|
||||||
|
const handle = await open(
|
||||||
|
directory,
|
||||||
|
constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW,
|
||||||
|
);
|
||||||
try {
|
try {
|
||||||
await lstat(target);
|
await syncHandle(handle);
|
||||||
return true;
|
} finally {
|
||||||
} catch (error) {
|
await handle.close();
|
||||||
if (hasErrorCode(error, "ENOENT")) return false;
|
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function syncDirectory(directory: string): Promise<void> {
|
async function syncHandle(handle: Awaited<ReturnType<typeof open>>): Promise<void> {
|
||||||
const handle = await open(directory, constants.O_RDONLY);
|
|
||||||
try {
|
try {
|
||||||
try {
|
await handle.sync();
|
||||||
await handle.sync();
|
} catch (error) {
|
||||||
} catch (error) {
|
if (!hasErrorCode(error, "EINVAL") && !hasErrorCode(error, "ENOTSUP")) throw error;
|
||||||
if (!hasErrorCode(error, "EINVAL") && !hasErrorCode(error, "ENOTSUP")) throw error;
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
await handle.close();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ import { createHash, createPublicKey } from "node:crypto";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
PROMOTION_VERIFIER_ID,
|
||||||
|
PROMOTION_VERIFIER_VERSION,
|
||||||
evaluatePromotionEvidence,
|
evaluatePromotionEvidence,
|
||||||
|
providerPublicKeyFingerprint,
|
||||||
|
trustPolicySha256,
|
||||||
type ProviderVerificationArtifactType,
|
type ProviderVerificationArtifactType,
|
||||||
type ProviderTrust,
|
type ProviderTrust,
|
||||||
} from "./provider-evidence.ts";
|
} from "./provider-evidence.ts";
|
||||||
@@ -13,6 +17,7 @@ import {
|
|||||||
} from "./release-candidate.ts";
|
} from "./release-candidate.ts";
|
||||||
import { verifyArchivedLocalEvidence } from "./local-release-evidence.ts";
|
import { verifyArchivedLocalEvidence } from "./local-release-evidence.ts";
|
||||||
import { readBoundedRegularFile } from "./ci-artifact-validator.ts";
|
import { readBoundedRegularFile } from "./ci-artifact-validator.ts";
|
||||||
|
import { supplyChainDigest } from "./supply-chain.ts";
|
||||||
|
|
||||||
type LocalEvidenceVerifier = typeof verifyArchivedLocalEvidence;
|
type LocalEvidenceVerifier = typeof verifyArchivedLocalEvidence;
|
||||||
|
|
||||||
@@ -23,6 +28,7 @@ export type VerifyPromotionInputsOptions = Readonly<{
|
|||||||
providerEvidenceRoot?: string;
|
providerEvidenceRoot?: string;
|
||||||
trustRoot?: string;
|
trustRoot?: string;
|
||||||
verifyLocalEvidence?: LocalEvidenceVerifier;
|
verifyLocalEvidence?: LocalEvidenceVerifier;
|
||||||
|
nowEpochMs?: () => number;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export async function verifyPromotionInputs(
|
export async function verifyPromotionInputs(
|
||||||
@@ -75,25 +81,73 @@ export async function verifyPromotionInputs(
|
|||||||
);
|
);
|
||||||
const localEvidence = await (
|
const localEvidence = await (
|
||||||
options.verifyLocalEvidence ?? verifyArchivedLocalEvidence
|
options.verifyLocalEvidence ?? verifyArchivedLocalEvidence
|
||||||
)({ repositoryRoot, candidate: manifest });
|
)({ extractionRoot: repositoryRoot, expectedManifest: manifest });
|
||||||
const vulnerabilityReport = parseCapturedJson(vulnerabilityCapture.bytes);
|
const vulnerabilityReport = parseCapturedJson(vulnerabilityCapture.bytes);
|
||||||
const provenanceAttestation = parseCapturedJson(provenanceCapture.bytes);
|
const provenanceAttestation = parseCapturedJson(provenanceCapture.bytes);
|
||||||
|
const vulnerabilityTrust = await readProviderTrust(
|
||||||
|
trustRoot,
|
||||||
|
environment.VULNERABILITY_PUBLIC_KEY_PATH,
|
||||||
|
environment.VULNERABILITY_KEY_ID,
|
||||||
|
);
|
||||||
|
const provenanceTrust = await readProviderTrust(
|
||||||
|
trustRoot,
|
||||||
|
environment.PROVENANCE_PUBLIC_KEY_PATH,
|
||||||
|
environment.PROVENANCE_KEY_ID,
|
||||||
|
);
|
||||||
|
const runId = environment.CI_RUN_ID ?? "missing-run";
|
||||||
|
const runAttempt = Number(environment.CI_RUN_ATTEMPT);
|
||||||
|
if (!environment.CI_RUN_ID) inputFailures.push("provider expected run ID is missing");
|
||||||
|
if (!Number.isInteger(runAttempt) || runAttempt < 1 || runAttempt > 1_000) {
|
||||||
|
inputFailures.push("provider expected run attempt is missing or invalid");
|
||||||
|
}
|
||||||
|
if (!localEvidence.identity) {
|
||||||
|
inputFailures.push("archived local evidence identity is unavailable");
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
environment.EXPECTED_SOURCE_REVISION &&
|
||||||
|
localEvidence.identity &&
|
||||||
|
environment.EXPECTED_SOURCE_REVISION !== localEvidence.identity.sourceRevision
|
||||||
|
) {
|
||||||
|
inputFailures.push(
|
||||||
|
`provider expected source revision mismatch: expected ${environment.EXPECTED_SOURCE_REVISION}, archived ${localEvidence.identity.sourceRevision}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const vulnerabilityInvocationNonce = requiredExpectedNonce(
|
||||||
|
environment.VULNERABILITY_INVOCATION_NONCE,
|
||||||
|
"vulnerability",
|
||||||
|
inputFailures,
|
||||||
|
);
|
||||||
|
const provenanceInvocationNonce = requiredExpectedNonce(
|
||||||
|
environment.PROVENANCE_INVOCATION_NONCE,
|
||||||
|
"provenance",
|
||||||
|
inputFailures,
|
||||||
|
);
|
||||||
|
const expected = {
|
||||||
|
run: { id: runId, attempt: Number.isInteger(runAttempt) ? runAttempt : 1 },
|
||||||
|
source: {
|
||||||
|
revision:
|
||||||
|
localEvidence.identity?.sourceRevision ??
|
||||||
|
environment.EXPECTED_SOURCE_REVISION ??
|
||||||
|
"0".repeat(40),
|
||||||
|
sourceSetSha256: localEvidence.identity?.sourceSetSha256 ?? "0".repeat(64),
|
||||||
|
},
|
||||||
|
candidate: {
|
||||||
|
archiveSha256: archive.sha256 ?? "0".repeat(64),
|
||||||
|
bundleSha256: manifest.bundleSha256,
|
||||||
|
distSha256: manifest.distSha256,
|
||||||
|
lockfileSha256: manifest.lockfileSha256,
|
||||||
|
},
|
||||||
|
vulnerabilityInvocationNonce,
|
||||||
|
provenanceInvocationNonce,
|
||||||
|
} as const;
|
||||||
const result = evaluatePromotionEvidence({
|
const result = evaluatePromotionEvidence({
|
||||||
candidate: manifest,
|
expected,
|
||||||
currentDistSha256: candidate.currentDistSha256 ?? "",
|
|
||||||
localStatus: localEvidence.status,
|
localStatus: localEvidence.status,
|
||||||
vulnerabilityReport,
|
vulnerabilityReport,
|
||||||
provenanceAttestation,
|
provenanceAttestation,
|
||||||
vulnerabilityTrust: await readProviderTrust(
|
vulnerabilityTrust,
|
||||||
trustRoot,
|
provenanceTrust,
|
||||||
environment.VULNERABILITY_PUBLIC_KEY_PATH,
|
nowEpochMs: options.nowEpochMs,
|
||||||
environment.VULNERABILITY_KEY_ID,
|
|
||||||
),
|
|
||||||
provenanceTrust: await readProviderTrust(
|
|
||||||
trustRoot,
|
|
||||||
environment.PROVENANCE_PUBLIC_KEY_PATH,
|
|
||||||
environment.PROVENANCE_KEY_ID,
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
const failures = [
|
const failures = [
|
||||||
...inputFailures,
|
...inputFailures,
|
||||||
@@ -101,21 +155,93 @@ export async function verifyPromotionInputs(
|
|||||||
...localEvidence.failures,
|
...localEvidence.failures,
|
||||||
...result.failures,
|
...result.failures,
|
||||||
];
|
];
|
||||||
return Object.freeze({
|
const now = (options.nowEpochMs ?? Date.now)();
|
||||||
schemaVersion: 2 as const,
|
const common = {
|
||||||
|
schemaVersion: 3 as const,
|
||||||
artifactType: options.artifactType,
|
artifactType: options.artifactType,
|
||||||
|
verifiedAt: new Date(now).toISOString(),
|
||||||
status:
|
status:
|
||||||
failures.length === 0 && result.status === "PASS"
|
failures.length === 0 && result.status === "PASS"
|
||||||
? ("PASS" as const)
|
? ("PASS" as const)
|
||||||
: ("FAIL_UNVERIFIED" as const),
|
: ("FAIL_UNVERIFIED" as const),
|
||||||
vulnerabilityStatus: result.vulnerabilityStatus,
|
verifier: Object.freeze({
|
||||||
provenanceAttestationStatus: result.provenanceAttestationStatus,
|
id: PROMOTION_VERIFIER_ID,
|
||||||
lockfileSha256: manifest.lockfileSha256,
|
version: PROMOTION_VERIFIER_VERSION,
|
||||||
distSha256: manifest.distSha256,
|
}),
|
||||||
candidateArchiveSha256: archive.sha256,
|
run: expected.run,
|
||||||
vulnerabilityReportSha256: vulnerabilityCapture.sha256,
|
source: expected.source,
|
||||||
provenanceAttestationSha256: provenanceCapture.sha256,
|
candidate: expected.candidate,
|
||||||
|
providerEvidence: Object.freeze({
|
||||||
|
vulnerabilityReportSha256: vulnerabilityCapture.sha256 ?? "0".repeat(64),
|
||||||
|
provenanceAttestationSha256: provenanceCapture.sha256 ?? "0".repeat(64),
|
||||||
|
vulnerabilityInvocationNonce: expected.vulnerabilityInvocationNonce,
|
||||||
|
provenanceInvocationNonce: expected.provenanceInvocationNonce,
|
||||||
|
vulnerabilityKeyId:
|
||||||
|
vulnerabilityTrust?.keyId ?? environment.VULNERABILITY_KEY_ID ?? "missing-key",
|
||||||
|
vulnerabilityKeyFingerprint:
|
||||||
|
vulnerabilityTrust?.publicKeyFingerprint ?? `sha256:${"0".repeat(64)}`,
|
||||||
|
provenanceKeyId:
|
||||||
|
provenanceTrust?.keyId ?? environment.PROVENANCE_KEY_ID ?? "missing-key",
|
||||||
|
provenanceKeyFingerprint:
|
||||||
|
provenanceTrust?.publicKeyFingerprint ?? `sha256:${"0".repeat(64)}`,
|
||||||
|
}),
|
||||||
|
trustPolicySha256: verificationTrustPolicySha256(
|
||||||
|
vulnerabilityTrust,
|
||||||
|
provenanceTrust,
|
||||||
|
environment,
|
||||||
|
),
|
||||||
failures: Object.freeze(failures),
|
failures: Object.freeze(failures),
|
||||||
|
};
|
||||||
|
return options.artifactType === "provider-verification"
|
||||||
|
? Object.freeze({
|
||||||
|
...common,
|
||||||
|
artifactType: "provider-verification" as const,
|
||||||
|
vulnerabilityStatus: result.vulnerabilityStatus,
|
||||||
|
provenanceAttestationStatus: result.provenanceAttestationStatus,
|
||||||
|
})
|
||||||
|
: Object.freeze({
|
||||||
|
...common,
|
||||||
|
artifactType: "promotion-verification" as const,
|
||||||
|
localEvidenceStatus: localEvidence.status,
|
||||||
|
localEvidenceAssessmentSha256:
|
||||||
|
localEvidence.identity?.assessmentSha256 ?? "0".repeat(64),
|
||||||
|
providerVerificationSha256:
|
||||||
|
environment.PROVIDER_VERIFICATION_SHA256 ?? "0".repeat(64),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function requiredExpectedNonce(
|
||||||
|
value: string | undefined,
|
||||||
|
label: "vulnerability" | "provenance",
|
||||||
|
failures: string[],
|
||||||
|
): string {
|
||||||
|
if (value && /^[a-f0-9]{64}$/u.test(value)) return value;
|
||||||
|
failures.push(`${label} expected invocation nonce is missing or invalid`);
|
||||||
|
return "0".repeat(64);
|
||||||
|
}
|
||||||
|
|
||||||
|
function verificationTrustPolicySha256(
|
||||||
|
vulnerabilityTrust: ProviderTrust | null,
|
||||||
|
provenanceTrust: ProviderTrust | null,
|
||||||
|
environment: NodeJS.ProcessEnv,
|
||||||
|
): string {
|
||||||
|
if (vulnerabilityTrust && provenanceTrust) {
|
||||||
|
return trustPolicySha256({ vulnerabilityTrust, provenanceTrust });
|
||||||
|
}
|
||||||
|
return supplyChainDigest({
|
||||||
|
algorithm: "Ed25519",
|
||||||
|
vulnerability: {
|
||||||
|
keyId: vulnerabilityTrust?.keyId ?? environment.VULNERABILITY_KEY_ID ?? "missing-key",
|
||||||
|
publicKeyFingerprint:
|
||||||
|
vulnerabilityTrust?.publicKeyFingerprint ?? `sha256:${"0".repeat(64)}`,
|
||||||
|
},
|
||||||
|
provenance: {
|
||||||
|
keyId: provenanceTrust?.keyId ?? environment.PROVENANCE_KEY_ID ?? "missing-key",
|
||||||
|
publicKeyFingerprint:
|
||||||
|
provenanceTrust?.publicKeyFingerprint ?? `sha256:${"0".repeat(64)}`,
|
||||||
|
},
|
||||||
|
issuedAtFutureSkewMs: 5 * 60 * 1_000,
|
||||||
|
maximumLifetimeMs: 2 * 60 * 60 * 1_000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,13 +252,15 @@ export async function readProviderTrust(
|
|||||||
): Promise<ProviderTrust | null> {
|
): Promise<ProviderTrust | null> {
|
||||||
if (!publicKeyPath || !keyId?.trim()) return null;
|
if (!publicKeyPath || !keyId?.trim()) return null;
|
||||||
try {
|
try {
|
||||||
|
const publicKey = createPublicKey(
|
||||||
|
new TextDecoder("utf-8", { fatal: true }).decode(
|
||||||
|
await boundedConfiguredFile(repositoryRoot, publicKeyPath, 1_048_576),
|
||||||
|
),
|
||||||
|
);
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
keyId,
|
keyId,
|
||||||
publicKey: createPublicKey(
|
publicKey,
|
||||||
new TextDecoder("utf-8", { fatal: true }).decode(
|
publicKeyFingerprint: providerPublicKeyFingerprint(publicKey),
|
||||||
await boundedConfiguredFile(repositoryRoot, publicKeyPath, 1_048_576),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
+317
-118
@@ -1,90 +1,145 @@
|
|||||||
import { verify, type KeyObject } from "node:crypto";
|
import { createHash, verify, type KeyObject } from "node:crypto";
|
||||||
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { canonicalizeSupplyChainValue } from "./supply-chain.ts";
|
import {
|
||||||
|
canonicalizeSupplyChainValue,
|
||||||
|
supplyChainDigest,
|
||||||
|
} from "./supply-chain.ts";
|
||||||
|
|
||||||
|
export const PROVIDER_FUTURE_SKEW_MS = 5 * 60 * 1_000;
|
||||||
|
export const PROVIDER_MAX_LIFETIME_MS = 2 * 60 * 60 * 1_000;
|
||||||
|
export const PROMOTION_VERIFIER_ID =
|
||||||
|
"clean-architecture-frontend-template/promotion-verifier";
|
||||||
|
export const PROMOTION_VERIFIER_VERSION = "3";
|
||||||
|
|
||||||
const sha256 = z.string().regex(/^[a-f0-9]{64}$/u);
|
const sha256 = z.string().regex(/^[a-f0-9]{64}$/u);
|
||||||
const nonEmptyString = z.string().trim().min(1);
|
const fingerprint = z.string().regex(/^sha256:[a-f0-9]{64}$/u);
|
||||||
|
const revision = z.string().regex(/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/u);
|
||||||
|
const nonce = z.string().regex(/^[a-f0-9]{64}$/u);
|
||||||
|
const nonEmptyString = z.string().min(1);
|
||||||
|
const timestamp = z
|
||||||
|
.string()
|
||||||
|
.regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u)
|
||||||
|
.refine((value) => new Date(value).toISOString() === value);
|
||||||
|
const runSchema = z
|
||||||
|
.object({ id: z.string().min(1).max(128), attempt: z.int().min(1).max(1_000) })
|
||||||
|
.strict();
|
||||||
|
const sourceSchema = z
|
||||||
|
.object({ revision, sourceSetSha256: sha256 })
|
||||||
|
.strict();
|
||||||
|
const candidateSchema = z
|
||||||
|
.object({
|
||||||
|
archiveSha256: sha256,
|
||||||
|
bundleSha256: sha256,
|
||||||
|
distSha256: sha256,
|
||||||
|
lockfileSha256: sha256,
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
const providerRunSchema = runSchema.extend({ invocationNonce: nonce }).strict();
|
||||||
const signatureSchema = z
|
const signatureSchema = z
|
||||||
.object({
|
.object({
|
||||||
algorithm: z.literal("Ed25519"),
|
algorithm: z.literal("Ed25519"),
|
||||||
keyId: nonEmptyString,
|
keyId: nonEmptyString,
|
||||||
value: z.string().regex(/^[A-Za-z0-9+/]+={0,2}$/u),
|
publicKeyFingerprint: fingerprint,
|
||||||
|
value: z.string().regex(/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u),
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
const providerCommon = {
|
||||||
|
schemaVersion: z.literal(2),
|
||||||
|
provider: nonEmptyString,
|
||||||
|
issuedAt: timestamp,
|
||||||
|
expiresAt: timestamp,
|
||||||
|
run: providerRunSchema,
|
||||||
|
source: sourceSchema,
|
||||||
|
candidate: candidateSchema,
|
||||||
|
signature: signatureSchema,
|
||||||
|
} as const;
|
||||||
|
|
||||||
export const vulnerabilityProviderReportSchema = z
|
export const vulnerabilityProviderReportSchema = z
|
||||||
.object({
|
.object({
|
||||||
schemaVersion: z.literal(1),
|
...providerCommon,
|
||||||
provider: nonEmptyString,
|
evidenceType: z.literal("vulnerability-report"),
|
||||||
generatedAt: z.iso.datetime(),
|
|
||||||
scannedLockfileSha256: sha256,
|
|
||||||
scannedDistSha256: sha256,
|
|
||||||
findings: z.array(z.record(z.string(), z.json())),
|
findings: z.array(z.record(z.string(), z.json())),
|
||||||
signature: signatureSchema,
|
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
export const provenanceProviderAttestationSchema = z
|
export const provenanceProviderAttestationSchema = z
|
||||||
.object({
|
.object({
|
||||||
schemaVersion: z.literal(1),
|
...providerCommon,
|
||||||
provider: nonEmptyString,
|
evidenceType: z.literal("provenance-attestation"),
|
||||||
signer: nonEmptyString,
|
signer: nonEmptyString,
|
||||||
generatedAt: z.iso.datetime(),
|
|
||||||
subject: z
|
subject: z
|
||||||
.object({
|
.object({ name: z.literal("dist"), digest: z.object({ sha256 }).strict() })
|
||||||
name: z.literal("dist"),
|
|
||||||
digest: z.object({ sha256 }).strict(),
|
|
||||||
})
|
|
||||||
.strict(),
|
.strict(),
|
||||||
signature: signatureSchema,
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
const verificationCommon = {
|
||||||
|
schemaVersion: z.literal(3),
|
||||||
|
verifiedAt: timestamp,
|
||||||
|
status: z.enum(["PASS", "FAIL_UNVERIFIED"]),
|
||||||
|
verifier: z
|
||||||
|
.object({ id: nonEmptyString, version: nonEmptyString })
|
||||||
|
.strict(),
|
||||||
|
run: runSchema,
|
||||||
|
source: sourceSchema,
|
||||||
|
candidate: candidateSchema,
|
||||||
|
providerEvidence: z
|
||||||
|
.object({
|
||||||
|
vulnerabilityReportSha256: sha256,
|
||||||
|
provenanceAttestationSha256: sha256,
|
||||||
|
vulnerabilityInvocationNonce: nonce,
|
||||||
|
provenanceInvocationNonce: nonce,
|
||||||
|
vulnerabilityKeyId: nonEmptyString,
|
||||||
|
vulnerabilityKeyFingerprint: fingerprint,
|
||||||
|
provenanceKeyId: nonEmptyString,
|
||||||
|
provenanceKeyFingerprint: fingerprint,
|
||||||
|
})
|
||||||
|
.strict(),
|
||||||
|
trustPolicySha256: sha256,
|
||||||
|
failures: z.array(z.string()),
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const providerVerificationV3Schema = z
|
||||||
|
.object({
|
||||||
|
...verificationCommon,
|
||||||
|
artifactType: z.literal("provider-verification"),
|
||||||
|
vulnerabilityStatus: z.enum(["PASS", "FAIL_UNVERIFIED"]),
|
||||||
|
provenanceAttestationStatus: z.enum(["PASS", "FAIL_UNVERIFIED"]),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
const promotionVerificationV3Schema = z
|
||||||
|
.object({
|
||||||
|
...verificationCommon,
|
||||||
|
artifactType: z.literal("promotion-verification"),
|
||||||
|
localEvidenceStatus: z.enum(["PASS", "FAIL"]),
|
||||||
|
localEvidenceAssessmentSha256: sha256,
|
||||||
|
providerVerificationSha256: sha256,
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
export const providerVerificationArtifactSchema = z
|
export const providerVerificationArtifactSchema = z
|
||||||
.object({
|
.discriminatedUnion("artifactType", [
|
||||||
schemaVersion: z.literal(2),
|
providerVerificationV3Schema,
|
||||||
artifactType: z.enum(["provider-verification", "promotion-verification"]),
|
promotionVerificationV3Schema,
|
||||||
status: z.enum(["PASS", "FAIL_UNVERIFIED"]),
|
])
|
||||||
vulnerabilityStatus: z.enum(["PASS", "FAIL_UNVERIFIED"]),
|
.superRefine((record, context) => {
|
||||||
provenanceAttestationStatus: z.enum(["PASS", "FAIL_UNVERIFIED"]),
|
const subordinatePass =
|
||||||
lockfileSha256: sha256,
|
record.artifactType === "provider-verification"
|
||||||
distSha256: sha256,
|
? record.vulnerabilityStatus === "PASS" &&
|
||||||
candidateArchiveSha256: sha256.nullable(),
|
record.provenanceAttestationStatus === "PASS"
|
||||||
vulnerabilityReportSha256: sha256.nullable(),
|
: record.localEvidenceStatus === "PASS";
|
||||||
provenanceAttestationSha256: sha256.nullable(),
|
const coherentPass = subordinatePass && record.failures.length === 0;
|
||||||
failures: z.array(z.string()),
|
if ((record.status === "PASS") !== coherentPass) {
|
||||||
})
|
|
||||||
.strict()
|
|
||||||
.superRefine((artifact, context) => {
|
|
||||||
const passing =
|
|
||||||
artifact.status === "PASS" &&
|
|
||||||
artifact.vulnerabilityStatus === "PASS" &&
|
|
||||||
artifact.provenanceAttestationStatus === "PASS" &&
|
|
||||||
artifact.failures.length === 0;
|
|
||||||
if ((artifact.status === "PASS") !== passing) {
|
|
||||||
context.addIssue({
|
context.addIssue({
|
||||||
code: "custom",
|
code: "custom",
|
||||||
path: ["status"],
|
path: ["status"],
|
||||||
message: "verification PASS must agree with provider statuses and failures",
|
message: "verification PASS must agree with subordinate statuses and failures",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (
|
if (record.status === "FAIL_UNVERIFIED" && record.failures.length === 0) {
|
||||||
artifact.status === "PASS" &&
|
|
||||||
[
|
|
||||||
artifact.candidateArchiveSha256,
|
|
||||||
artifact.vulnerabilityReportSha256,
|
|
||||||
artifact.provenanceAttestationSha256,
|
|
||||||
].some((digest) => digest === null)
|
|
||||||
) {
|
|
||||||
context.addIssue({
|
|
||||||
code: "custom",
|
|
||||||
path: ["candidateArchiveSha256"],
|
|
||||||
message: "passing verification requires every exact input digest",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (artifact.status === "FAIL_UNVERIFIED" && artifact.failures.length === 0) {
|
|
||||||
context.addIssue({
|
context.addIssue({
|
||||||
code: "custom",
|
code: "custom",
|
||||||
path: ["failures"],
|
path: ["failures"],
|
||||||
@@ -100,6 +155,20 @@ export type ProviderVerificationArtifactType = z.infer<
|
|||||||
export type ProviderTrust = Readonly<{
|
export type ProviderTrust = Readonly<{
|
||||||
keyId: string;
|
keyId: string;
|
||||||
publicKey: KeyObject;
|
publicKey: KeyObject;
|
||||||
|
publicKeyFingerprint: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type ExpectedPromotionContext = Readonly<{
|
||||||
|
run: Readonly<{ id: string; attempt: number }>;
|
||||||
|
source: Readonly<{ revision: string; sourceSetSha256: string }>;
|
||||||
|
candidate: Readonly<{
|
||||||
|
archiveSha256: string;
|
||||||
|
bundleSha256: string;
|
||||||
|
distSha256: string;
|
||||||
|
lockfileSha256: string;
|
||||||
|
}>;
|
||||||
|
vulnerabilityInvocationNonce: string;
|
||||||
|
provenanceInvocationNonce: string;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export type PromotionEvidenceResult = Readonly<{
|
export type PromotionEvidenceResult = Readonly<{
|
||||||
@@ -109,35 +178,134 @@ export type PromotionEvidenceResult = Readonly<{
|
|||||||
failures: readonly string[];
|
failures: readonly string[];
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
export function validateProviderEvidence(input: Readonly<{
|
||||||
|
kind: "vulnerability" | "provenance";
|
||||||
|
value: unknown;
|
||||||
|
expected: ExpectedPromotionContext;
|
||||||
|
trust: ProviderTrust | null;
|
||||||
|
nowEpochMs?: () => number;
|
||||||
|
}>): Readonly<{
|
||||||
|
evidence: unknown | null;
|
||||||
|
status: "PASS" | "FAIL_UNVERIFIED";
|
||||||
|
failures: readonly string[];
|
||||||
|
}> {
|
||||||
|
const failures: string[] = [];
|
||||||
|
const now = (input.nowEpochMs ?? Date.now)();
|
||||||
|
if (input.kind === "vulnerability") {
|
||||||
|
const parsed = vulnerabilityProviderReportSchema.safeParse(input.value);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return Object.freeze({
|
||||||
|
evidence: null,
|
||||||
|
status: "FAIL_UNVERIFIED",
|
||||||
|
failures: Object.freeze([
|
||||||
|
"external vulnerability provider report is missing or invalid",
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
validateCommonContext(
|
||||||
|
"vulnerability report",
|
||||||
|
parsed.data,
|
||||||
|
input.expected,
|
||||||
|
input.expected.vulnerabilityInvocationNonce,
|
||||||
|
input.trust,
|
||||||
|
now,
|
||||||
|
failures,
|
||||||
|
);
|
||||||
|
if (parsed.data.findings.length > 0) {
|
||||||
|
failures.push("vulnerability report contains findings");
|
||||||
|
}
|
||||||
|
return Object.freeze({
|
||||||
|
evidence: parsed.data,
|
||||||
|
status: failures.length === 0 ? "PASS" : "FAIL_UNVERIFIED",
|
||||||
|
failures: Object.freeze(failures),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const parsed = provenanceProviderAttestationSchema.safeParse(input.value);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return Object.freeze({
|
||||||
|
evidence: null,
|
||||||
|
status: "FAIL_UNVERIFIED",
|
||||||
|
failures: Object.freeze([
|
||||||
|
"external signed provenance attestation is missing or invalid",
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
validateCommonContext(
|
||||||
|
"provenance attestation",
|
||||||
|
parsed.data,
|
||||||
|
input.expected,
|
||||||
|
input.expected.provenanceInvocationNonce,
|
||||||
|
input.trust,
|
||||||
|
now,
|
||||||
|
failures,
|
||||||
|
);
|
||||||
|
if (parsed.data.subject.digest.sha256 !== input.expected.candidate.distSha256) {
|
||||||
|
failures.push("provenance attestation subject dist digest mismatch");
|
||||||
|
}
|
||||||
|
return Object.freeze({
|
||||||
|
evidence: parsed.data,
|
||||||
|
status: failures.length === 0 ? "PASS" : "FAIL_UNVERIFIED",
|
||||||
|
failures: Object.freeze(failures),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function providerEvidenceSignaturePayload(value: unknown): Buffer {
|
export function providerEvidenceSignaturePayload(value: unknown): Buffer {
|
||||||
if (!isRecord(value)) return Buffer.from("null", "utf8");
|
if (!isRecord(value)) return Buffer.from("null", "utf8");
|
||||||
const { signature: _signature, ...payload } = value;
|
const { signature: _signature, ...payload } = value;
|
||||||
return Buffer.from(
|
return Buffer.from(JSON.stringify(canonicalizeSupplyChainValue(payload)), "utf8");
|
||||||
JSON.stringify(canonicalizeSupplyChainValue(payload)),
|
}
|
||||||
"utf8",
|
|
||||||
);
|
export function providerPublicKeyFingerprint(publicKey: KeyObject): string {
|
||||||
|
if (publicKey.asymmetricKeyType !== "ed25519") {
|
||||||
|
throw new TypeError("provider trust key must be Ed25519");
|
||||||
|
}
|
||||||
|
return `sha256:${createHash("sha256")
|
||||||
|
.update(publicKey.export({ type: "spki", format: "der" }))
|
||||||
|
.digest("hex")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTrustPolicy(input: Readonly<{
|
||||||
|
vulnerabilityTrust: ProviderTrust;
|
||||||
|
provenanceTrust: ProviderTrust;
|
||||||
|
}>) {
|
||||||
|
return Object.freeze({
|
||||||
|
algorithm: "Ed25519" as const,
|
||||||
|
vulnerability: Object.freeze({
|
||||||
|
keyId: input.vulnerabilityTrust.keyId,
|
||||||
|
publicKeyFingerprint: input.vulnerabilityTrust.publicKeyFingerprint,
|
||||||
|
}),
|
||||||
|
provenance: Object.freeze({
|
||||||
|
keyId: input.provenanceTrust.keyId,
|
||||||
|
publicKeyFingerprint: input.provenanceTrust.publicKeyFingerprint,
|
||||||
|
}),
|
||||||
|
issuedAtFutureSkewMs: PROVIDER_FUTURE_SKEW_MS,
|
||||||
|
maximumLifetimeMs: PROVIDER_MAX_LIFETIME_MS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function trustPolicySha256(input: Readonly<{
|
||||||
|
vulnerabilityTrust: ProviderTrust;
|
||||||
|
provenanceTrust: ProviderTrust;
|
||||||
|
}>): string {
|
||||||
|
return supplyChainDigest(createTrustPolicy(input));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function evaluatePromotionEvidence(input: Readonly<{
|
export function evaluatePromotionEvidence(input: Readonly<{
|
||||||
candidate: Readonly<{ distSha256: string; lockfileSha256: string }>;
|
expected: ExpectedPromotionContext;
|
||||||
currentDistSha256: string;
|
|
||||||
localStatus: unknown;
|
localStatus: unknown;
|
||||||
vulnerabilityReport: unknown;
|
vulnerabilityReport: unknown;
|
||||||
provenanceAttestation: unknown;
|
provenanceAttestation: unknown;
|
||||||
vulnerabilityTrust: ProviderTrust | null;
|
vulnerabilityTrust: ProviderTrust | null;
|
||||||
provenanceTrust: ProviderTrust | null;
|
provenanceTrust: ProviderTrust | null;
|
||||||
|
nowEpochMs?: () => number;
|
||||||
}>): PromotionEvidenceResult {
|
}>): PromotionEvidenceResult {
|
||||||
const failures: string[] = [];
|
const failures: string[] = [];
|
||||||
let vulnerabilityStatus: "PASS" | "FAIL_UNVERIFIED" = "FAIL_UNVERIFIED";
|
|
||||||
let provenanceAttestationStatus: "PASS" | "FAIL_UNVERIFIED" =
|
|
||||||
"FAIL_UNVERIFIED";
|
|
||||||
|
|
||||||
if (input.localStatus !== "PASS") {
|
if (input.localStatus !== "PASS") {
|
||||||
failures.push("local supply-chain evidence is not PASS");
|
failures.push("local supply-chain evidence is not PASS");
|
||||||
}
|
}
|
||||||
if (input.currentDistSha256 !== input.candidate.distSha256) {
|
const now = (input.nowEpochMs ?? Date.now)();
|
||||||
failures.push("candidate dist bytes changed after immutable build");
|
let vulnerabilityStatus: "PASS" | "FAIL_UNVERIFIED" = "FAIL_UNVERIFIED";
|
||||||
}
|
let provenanceAttestationStatus: "PASS" | "FAIL_UNVERIFIED" = "FAIL_UNVERIFIED";
|
||||||
|
|
||||||
const vulnerability = vulnerabilityProviderReportSchema.safeParse(
|
const vulnerability = vulnerabilityProviderReportSchema.safeParse(
|
||||||
input.vulnerabilityReport,
|
input.vulnerabilityReport,
|
||||||
@@ -145,36 +313,20 @@ export function evaluatePromotionEvidence(input: Readonly<{
|
|||||||
if (!vulnerability.success) {
|
if (!vulnerability.success) {
|
||||||
failures.push("external vulnerability provider report is missing or invalid");
|
failures.push("external vulnerability provider report is missing or invalid");
|
||||||
} else {
|
} else {
|
||||||
if (
|
const before = failures.length;
|
||||||
vulnerability.data.scannedLockfileSha256 !==
|
validateCommonContext(
|
||||||
input.candidate.lockfileSha256
|
"vulnerability report",
|
||||||
) {
|
vulnerability.data,
|
||||||
failures.push("vulnerability report lockfile digest mismatch");
|
input.expected,
|
||||||
}
|
input.expected.vulnerabilityInvocationNonce,
|
||||||
if (
|
input.vulnerabilityTrust,
|
||||||
vulnerability.data.scannedDistSha256 !== input.candidate.distSha256
|
now,
|
||||||
) {
|
failures,
|
||||||
failures.push("vulnerability report dist digest mismatch");
|
);
|
||||||
}
|
|
||||||
if (vulnerability.data.findings.length > 0) {
|
if (vulnerability.data.findings.length > 0) {
|
||||||
failures.push("vulnerability report contains findings");
|
failures.push("vulnerability report contains findings");
|
||||||
}
|
}
|
||||||
const signaturePassed = signatureMatches(
|
if (failures.length === before && input.localStatus === "PASS") {
|
||||||
vulnerability.data,
|
|
||||||
input.vulnerabilityTrust,
|
|
||||||
);
|
|
||||||
if (!signaturePassed) {
|
|
||||||
failures.push("vulnerability report signature verification failed");
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
vulnerability.data.scannedLockfileSha256 ===
|
|
||||||
input.candidate.lockfileSha256 &&
|
|
||||||
vulnerability.data.scannedDistSha256 === input.candidate.distSha256 &&
|
|
||||||
vulnerability.data.findings.length === 0 &&
|
|
||||||
input.currentDistSha256 === input.candidate.distSha256 &&
|
|
||||||
input.localStatus === "PASS" &&
|
|
||||||
signaturePassed
|
|
||||||
) {
|
|
||||||
vulnerabilityStatus = "PASS";
|
vulnerabilityStatus = "PASS";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -185,22 +337,20 @@ export function evaluatePromotionEvidence(input: Readonly<{
|
|||||||
if (!provenance.success) {
|
if (!provenance.success) {
|
||||||
failures.push("external signed provenance attestation is missing or invalid");
|
failures.push("external signed provenance attestation is missing or invalid");
|
||||||
} else {
|
} else {
|
||||||
if (provenance.data.subject.digest.sha256 !== input.candidate.distSha256) {
|
const before = failures.length;
|
||||||
failures.push("provenance attestation dist digest mismatch");
|
validateCommonContext(
|
||||||
}
|
"provenance attestation",
|
||||||
const signaturePassed = signatureMatches(
|
|
||||||
provenance.data,
|
provenance.data,
|
||||||
|
input.expected,
|
||||||
|
input.expected.provenanceInvocationNonce,
|
||||||
input.provenanceTrust,
|
input.provenanceTrust,
|
||||||
|
now,
|
||||||
|
failures,
|
||||||
);
|
);
|
||||||
if (!signaturePassed) {
|
if (provenance.data.subject.digest.sha256 !== input.expected.candidate.distSha256) {
|
||||||
failures.push("provenance attestation signature verification failed");
|
failures.push("provenance attestation subject dist digest mismatch");
|
||||||
}
|
}
|
||||||
if (
|
if (failures.length === before && input.localStatus === "PASS") {
|
||||||
provenance.data.subject.digest.sha256 === input.candidate.distSha256 &&
|
|
||||||
input.currentDistSha256 === input.candidate.distSha256 &&
|
|
||||||
input.localStatus === "PASS" &&
|
|
||||||
signaturePassed
|
|
||||||
) {
|
|
||||||
provenanceAttestationStatus = "PASS";
|
provenanceAttestationStatus = "PASS";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -218,29 +368,78 @@ export function evaluatePromotionEvidence(input: Readonly<{
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function signatureMatches(
|
function validateCommonContext(
|
||||||
|
label: "vulnerability report" | "provenance attestation",
|
||||||
evidence: z.infer<
|
evidence: z.infer<
|
||||||
| typeof vulnerabilityProviderReportSchema
|
| typeof vulnerabilityProviderReportSchema
|
||||||
| typeof provenanceProviderAttestationSchema
|
| typeof provenanceProviderAttestationSchema
|
||||||
>,
|
>,
|
||||||
|
expected: ExpectedPromotionContext,
|
||||||
|
expectedNonce: string,
|
||||||
trust: ProviderTrust | null,
|
trust: ProviderTrust | null,
|
||||||
): boolean {
|
now: number,
|
||||||
|
failures: string[],
|
||||||
|
): void {
|
||||||
|
if (
|
||||||
|
evidence.run.id !== expected.run.id ||
|
||||||
|
evidence.run.attempt !== expected.run.attempt
|
||||||
|
) {
|
||||||
|
failures.push(`${label} run identity mismatch`);
|
||||||
|
}
|
||||||
|
if (evidence.run.invocationNonce !== expectedNonce) {
|
||||||
|
failures.push(`${label} invocation nonce mismatch`);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
evidence.source.revision !== expected.source.revision ||
|
||||||
|
evidence.source.sourceSetSha256 !== expected.source.sourceSetSha256
|
||||||
|
) {
|
||||||
|
failures.push(`${label} source identity mismatch`);
|
||||||
|
}
|
||||||
|
if (JSON.stringify(evidence.candidate) !== JSON.stringify(expected.candidate)) {
|
||||||
|
failures.push(`${label} candidate identity mismatch`);
|
||||||
|
}
|
||||||
|
validateEvidenceTime(label, evidence.issuedAt, evidence.expiresAt, now, failures);
|
||||||
if (
|
if (
|
||||||
!trust ||
|
!trust ||
|
||||||
evidence.signature.keyId !== trust.keyId ||
|
evidence.signature.keyId !== trust.keyId ||
|
||||||
trust.publicKey.asymmetricKeyType !== "ed25519"
|
evidence.signature.publicKeyFingerprint !== trust.publicKeyFingerprint
|
||||||
) {
|
) {
|
||||||
return false;
|
failures.push(`${label} trust identity mismatch`);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
return verify(
|
if (
|
||||||
null,
|
providerPublicKeyFingerprint(trust.publicKey) !== trust.publicKeyFingerprint ||
|
||||||
providerEvidenceSignaturePayload(evidence),
|
!verify(
|
||||||
trust.publicKey,
|
null,
|
||||||
Buffer.from(evidence.signature.value, "base64"),
|
providerEvidenceSignaturePayload(evidence),
|
||||||
);
|
trust.publicKey,
|
||||||
|
Buffer.from(evidence.signature.value, "base64"),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
failures.push(`${label} signature verification failed`);
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
failures.push(`${label} signature verification failed`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateEvidenceTime(
|
||||||
|
label: string,
|
||||||
|
issuedAt: string,
|
||||||
|
expiresAt: string,
|
||||||
|
now: number,
|
||||||
|
failures: string[],
|
||||||
|
): void {
|
||||||
|
const issued = Date.parse(issuedAt);
|
||||||
|
const expires = Date.parse(expiresAt);
|
||||||
|
if (issued > now + PROVIDER_FUTURE_SKEW_MS) {
|
||||||
|
failures.push(`${label} issuedAt exceeds allowed future skew`);
|
||||||
|
}
|
||||||
|
if (expires <= now) failures.push(`${label} is expired`);
|
||||||
|
if (expires <= issued) failures.push(`${label} validity window is not positive`);
|
||||||
|
if (expires - issued > PROVIDER_MAX_LIFETIME_MS) {
|
||||||
|
failures.push(`${label} validity window exceeds two hours`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import { randomBytes as cryptoRandomBytes } from "node:crypto";
|
||||||
|
|
||||||
|
import {
|
||||||
|
captureCiCandidateArchive,
|
||||||
|
withVerifiedCapturedCandidate,
|
||||||
|
type CapturedCandidateArchive,
|
||||||
|
} from "./ci-candidate-archive.ts";
|
||||||
|
import { verifyArchivedLocalEvidence } from "./local-release-evidence.ts";
|
||||||
|
import type { ExpectedPromotionContext, ProviderTrust } from "./provider-evidence.ts";
|
||||||
|
import { validateProviderUpload } from "./provider-upload-validator.ts";
|
||||||
|
|
||||||
|
export type ProviderInvocation = Readonly<{
|
||||||
|
candidateRoot: string;
|
||||||
|
environment: Readonly<Record<string, string>>;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export async function superviseProviderEvidence(input: Readonly<{
|
||||||
|
kind: "vulnerability" | "provenance";
|
||||||
|
archivePath: string;
|
||||||
|
expectedArchiveSha256: string;
|
||||||
|
expectedRun: Readonly<{ id: string; attempt: number; sourceRevision: string }>;
|
||||||
|
trust: ProviderTrust;
|
||||||
|
executeProvider: (invocation: ProviderInvocation) => Promise<void>;
|
||||||
|
captureReport: () => Promise<Buffer>;
|
||||||
|
}>, dependencies: Readonly<{
|
||||||
|
captureArchive?: typeof captureCiCandidateArchive;
|
||||||
|
withVerifiedCandidate?: typeof withVerifiedCapturedCandidate;
|
||||||
|
verifyLocalEvidence?: typeof verifyArchivedLocalEvidence;
|
||||||
|
validateUpload?: typeof validateProviderUpload;
|
||||||
|
randomBytes?: (bytes: number) => Buffer;
|
||||||
|
nowEpochMs?: () => number;
|
||||||
|
}> = {}): Promise<Readonly<{
|
||||||
|
evidence: unknown;
|
||||||
|
invocationNonce: string;
|
||||||
|
expectedContext: ExpectedPromotionContext;
|
||||||
|
}>> {
|
||||||
|
const captured = await (dependencies.captureArchive ?? captureCiCandidateArchive)({
|
||||||
|
archivePath: input.archivePath,
|
||||||
|
expectedSha256: input.expectedArchiveSha256,
|
||||||
|
});
|
||||||
|
const nonceBytes = (dependencies.randomBytes ?? cryptoRandomBytes)(32);
|
||||||
|
if (nonceBytes.byteLength !== 32) {
|
||||||
|
throw new TypeError("provider invocation nonce must contain exactly 32 bytes");
|
||||||
|
}
|
||||||
|
const invocationNonce = nonceBytes.toString("hex");
|
||||||
|
const now = (dependencies.nowEpochMs ?? Date.now)();
|
||||||
|
const result = await (dependencies.withVerifiedCandidate ?? withVerifiedCapturedCandidate)({
|
||||||
|
captured,
|
||||||
|
verify: async ({ extractionRoot, manifest }) => {
|
||||||
|
const local = await (dependencies.verifyLocalEvidence ?? verifyArchivedLocalEvidence)({
|
||||||
|
extractionRoot,
|
||||||
|
expectedManifest: manifest,
|
||||||
|
});
|
||||||
|
if (local.status !== "PASS" || !local.identity) {
|
||||||
|
throw new Error(
|
||||||
|
`provider candidate local assessment failed: ${local.failures.join("; ")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (local.identity.sourceRevision !== input.expectedRun.sourceRevision) {
|
||||||
|
throw new Error("provider candidate source revision mismatch");
|
||||||
|
}
|
||||||
|
const expectedContext: ExpectedPromotionContext = Object.freeze({
|
||||||
|
run: Object.freeze({ id: input.expectedRun.id, attempt: input.expectedRun.attempt }),
|
||||||
|
source: Object.freeze({
|
||||||
|
revision: local.identity.sourceRevision,
|
||||||
|
sourceSetSha256: local.identity.sourceSetSha256,
|
||||||
|
}),
|
||||||
|
candidate: Object.freeze({
|
||||||
|
archiveSha256: captured.archiveSha256,
|
||||||
|
bundleSha256: manifest.bundleSha256,
|
||||||
|
distSha256: manifest.distSha256,
|
||||||
|
lockfileSha256: manifest.lockfileSha256,
|
||||||
|
}),
|
||||||
|
vulnerabilityInvocationNonce:
|
||||||
|
input.kind === "vulnerability" ? invocationNonce : "0".repeat(64),
|
||||||
|
provenanceInvocationNonce:
|
||||||
|
input.kind === "provenance" ? invocationNonce : "0".repeat(64),
|
||||||
|
});
|
||||||
|
const issuedAt = new Date(now).toISOString();
|
||||||
|
const expiresAt = new Date(now + 60 * 60 * 1_000).toISOString();
|
||||||
|
await input.executeProvider({
|
||||||
|
candidateRoot: extractionRoot,
|
||||||
|
environment: providerInvocationEnvironment({
|
||||||
|
kind: input.kind,
|
||||||
|
expectedContext,
|
||||||
|
invocationNonce,
|
||||||
|
issuedAt,
|
||||||
|
expiresAt,
|
||||||
|
trust: input.trust,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const capturedReport = await input.captureReport();
|
||||||
|
const evidence = await (dependencies.validateUpload ?? validateProviderUpload)({
|
||||||
|
kind: input.kind,
|
||||||
|
verifiedManifest: manifest,
|
||||||
|
archiveSha256: captured.archiveSha256,
|
||||||
|
candidateRoot: extractionRoot,
|
||||||
|
capturedReport,
|
||||||
|
expectedContext,
|
||||||
|
trust: input.trust,
|
||||||
|
nowEpochMs: () => now,
|
||||||
|
});
|
||||||
|
return Object.freeze({ evidence, invocationNonce, expectedContext });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function providerInvocationEnvironment(input: Readonly<{
|
||||||
|
kind: "vulnerability" | "provenance";
|
||||||
|
expectedContext: ExpectedPromotionContext;
|
||||||
|
invocationNonce: string;
|
||||||
|
issuedAt: string;
|
||||||
|
expiresAt: string;
|
||||||
|
trust: ProviderTrust;
|
||||||
|
}>): Readonly<Record<string, string>> {
|
||||||
|
return Object.freeze({
|
||||||
|
PROVIDER_EVIDENCE_SCHEMA_VERSION: "2",
|
||||||
|
PROVIDER_EVIDENCE_TYPE:
|
||||||
|
input.kind === "vulnerability"
|
||||||
|
? "vulnerability-report"
|
||||||
|
: "provenance-attestation",
|
||||||
|
PROVIDER_ISSUED_AT: input.issuedAt,
|
||||||
|
PROVIDER_EXPIRES_AT: input.expiresAt,
|
||||||
|
PROVIDER_INVOCATION_NONCE: input.invocationNonce,
|
||||||
|
PROVIDER_KEY_ID: input.trust.keyId,
|
||||||
|
PROVIDER_PUBLIC_KEY_FINGERPRINT: input.trust.publicKeyFingerprint,
|
||||||
|
CI_RUN_ID: input.expectedContext.run.id,
|
||||||
|
CI_RUN_ATTEMPT: String(input.expectedContext.run.attempt),
|
||||||
|
SOURCE_REVISION: input.expectedContext.source.revision,
|
||||||
|
SOURCE_SET_SHA256: input.expectedContext.source.sourceSetSha256,
|
||||||
|
CANDIDATE_ROOT: "/candidate",
|
||||||
|
CANDIDATE_LOCKFILE_PATH: "/candidate/pnpm-lock.yaml",
|
||||||
|
CANDIDATE_ARCHIVE_SHA256: input.expectedContext.candidate.archiveSha256,
|
||||||
|
CANDIDATE_BUNDLE_SHA256: input.expectedContext.candidate.bundleSha256,
|
||||||
|
CANDIDATE_DIST_SHA256: input.expectedContext.candidate.distSha256,
|
||||||
|
CANDIDATE_LOCKFILE_SHA256: input.expectedContext.candidate.lockfileSha256,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CaptureArchiveDependency = (
|
||||||
|
input: Readonly<{ archivePath: string; expectedSha256: string }>,
|
||||||
|
) => Promise<CapturedCandidateArchive>;
|
||||||
@@ -1,74 +1,59 @@
|
|||||||
import { createHash } from "node:crypto";
|
|
||||||
import path from "node:path";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
provenanceProviderAttestationSchema,
|
validateProviderEvidence,
|
||||||
vulnerabilityProviderReportSchema,
|
type ExpectedPromotionContext,
|
||||||
|
type ProviderTrust,
|
||||||
} from "./provider-evidence.ts";
|
} from "./provider-evidence.ts";
|
||||||
import {
|
import {
|
||||||
verifyReleaseCandidate,
|
verifyReleaseCandidate,
|
||||||
|
type ReleaseCandidateManifest,
|
||||||
} from "./release-candidate.ts";
|
} from "./release-candidate.ts";
|
||||||
import { readBoundedRegularFile } from "./ci-artifact-validator.ts";
|
|
||||||
import { verifyCiCandidateArchive } from "./ci-candidate-archive.ts";
|
|
||||||
|
|
||||||
export async function validateProviderUpload(input: Readonly<{
|
export async function validateProviderUpload(input: Readonly<{
|
||||||
kind: "vulnerability" | "provenance";
|
kind: "vulnerability" | "provenance";
|
||||||
|
verifiedManifest: ReleaseCandidateManifest;
|
||||||
|
archiveSha256: string;
|
||||||
candidateRoot: string;
|
candidateRoot: string;
|
||||||
archivePath: string;
|
capturedReport: Buffer;
|
||||||
expectedArchiveSha256: string;
|
expectedContext: ExpectedPromotionContext;
|
||||||
reportPath: string;
|
trust: ProviderTrust;
|
||||||
workspaceRoot?: string;
|
nowEpochMs?: () => number;
|
||||||
expectedDistSha256: string;
|
|
||||||
}>): Promise<unknown> {
|
}>): Promise<unknown> {
|
||||||
if (!/^[a-f0-9]{64}$/u.test(input.expectedDistSha256)) {
|
if (
|
||||||
throw new TypeError("expected candidate dist SHA-256 is invalid");
|
input.expectedContext.candidate.archiveSha256 !== input.archiveSha256 ||
|
||||||
|
input.expectedContext.candidate.bundleSha256 !== input.verifiedManifest.bundleSha256 ||
|
||||||
|
input.expectedContext.candidate.distSha256 !== input.verifiedManifest.distSha256 ||
|
||||||
|
input.expectedContext.candidate.lockfileSha256 !== input.verifiedManifest.lockfileSha256
|
||||||
|
) {
|
||||||
|
throw new Error("provider supervisor expected candidate context mismatch");
|
||||||
}
|
}
|
||||||
const archive = await verifyCiCandidateArchive({
|
const verifiedCandidate = await verifyReleaseCandidate(
|
||||||
archivePath: input.archivePath,
|
input.verifiedManifest,
|
||||||
expectedSha256: input.expectedArchiveSha256,
|
input.candidateRoot,
|
||||||
});
|
);
|
||||||
const manifest = archive.manifest;
|
|
||||||
if (manifest.distSha256 !== input.expectedDistSha256) {
|
|
||||||
throw new Error("provider input candidate dist digest mismatch");
|
|
||||||
}
|
|
||||||
const verifiedCandidate = await verifyReleaseCandidate(manifest, input.candidateRoot);
|
|
||||||
if (verifiedCandidate.failures.length > 0) {
|
if (verifiedCandidate.failures.length > 0) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`provider input candidate root changed: ${verifiedCandidate.failures.join("; ")}`,
|
`provider input candidate root changed: ${verifiedCandidate.failures.join("; ")}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const reportAbsolute = path.resolve(input.reportPath);
|
let report: unknown;
|
||||||
const reportRoot = path.resolve(input.workspaceRoot ?? process.cwd());
|
try {
|
||||||
const reportRelative = path.relative(reportRoot, reportAbsolute).replaceAll(path.sep, "/");
|
report = JSON.parse(
|
||||||
const report = JSON.parse(
|
new TextDecoder("utf-8", { fatal: true }).decode(input.capturedReport),
|
||||||
new TextDecoder("utf-8", { fatal: true }).decode(
|
) as unknown;
|
||||||
await readBoundedRegularFile({
|
} catch {
|
||||||
root: reportRoot,
|
throw new TypeError("provider output is not canonical UTF-8 JSON");
|
||||||
relativePath: reportRelative,
|
|
||||||
maxBytes: 8_388_608,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
) as unknown;
|
|
||||||
if (input.kind === "vulnerability") {
|
|
||||||
const parsed = vulnerabilityProviderReportSchema.parse(report);
|
|
||||||
const lockfile = await readBoundedRegularFile({
|
|
||||||
root: input.candidateRoot,
|
|
||||||
relativePath: "pnpm-lock.yaml",
|
|
||||||
maxBytes: 67_108_864,
|
|
||||||
});
|
|
||||||
const lockfileSha256 = createHash("sha256").update(lockfile).digest("hex");
|
|
||||||
if (
|
|
||||||
parsed.scannedDistSha256 !== manifest.distSha256 ||
|
|
||||||
parsed.scannedLockfileSha256 !== manifest.lockfileSha256 ||
|
|
||||||
lockfileSha256 !== manifest.lockfileSha256
|
|
||||||
) {
|
|
||||||
throw new Error("vulnerability provider evidence candidate digest mismatch");
|
|
||||||
}
|
|
||||||
return parsed;
|
|
||||||
}
|
}
|
||||||
const parsed = provenanceProviderAttestationSchema.parse(report);
|
const evaluated = validateProviderEvidence({
|
||||||
if (parsed.subject.digest.sha256 !== manifest.distSha256) {
|
kind: input.kind,
|
||||||
throw new Error("provenance provider evidence candidate digest mismatch");
|
value: report,
|
||||||
|
expected: input.expectedContext,
|
||||||
|
trust: input.trust,
|
||||||
|
nowEpochMs: input.nowEpochMs,
|
||||||
|
});
|
||||||
|
if (evaluated.status !== "PASS" || !evaluated.evidence) {
|
||||||
|
throw new Error(
|
||||||
|
`provider evidence context validation failed: ${evaluated.failures.join("; ")}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return parsed;
|
return evaluated.evidence;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ export type ReleaseCandidateManifest = z.infer<
|
|||||||
|
|
||||||
export const RELEASE_CANDIDATE_MANIFEST_PATH =
|
export const RELEASE_CANDIDATE_MANIFEST_PATH =
|
||||||
"artifacts/release/release-candidate.json";
|
"artifacts/release/release-candidate.json";
|
||||||
|
export const LOCAL_EVIDENCE_ASSESSMENT_PATH =
|
||||||
|
"artifacts/security/local-evidence-assessment.json";
|
||||||
|
|
||||||
export const RELEASE_CANDIDATE_EVIDENCE_PATHS = Object.freeze([
|
export const RELEASE_CANDIDATE_EVIDENCE_PATHS = Object.freeze([
|
||||||
"pnpm-lock.yaml",
|
"pnpm-lock.yaml",
|
||||||
@@ -45,6 +47,7 @@ export const RELEASE_CANDIDATE_EVIDENCE_PATHS = Object.freeze([
|
|||||||
"artifacts/release/sbom.cdx.json",
|
"artifacts/release/sbom.cdx.json",
|
||||||
"artifacts/security/dependency-diff.json",
|
"artifacts/security/dependency-diff.json",
|
||||||
"artifacts/security/license-report.json",
|
"artifacts/security/license-report.json",
|
||||||
|
LOCAL_EVIDENCE_ASSESSMENT_PATH,
|
||||||
"artifacts/security/scan.sarif",
|
"artifacts/security/scan.sarif",
|
||||||
"artifacts/security/supply-chain-coherence.json",
|
"artifacts/security/supply-chain-coherence.json",
|
||||||
"artifacts/security/supply-chain-verification.json",
|
"artifacts/security/supply-chain-verification.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { spawn } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
import { constants } from "node:fs";
|
import { constants } from "node:fs";
|
||||||
import { access, lstat, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
import { access, appendFile, lstat, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
@@ -8,7 +8,9 @@ import {
|
|||||||
provenanceProviderAttestationSchema,
|
provenanceProviderAttestationSchema,
|
||||||
vulnerabilityProviderReportSchema,
|
vulnerabilityProviderReportSchema,
|
||||||
} from "./lib/provider-evidence.ts";
|
} from "./lib/provider-evidence.ts";
|
||||||
import { validateProviderUpload } from "./lib/provider-upload-validator.ts";
|
import { readBoundedRegularFile } from "./lib/ci-artifact-validator.ts";
|
||||||
|
import { readProviderTrust } from "./lib/promotion-verifier.ts";
|
||||||
|
import { superviseProviderEvidence } from "./lib/provider-supervisor.ts";
|
||||||
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
|
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
|
||||||
import {
|
import {
|
||||||
assertSafePublishLeaf,
|
assertSafePublishLeaf,
|
||||||
@@ -29,22 +31,37 @@ const reportPath =
|
|||||||
? process.env.VULNERABILITY_REPORT_PATH
|
? process.env.VULNERABILITY_REPORT_PATH
|
||||||
: process.env.PROVENANCE_ATTESTATION_PATH;
|
: process.env.PROVENANCE_ATTESTATION_PATH;
|
||||||
const sealedPath = process.env.VALIDATED_PROVIDER_REPORT_PATH;
|
const sealedPath = process.env.VALIDATED_PROVIDER_REPORT_PATH;
|
||||||
const candidateLockfile = process.env.CANDIDATE_LOCKFILE_PATH;
|
|
||||||
const archivePath = process.env.CANDIDATE_ARCHIVE_PATH;
|
const archivePath = process.env.CANDIDATE_ARCHIVE_PATH;
|
||||||
const archiveSha256 = process.env.CANDIDATE_ARCHIVE_SHA256;
|
const archiveSha256 = process.env.CANDIDATE_ARCHIVE_SHA256;
|
||||||
const candidateDistSha256 = process.env.CANDIDATE_DIST_SHA256;
|
const publicKeyPath = kind === "vulnerability"
|
||||||
|
? process.env.VULNERABILITY_PUBLIC_KEY_PATH
|
||||||
|
: process.env.PROVENANCE_PUBLIC_KEY_PATH;
|
||||||
|
const keyId = kind === "vulnerability"
|
||||||
|
? process.env.VULNERABILITY_KEY_ID
|
||||||
|
: process.env.PROVENANCE_KEY_ID;
|
||||||
|
const runId = process.env.GITEA_RUN_ID ?? process.env.GITHUB_RUN_ID ?? process.env.CI_RUN_ID;
|
||||||
|
const runAttemptSource = process.env.GITEA_RUN_ATTEMPT ??
|
||||||
|
process.env.GITHUB_RUN_ATTEMPT ?? process.env.CI_RUN_ATTEMPT;
|
||||||
|
const sourceRevision = process.env.EXPECTED_SOURCE_REVISION ?? process.env.VITE_COMMIT_SHA;
|
||||||
if (
|
if (
|
||||||
!command ||
|
!command ||
|
||||||
!reportPath ||
|
!reportPath ||
|
||||||
!sealedPath ||
|
!sealedPath ||
|
||||||
!candidateLockfile ||
|
|
||||||
!archivePath ||
|
!archivePath ||
|
||||||
!archiveSha256 ||
|
!archiveSha256 ||
|
||||||
!candidateDistSha256
|
!publicKeyPath ||
|
||||||
|
!keyId ||
|
||||||
|
!runId ||
|
||||||
|
!runAttemptSource ||
|
||||||
|
!sourceRevision
|
||||||
) {
|
) {
|
||||||
process.stderr.write("Provider supervisor environment is incomplete\n");
|
process.stderr.write("Provider supervisor environment is incomplete\n");
|
||||||
process.exit(2);
|
process.exit(2);
|
||||||
}
|
}
|
||||||
|
const runAttempt = Number(runAttemptSource);
|
||||||
|
if (!Number.isInteger(runAttempt) || runAttempt < 1 || runAttempt > 1_000) {
|
||||||
|
throw new TypeError("provider supervisor run attempt is invalid");
|
||||||
|
}
|
||||||
|
|
||||||
const workspaceRoot = process.cwd();
|
const workspaceRoot = process.cwd();
|
||||||
const reportAbsolute = path.resolve(reportPath);
|
const reportAbsolute = path.resolve(reportPath);
|
||||||
@@ -62,22 +79,30 @@ await prepareMissingProviderOutput(workspaceRoot, sealedAbsolute, sealedPath, "s
|
|||||||
await access("/usr/bin/bwrap", constants.X_OK).catch(() => {
|
await access("/usr/bin/bwrap", constants.X_OK).catch(() => {
|
||||||
throw new Error("provider sandbox unavailable: /usr/bin/bwrap is required");
|
throw new Error("provider sandbox unavailable: /usr/bin/bwrap is required");
|
||||||
});
|
});
|
||||||
|
const trust = await readProviderTrust(workspaceRoot, publicKeyPath, keyId);
|
||||||
const childEnvironment = createProviderEnvironment(kind, reportPath, {
|
if (!trust) throw new TypeError("provider supervisor trust key is invalid");
|
||||||
candidateLockfile,
|
const supervised = await superviseProviderEvidence({
|
||||||
archivePath,
|
|
||||||
archiveSha256,
|
|
||||||
candidateDistSha256,
|
|
||||||
});
|
|
||||||
await runProviderInSandbox(command, childEnvironment, rawDirectory, workspaceRoot);
|
|
||||||
const parsed = await validateProviderUpload({
|
|
||||||
kind,
|
kind,
|
||||||
candidateRoot: path.dirname(path.resolve(candidateLockfile)),
|
|
||||||
archivePath,
|
archivePath,
|
||||||
expectedArchiveSha256: archiveSha256,
|
expectedArchiveSha256: archiveSha256,
|
||||||
reportPath,
|
expectedRun: { id: runId, attempt: runAttempt, sourceRevision },
|
||||||
workspaceRoot,
|
trust,
|
||||||
expectedDistSha256: candidateDistSha256,
|
executeProvider: async ({ candidateRoot, environment }) => {
|
||||||
|
const childEnvironment = createProviderEnvironment(kind, reportPath, environment);
|
||||||
|
await runProviderInSandbox(
|
||||||
|
command,
|
||||||
|
childEnvironment,
|
||||||
|
rawDirectory,
|
||||||
|
workspaceRoot,
|
||||||
|
candidateRoot,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
captureReport: () =>
|
||||||
|
readBoundedRegularFile({
|
||||||
|
root: workspaceRoot,
|
||||||
|
relativePath: path.relative(workspaceRoot, reportAbsolute).replaceAll(path.sep, "/"),
|
||||||
|
maxBytes: 8_388_608,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
await assertSafePublishLeaf(sealedAbsolute, sealedPath);
|
await assertSafePublishLeaf(sealedAbsolute, sealedPath);
|
||||||
await writeValidatedJsonArtifact({
|
await writeValidatedJsonArtifact({
|
||||||
@@ -86,19 +111,21 @@ await writeValidatedJsonArtifact({
|
|||||||
kind === "vulnerability"
|
kind === "vulnerability"
|
||||||
? vulnerabilityProviderReportSchema
|
? vulnerabilityProviderReportSchema
|
||||||
: provenanceProviderAttestationSchema,
|
: provenanceProviderAttestationSchema,
|
||||||
value: parsed,
|
value: supervised.evidence,
|
||||||
});
|
});
|
||||||
|
if (process.env.GITHUB_OUTPUT) {
|
||||||
|
await appendFile(
|
||||||
|
process.env.GITHUB_OUTPUT,
|
||||||
|
`invocation_nonce=${supervised.invocationNonce}\n`,
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
}
|
||||||
process.stdout.write(`${kind} provider supervised validation: PASS\n`);
|
process.stdout.write(`${kind} provider supervised validation: PASS\n`);
|
||||||
|
|
||||||
function createProviderEnvironment(
|
function createProviderEnvironment(
|
||||||
providerKind: "vulnerability" | "provenance",
|
providerKind: "vulnerability" | "provenance",
|
||||||
rawReportPath: string,
|
rawReportPath: string,
|
||||||
candidate: Readonly<{
|
bindings: Readonly<Record<string, string>>,
|
||||||
candidateLockfile: string;
|
|
||||||
archivePath: string;
|
|
||||||
archiveSha256: string;
|
|
||||||
candidateDistSha256: string;
|
|
||||||
}>,
|
|
||||||
): NodeJS.ProcessEnv {
|
): NodeJS.ProcessEnv {
|
||||||
const environment: NodeJS.ProcessEnv = {
|
const environment: NodeJS.ProcessEnv = {
|
||||||
PATH: process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin",
|
PATH: process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin",
|
||||||
@@ -107,10 +134,7 @@ function createProviderEnvironment(
|
|||||||
CI: "true",
|
CI: "true",
|
||||||
GITHUB_ENV: "/tmp/github-env",
|
GITHUB_ENV: "/tmp/github-env",
|
||||||
GITHUB_PATH: "/tmp/github-path",
|
GITHUB_PATH: "/tmp/github-path",
|
||||||
CANDIDATE_LOCKFILE_PATH: candidate.candidateLockfile,
|
...bindings,
|
||||||
CANDIDATE_ARCHIVE_PATH: candidate.archivePath,
|
|
||||||
CANDIDATE_ARCHIVE_SHA256: candidate.archiveSha256,
|
|
||||||
CANDIDATE_DIST_SHA256: candidate.candidateDistSha256,
|
|
||||||
...(providerKind === "vulnerability"
|
...(providerKind === "vulnerability"
|
||||||
? { VULNERABILITY_REPORT_PATH: rawReportPath }
|
? { VULNERABILITY_REPORT_PATH: rawReportPath }
|
||||||
: { PROVENANCE_ATTESTATION_PATH: rawReportPath }),
|
: { PROVENANCE_ATTESTATION_PATH: rawReportPath }),
|
||||||
@@ -132,6 +156,7 @@ async function runProviderInSandbox(
|
|||||||
environment: NodeJS.ProcessEnv,
|
environment: NodeJS.ProcessEnv,
|
||||||
rawDirectory: string,
|
rawDirectory: string,
|
||||||
workspaceRoot: string,
|
workspaceRoot: string,
|
||||||
|
candidateRoot: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const scratch = await mkdtemp(path.join(tmpdir(), "ci-provider-sandbox-"));
|
const scratch = await mkdtemp(path.join(tmpdir(), "ci-provider-sandbox-"));
|
||||||
try {
|
try {
|
||||||
@@ -177,6 +202,7 @@ async function runProviderInSandbox(
|
|||||||
}
|
}
|
||||||
arguments_.push(
|
arguments_.push(
|
||||||
"--bind", rawDirectory, rawDirectory,
|
"--bind", rawDirectory, rawDirectory,
|
||||||
|
"--ro-bind", candidateRoot, "/candidate",
|
||||||
"--chdir", workspaceRoot,
|
"--chdir", workspaceRoot,
|
||||||
"/bin/sh", "-eu", "-c", command,
|
"/bin/sh", "-eu", "-c", command,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { stageVerifiedPromotion } from "./lib/promotion-stager.ts";
|
import { appendFile } from "node:fs/promises";
|
||||||
|
|
||||||
|
import { finalizeVerifiedPromotion } from "./lib/promotion-stager.ts";
|
||||||
|
|
||||||
const required = (name: string): string => {
|
const required = (name: string): string => {
|
||||||
const value = process.env[name];
|
const value = process.env[name];
|
||||||
@@ -6,7 +8,7 @@ const required = (name: string): string => {
|
|||||||
return value;
|
return value;
|
||||||
};
|
};
|
||||||
|
|
||||||
const staged = await stageVerifiedPromotion({
|
const staged = await finalizeVerifiedPromotion({
|
||||||
repositoryRoot: process.cwd(),
|
repositoryRoot: process.cwd(),
|
||||||
archivePath: required("CANDIDATE_ARCHIVE_PATH"),
|
archivePath: required("CANDIDATE_ARCHIVE_PATH"),
|
||||||
expectedArchiveSha256: required("CANDIDATE_ARCHIVE_SHA256"),
|
expectedArchiveSha256: required("CANDIDATE_ARCHIVE_SHA256"),
|
||||||
@@ -16,7 +18,27 @@ const staged = await stageVerifiedPromotion({
|
|||||||
vulnerabilityKeyId: required("VULNERABILITY_KEY_ID"),
|
vulnerabilityKeyId: required("VULNERABILITY_KEY_ID"),
|
||||||
provenancePublicKeyPath: required("PROVENANCE_PUBLIC_KEY_PATH"),
|
provenancePublicKeyPath: required("PROVENANCE_PUBLIC_KEY_PATH"),
|
||||||
provenanceKeyId: required("PROVENANCE_KEY_ID"),
|
provenanceKeyId: required("PROVENANCE_KEY_ID"),
|
||||||
|
expectedRun: {
|
||||||
|
id: process.env.GITEA_RUN_ID ?? process.env.GITHUB_RUN_ID ?? required("CI_RUN_ID"),
|
||||||
|
attempt: Number(process.env.GITEA_RUN_ATTEMPT ?? process.env.GITHUB_RUN_ATTEMPT ?? required("CI_RUN_ATTEMPT")),
|
||||||
|
sourceRevision: process.env.EXPECTED_SOURCE_REVISION ?? required("VITE_COMMIT_SHA"),
|
||||||
|
},
|
||||||
|
vulnerabilityInvocationNonce: required("VULNERABILITY_INVOCATION_NONCE"),
|
||||||
|
provenanceInvocationNonce: required("PROVENANCE_INVOCATION_NONCE"),
|
||||||
|
runnerTempRoot: required("RUNNER_TEMP"),
|
||||||
});
|
});
|
||||||
|
const output = required("GITHUB_OUTPUT");
|
||||||
|
await appendFile(
|
||||||
|
output,
|
||||||
|
[
|
||||||
|
`staging_root=${staged.stagingRoot}`,
|
||||||
|
`cleanup_token=${staged.cleanupToken}`,
|
||||||
|
`runner_temp_dev=${staged.runnerTempIdentity.dev}`,
|
||||||
|
`runner_temp_ino=${staged.runnerTempIdentity.ino}`,
|
||||||
|
"",
|
||||||
|
].join("\n"),
|
||||||
|
{ encoding: "utf8" },
|
||||||
|
);
|
||||||
process.stdout.write(
|
process.stdout.write(
|
||||||
`Promotion staging: ${staged.map(({ path, sha256 }) => `${path}=${sha256}`).join(", ")} PASS\n`,
|
`Promotion staging: ${staged.files.map(({ name, sha256 }) => `${name}=${sha256}`).join(", ")} PASS\n`,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,7 +9,10 @@ import {
|
|||||||
const candidate = releaseCandidateManifestSchema.parse(
|
const candidate = releaseCandidateManifestSchema.parse(
|
||||||
JSON.parse(await readFile(RELEASE_CANDIDATE_MANIFEST_PATH, "utf8")),
|
JSON.parse(await readFile(RELEASE_CANDIDATE_MANIFEST_PATH, "utf8")),
|
||||||
);
|
);
|
||||||
const result = await verifyArchivedLocalEvidence({ candidate });
|
const result = await verifyArchivedLocalEvidence({
|
||||||
|
extractionRoot: process.cwd(),
|
||||||
|
expectedManifest: candidate,
|
||||||
|
});
|
||||||
if (result.status !== "PASS") {
|
if (result.status !== "PASS") {
|
||||||
process.stderr.write(
|
process.stderr.write(
|
||||||
`Archived local evidence verification failed:\n- ${result.failures.join("\n- ")}\n`,
|
`Archived local evidence verification failed:\n- ${result.failures.join("\n- ")}\n`,
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import { cp, mkdtemp, readFile, rm, symlink } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { expect, it } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
captureCiCandidateArchive,
|
||||||
|
withVerifiedCapturedCandidate,
|
||||||
|
} from "../../scripts/lib/ci-candidate-archive.ts";
|
||||||
|
import { verifyArchivedLocalEvidence } from "../../scripts/lib/local-release-evidence.ts";
|
||||||
|
import {
|
||||||
|
RELEASE_CANDIDATE_EVIDENCE_PATHS,
|
||||||
|
RELEASE_CANDIDATE_MANIFEST_PATH,
|
||||||
|
releaseCandidateManifestSchema,
|
||||||
|
} from "../../scripts/lib/release-candidate.ts";
|
||||||
|
|
||||||
|
it(
|
||||||
|
"builds a real candidate assessment and passes the default archived verifier from the captured archive",
|
||||||
|
async () => {
|
||||||
|
const sourceRoot = process.cwd();
|
||||||
|
const fixtureRoot = await mkdtemp(path.join(tmpdir(), "security-followup-producer-"));
|
||||||
|
try {
|
||||||
|
await cp(sourceRoot, fixtureRoot, {
|
||||||
|
recursive: true,
|
||||||
|
filter: (source) => {
|
||||||
|
const relative = path.relative(sourceRoot, source);
|
||||||
|
if (!relative) return true;
|
||||||
|
const first = relative.split(path.sep)[0];
|
||||||
|
return ![
|
||||||
|
".release",
|
||||||
|
"artifacts",
|
||||||
|
"dist",
|
||||||
|
"node_modules",
|
||||||
|
].includes(first ?? "");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await cp(path.join(sourceRoot, "artifacts"), path.join(fixtureRoot, "artifacts"), {
|
||||||
|
recursive: true,
|
||||||
|
});
|
||||||
|
await rm(path.join(fixtureRoot, "artifacts/release"), {
|
||||||
|
recursive: true,
|
||||||
|
force: true,
|
||||||
|
});
|
||||||
|
await symlink(path.join(sourceRoot, "node_modules"), path.join(fixtureRoot, "node_modules"), "dir");
|
||||||
|
const git = spawnSync("git", ["show", "-s", "--format=%H%n%ct", "HEAD"], {
|
||||||
|
cwd: sourceRoot,
|
||||||
|
encoding: "utf8",
|
||||||
|
});
|
||||||
|
expect(git.status, git.stderr).toBe(0);
|
||||||
|
const [revision, sourceDateEpoch] = git.stdout.trim().split(/\r?\n/u);
|
||||||
|
const build = spawnSync(
|
||||||
|
"corepack",
|
||||||
|
["pnpm", "build:release-candidate"],
|
||||||
|
{
|
||||||
|
cwd: fixtureRoot,
|
||||||
|
encoding: "utf8",
|
||||||
|
timeout: 120_000,
|
||||||
|
maxBuffer: 32 * 1024 * 1024,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
CI: "true",
|
||||||
|
VITE_BUILD_ID: "security-followup-integration",
|
||||||
|
VITE_COMMIT_SHA: revision,
|
||||||
|
RELEASE_ID: "security-followup-integration",
|
||||||
|
SOURCE_DATE_EPOCH: sourceDateEpoch,
|
||||||
|
CI_RUNNER_IMAGE: `fixture@sha256:${"a".repeat(64)}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(build.status, `${build.stdout}\n${build.stderr}`).toBe(0);
|
||||||
|
const manifest = releaseCandidateManifestSchema.parse(
|
||||||
|
JSON.parse(
|
||||||
|
await readFile(path.join(fixtureRoot, RELEASE_CANDIDATE_MANIFEST_PATH), "utf8"),
|
||||||
|
) as unknown,
|
||||||
|
);
|
||||||
|
const archivePath = path.join(fixtureRoot, "candidate.tar.gz");
|
||||||
|
const archived = spawnSync(
|
||||||
|
"/usr/bin/tar",
|
||||||
|
[
|
||||||
|
"--sort=name",
|
||||||
|
"--mtime=@0",
|
||||||
|
"--owner=0",
|
||||||
|
"--group=0",
|
||||||
|
"--numeric-owner",
|
||||||
|
"-czf",
|
||||||
|
archivePath,
|
||||||
|
"dist",
|
||||||
|
...RELEASE_CANDIDATE_EVIDENCE_PATHS,
|
||||||
|
RELEASE_CANDIDATE_MANIFEST_PATH,
|
||||||
|
],
|
||||||
|
{ cwd: fixtureRoot, encoding: "utf8" },
|
||||||
|
);
|
||||||
|
expect(archived.status, archived.stderr).toBe(0);
|
||||||
|
const archiveBytes = await readFile(archivePath);
|
||||||
|
const expectedSha256 = await import("node:crypto").then(({ createHash }) =>
|
||||||
|
createHash("sha256").update(archiveBytes).digest("hex"),
|
||||||
|
);
|
||||||
|
const captured = await captureCiCandidateArchive({ archivePath, expectedSha256 });
|
||||||
|
const verified = await withVerifiedCapturedCandidate({
|
||||||
|
captured,
|
||||||
|
verify: ({ extractionRoot, manifest: extractedManifest }) =>
|
||||||
|
verifyArchivedLocalEvidence({
|
||||||
|
extractionRoot,
|
||||||
|
expectedManifest: extractedManifest,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(manifest.files).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
path: "artifacts/security/local-evidence-assessment.json",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(verified).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
status: "PASS",
|
||||||
|
identity: expect.objectContaining({ sourceRevision: revision }),
|
||||||
|
failures: [],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await rm(fixtureRoot, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
150_000,
|
||||||
|
);
|
||||||
@@ -159,6 +159,7 @@ jobs:
|
|||||||
artifacts/release/sbom.cdx.json \\
|
artifacts/release/sbom.cdx.json \\
|
||||||
artifacts/security/dependency-diff.json \\
|
artifacts/security/dependency-diff.json \\
|
||||||
artifacts/security/license-report.json \\
|
artifacts/security/license-report.json \\
|
||||||
|
artifacts/security/local-evidence-assessment.json \\
|
||||||
artifacts/security/scan.sarif \\
|
artifacts/security/scan.sarif \\
|
||||||
artifacts/security/supply-chain-coherence.json \\
|
artifacts/security/supply-chain-coherence.json \\
|
||||||
artifacts/security/supply-chain-verification.json \\
|
artifacts/security/supply-chain-verification.json \\
|
||||||
@@ -177,11 +178,16 @@ jobs:
|
|||||||
needs: immutable_build
|
needs: immutable_build
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 45
|
timeout-minutes: 45
|
||||||
|
outputs:
|
||||||
|
invocation_nonce: \${{ steps.supervise_vulnerability.outputs.invocation_nonce }}
|
||||||
env:
|
env:
|
||||||
CANDIDATE_ARCHIVE_SHA256: "\${{ needs.immutable_build.outputs.archive_sha256 }}"
|
CANDIDATE_ARCHIVE_SHA256: "\${{ needs.immutable_build.outputs.archive_sha256 }}"
|
||||||
CANDIDATE_ARCHIVE_PATH: ".release/vulnerability-candidate/release-candidate-\${{ gitea.run_id }}-\${{ gitea.run_attempt }}.tar.gz"
|
CANDIDATE_ARCHIVE_PATH: ".release/vulnerability-candidate/release-candidate-\${{ gitea.run_id }}-\${{ gitea.run_attempt }}.tar.gz"
|
||||||
CANDIDATE_DIST_SHA256: "\${{ needs.immutable_build.outputs.dist_sha256 }}"
|
CI_RUN_ID: "\${{ gitea.run_id }}"
|
||||||
CANDIDATE_LOCKFILE_PATH: .release/verified-vulnerability/pnpm-lock.yaml
|
CI_RUN_ATTEMPT: "\${{ gitea.run_attempt }}"
|
||||||
|
EXPECTED_SOURCE_REVISION: "\${{ gitea.sha }}"
|
||||||
|
VULNERABILITY_PUBLIC_KEY_PATH: "\${{ vars.VULNERABILITY_PUBLIC_KEY_PATH }}"
|
||||||
|
VULNERABILITY_KEY_ID: "\${{ vars.VULNERABILITY_KEY_ID }}"
|
||||||
VULNERABILITY_PROVIDER_COMMAND: "\${{ vars.VULNERABILITY_PROVIDER_COMMAND }}"
|
VULNERABILITY_PROVIDER_COMMAND: "\${{ vars.VULNERABILITY_PROVIDER_COMMAND }}"
|
||||||
VULNERABILITY_REPORT_PATH: provider-evidence/untrusted/vulnerability-report.json
|
VULNERABILITY_REPORT_PATH: provider-evidence/untrusted/vulnerability-report.json
|
||||||
VALIDATED_PROVIDER_REPORT_PATH: provider-evidence/vulnerability-report.json
|
VALIDATED_PROVIDER_REPORT_PATH: provider-evidence/vulnerability-report.json
|
||||||
@@ -201,9 +207,8 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
name: "release-candidate-\${{ gitea.run_id }}-\${{ gitea.run_attempt }}"
|
name: "release-candidate-\${{ gitea.run_id }}-\${{ gitea.run_attempt }}"
|
||||||
path: .release/vulnerability-candidate
|
path: .release/vulnerability-candidate
|
||||||
- name: Verify and extract the candidate through one inode-bound operation
|
|
||||||
run: node scripts/verify-ci-candidate-archive.ts --archive ".release/vulnerability-candidate/release-candidate-\${{ gitea.run_id }}-\${{ gitea.run_attempt }}.tar.gz" --extract-to ".release/verified-vulnerability"
|
|
||||||
- name: Run and validate external vulnerability provider in one trusted supervisor
|
- name: Run and validate external vulnerability provider in one trusted supervisor
|
||||||
|
id: supervise_vulnerability
|
||||||
run: node scripts/run-and-validate-provider.ts --kind vulnerability
|
run: node scripts/run-and-validate-provider.ts --kind vulnerability
|
||||||
- name: Confirm sealed vulnerability provider evidence
|
- name: Confirm sealed vulnerability provider evidence
|
||||||
run: test -s "$VALIDATED_PROVIDER_REPORT_PATH"
|
run: test -s "$VALIDATED_PROVIDER_REPORT_PATH"
|
||||||
@@ -219,11 +224,16 @@ jobs:
|
|||||||
needs: immutable_build
|
needs: immutable_build
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 45
|
timeout-minutes: 45
|
||||||
|
outputs:
|
||||||
|
invocation_nonce: \${{ steps.supervise_provenance.outputs.invocation_nonce }}
|
||||||
env:
|
env:
|
||||||
CANDIDATE_ARCHIVE_SHA256: "\${{ needs.immutable_build.outputs.archive_sha256 }}"
|
CANDIDATE_ARCHIVE_SHA256: "\${{ needs.immutable_build.outputs.archive_sha256 }}"
|
||||||
CANDIDATE_ARCHIVE_PATH: ".release/provenance-candidate/release-candidate-\${{ gitea.run_id }}-\${{ gitea.run_attempt }}.tar.gz"
|
CANDIDATE_ARCHIVE_PATH: ".release/provenance-candidate/release-candidate-\${{ gitea.run_id }}-\${{ gitea.run_attempt }}.tar.gz"
|
||||||
CANDIDATE_DIST_SHA256: "\${{ needs.immutable_build.outputs.dist_sha256 }}"
|
CI_RUN_ID: "\${{ gitea.run_id }}"
|
||||||
CANDIDATE_LOCKFILE_PATH: .release/verified-provenance/pnpm-lock.yaml
|
CI_RUN_ATTEMPT: "\${{ gitea.run_attempt }}"
|
||||||
|
EXPECTED_SOURCE_REVISION: "\${{ gitea.sha }}"
|
||||||
|
PROVENANCE_PUBLIC_KEY_PATH: "\${{ vars.PROVENANCE_PUBLIC_KEY_PATH }}"
|
||||||
|
PROVENANCE_KEY_ID: "\${{ vars.PROVENANCE_KEY_ID }}"
|
||||||
PROVENANCE_PROVIDER_COMMAND: "\${{ vars.PROVENANCE_PROVIDER_COMMAND }}"
|
PROVENANCE_PROVIDER_COMMAND: "\${{ vars.PROVENANCE_PROVIDER_COMMAND }}"
|
||||||
PROVENANCE_ATTESTATION_PATH: provider-evidence/untrusted/provenance-attestation.json
|
PROVENANCE_ATTESTATION_PATH: provider-evidence/untrusted/provenance-attestation.json
|
||||||
VALIDATED_PROVIDER_REPORT_PATH: provider-evidence/provenance-attestation.json
|
VALIDATED_PROVIDER_REPORT_PATH: provider-evidence/provenance-attestation.json
|
||||||
@@ -243,9 +253,8 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
name: "release-candidate-\${{ gitea.run_id }}-\${{ gitea.run_attempt }}"
|
name: "release-candidate-\${{ gitea.run_id }}-\${{ gitea.run_attempt }}"
|
||||||
path: .release/provenance-candidate
|
path: .release/provenance-candidate
|
||||||
- name: Verify and extract the candidate through one inode-bound operation
|
|
||||||
run: node scripts/verify-ci-candidate-archive.ts --archive ".release/provenance-candidate/release-candidate-\${{ gitea.run_id }}-\${{ gitea.run_attempt }}.tar.gz" --extract-to ".release/verified-provenance"
|
|
||||||
- name: Run and validate external provenance provider in one trusted supervisor
|
- name: Run and validate external provenance provider in one trusted supervisor
|
||||||
|
id: supervise_provenance
|
||||||
run: node scripts/run-and-validate-provider.ts --kind provenance
|
run: node scripts/run-and-validate-provider.ts --kind provenance
|
||||||
- name: Confirm sealed provenance provider evidence
|
- name: Confirm sealed provenance provider evidence
|
||||||
run: test -s "$VALIDATED_PROVIDER_REPORT_PATH"
|
run: test -s "$VALIDATED_PROVIDER_REPORT_PATH"
|
||||||
@@ -264,13 +273,16 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
CANDIDATE_ARCHIVE_SHA256: "\${{ needs.immutable_build.outputs.archive_sha256 }}"
|
CANDIDATE_ARCHIVE_SHA256: "\${{ needs.immutable_build.outputs.archive_sha256 }}"
|
||||||
CANDIDATE_ARCHIVE_PATH: ".release/candidate/release-candidate-\${{ gitea.run_id }}-\${{ gitea.run_attempt }}.tar.gz"
|
CANDIDATE_ARCHIVE_PATH: ".release/candidate/release-candidate-\${{ gitea.run_id }}-\${{ gitea.run_attempt }}.tar.gz"
|
||||||
CANDIDATE_ROOT: "\${{ gitea.workspace }}/.release/verified-candidate"
|
CI_RUN_ID: "\${{ gitea.run_id }}"
|
||||||
|
CI_RUN_ATTEMPT: "\${{ gitea.run_attempt }}"
|
||||||
VULNERABILITY_REPORT_PATH: "\${{ gitea.workspace }}/.release/vulnerability/vulnerability-report.json"
|
VULNERABILITY_REPORT_PATH: "\${{ gitea.workspace }}/.release/vulnerability/vulnerability-report.json"
|
||||||
PROVENANCE_ATTESTATION_PATH: "\${{ gitea.workspace }}/.release/provenance/provenance-attestation.json"
|
PROVENANCE_ATTESTATION_PATH: "\${{ gitea.workspace }}/.release/provenance/provenance-attestation.json"
|
||||||
VULNERABILITY_PUBLIC_KEY_PATH: "\${{ vars.VULNERABILITY_PUBLIC_KEY_PATH }}"
|
VULNERABILITY_PUBLIC_KEY_PATH: "\${{ vars.VULNERABILITY_PUBLIC_KEY_PATH }}"
|
||||||
VULNERABILITY_KEY_ID: "\${{ vars.VULNERABILITY_KEY_ID }}"
|
VULNERABILITY_KEY_ID: "\${{ vars.VULNERABILITY_KEY_ID }}"
|
||||||
PROVENANCE_PUBLIC_KEY_PATH: "\${{ vars.PROVENANCE_PUBLIC_KEY_PATH }}"
|
PROVENANCE_PUBLIC_KEY_PATH: "\${{ vars.PROVENANCE_PUBLIC_KEY_PATH }}"
|
||||||
PROVENANCE_KEY_ID: "\${{ vars.PROVENANCE_KEY_ID }}"
|
PROVENANCE_KEY_ID: "\${{ vars.PROVENANCE_KEY_ID }}"
|
||||||
|
VULNERABILITY_INVOCATION_NONCE: "\${{ needs.vulnerability_provider.outputs.invocation_nonce }}"
|
||||||
|
PROVENANCE_INVOCATION_NONCE: "\${{ needs.provenance_provider.outputs.invocation_nonce }}"
|
||||||
steps:
|
steps:
|
||||||
- uses: https://github.com/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
- uses: https://github.com/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
||||||
with:
|
with:
|
||||||
@@ -297,21 +309,31 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
name: "provenance-provider-\${{ gitea.run_id }}-\${{ gitea.run_attempt }}"
|
name: "provenance-provider-\${{ gitea.run_id }}-\${{ gitea.run_attempt }}"
|
||||||
path: .release/provenance
|
path: .release/provenance
|
||||||
- name: Verify and extract the candidate through one inode-bound operation
|
|
||||||
run: node scripts/verify-ci-candidate-archive.ts --archive ".release/candidate/release-candidate-\${{ gitea.run_id }}-\${{ gitea.run_attempt }}.tar.gz" --extract-to ".release/verified-candidate"
|
|
||||||
- name: Finalize verified promotion from inode-bound captured inputs
|
- name: Finalize verified promotion from inode-bound captured inputs
|
||||||
|
id: finalize
|
||||||
run: node scripts/stage-verified-promotion.ts
|
run: node scripts/stage-verified-promotion.ts
|
||||||
- name: Upload promoted release
|
- name: Upload promoted release
|
||||||
uses: https://github.com/ChristopherHX/gitea-upload-artifact@81f940d004763f986ba3582c007fd842dd5cb0d7
|
uses: https://github.com/ChristopherHX/gitea-upload-artifact@81f940d004763f986ba3582c007fd842dd5cb0d7
|
||||||
with:
|
with:
|
||||||
name: "promoted-release-\${{ gitea.run_id }}-\${{ gitea.run_attempt }}"
|
name: "promoted-release-\${{ gitea.run_id }}-\${{ gitea.run_attempt }}"
|
||||||
path: |
|
path: |
|
||||||
.release/promoted-staging/release-candidate.tar.gz
|
\${{ steps.finalize.outputs.staging_root }}/release-candidate.tar.gz
|
||||||
.release/promoted-staging/vulnerability-report.json
|
\${{ steps.finalize.outputs.staging_root }}/vulnerability-report.json
|
||||||
.release/promoted-staging/provenance-attestation.json
|
\${{ steps.finalize.outputs.staging_root }}/provenance-attestation.json
|
||||||
.release/promoted-staging/provider-verification.json
|
\${{ steps.finalize.outputs.staging_root }}/provider-verification.json
|
||||||
.release/promoted-staging/promotion-verification.json
|
\${{ steps.finalize.outputs.staging_root }}/promotion-verification.json
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
- name: Always remove private promotion staging
|
||||||
|
if: always()
|
||||||
|
env:
|
||||||
|
PROMOTION_STAGING_ROOT: \${{ steps.finalize.outputs.staging_root }}
|
||||||
|
PROMOTION_CLEANUP_TOKEN: \${{ steps.finalize.outputs.cleanup_token }}
|
||||||
|
PROMOTION_RUNNER_TEMP_DEV: \${{ steps.finalize.outputs.runner_temp_dev }}
|
||||||
|
PROMOTION_RUNNER_TEMP_INO: \${{ steps.finalize.outputs.runner_temp_ino }}
|
||||||
|
run: |
|
||||||
|
if [ -n "$PROMOTION_STAGING_ROOT" ] && [ -n "$PROMOTION_CLEANUP_TOKEN" ]; then
|
||||||
|
node scripts/cleanup-verified-promotion.ts
|
||||||
|
fi
|
||||||
|
|
||||||
production_gate:
|
production_gate:
|
||||||
name: "\${{ matrix.gate }} / \${{ matrix.name }}"
|
name: "\${{ matrix.gate }} / \${{ matrix.name }}"
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -151,16 +151,19 @@ describe("CI gate contract", () => {
|
|||||||
["candidate output identity drift", (value: Record<string, any>) => { const job = value.jobs.find((candidate: any) => candidate.id === "immutable_build"); job.steps.find((step: any) => step.kind === "archive-candidate").archiveOutputName = "renamed"; }, /candidate output identity drift/i],
|
["candidate output identity drift", (value: Record<string, any>) => { const job = value.jobs.find((candidate: any) => candidate.id === "immutable_build"); job.steps.find((step: any) => step.kind === "archive-candidate").archiveOutputName = "renamed"; }, /candidate output identity drift/i],
|
||||||
["stage cycle", (value: Record<string, any>) => (value.stages[0].needs = ["release"]), /stage dependency cycle/i],
|
["stage cycle", (value: Record<string, any>) => (value.stages[0].needs = ["release"]), /stage dependency cycle/i],
|
||||||
["provider adapter target drift", (value: Record<string, any>) => (value.providerAdapter = "package.json"), /canonical generated workflow/i],
|
["provider adapter target drift", (value: Record<string, any>) => (value.providerAdapter = "package.json"), /canonical generated workflow/i],
|
||||||
["workflow root extraction", (value: Record<string, any>) => (value.jobs.find((candidate: any) => candidate.id === "promotion").steps.find((step: any) => step.kind === "extract").targetRoot = ".."), /unsafe workflow path/i],
|
["promotion standalone extraction", (value: Record<string, any>) => value.jobs.find((candidate: any) => candidate.id === "promotion").steps.splice(6, 0, { kind: "extract", archivePath: ".release/candidate/candidate.tar.gz", targetRoot: ".release/verified-candidate" }), /step kind extract is forbidden|job step sequence drift/i],
|
||||||
["normalized upload root", (value: Record<string, any>) => (value.jobs[0].steps.find((step: any) => step.kind === "upload").paths = ["foo/.."]), /unsafe workflow path/i],
|
["normalized upload root", (value: Record<string, any>) => (value.jobs[0].steps.find((step: any) => step.kind === "upload").paths = ["foo/.."]), /unsafe workflow path/i],
|
||||||
["immutable archive field drift", (value: Record<string, any>) => (value.jobs.find((candidate: any) => candidate.id === "immutable_build").steps.find((step: any) => step.kind === "archive-candidate").archivePath = ".release/other.tar.gz"), /candidate output identity drift|archive and upload fields must remain linked/i],
|
["immutable archive field drift", (value: Record<string, any>) => (value.jobs.find((candidate: any) => candidate.id === "immutable_build").steps.find((step: any) => step.kind === "archive-candidate").archivePath = ".release/other.tar.gz"), /candidate output identity drift|archive and upload fields must remain linked/i],
|
||||||
["provider role drift", (value: Record<string, any>) => (value.jobs.find((candidate: any) => candidate.id === "vulnerability_provider").steps.find((step: any) => step.kind === "run-provider").provider = "provenance"), /provider archive, extraction, evidence, and upload fields must remain linked/i],
|
["provider role drift", (value: Record<string, any>) => (value.jobs.find((candidate: any) => candidate.id === "vulnerability_provider").steps.find((step: any) => step.kind === "run-provider").provider = "provenance"), /provider archive, extraction, evidence, and upload fields must remain linked/i],
|
||||||
["provider archive SHA environment drift", (value: Record<string, any>) => (value.jobs.find((candidate: any) => candidate.id === "vulnerability_provider").environment.find((entry: any) => entry.name === "CANDIDATE_ARCHIVE_SHA256").value = "0".repeat(64)), /job environment binding drift/i],
|
["provider archive SHA environment drift", (value: Record<string, any>) => (value.jobs.find((candidate: any) => candidate.id === "vulnerability_provider").environment.find((entry: any) => entry.name === "CANDIDATE_ARCHIVE_SHA256").value = "0".repeat(64)), /job environment binding drift/i],
|
||||||
["promotion transfer swap", (value: Record<string, any>) => (value.jobs.find((candidate: any) => candidate.id === "promotion").steps.find((step: any) => step.kind === "download").transferId = "vulnerability-provider-evidence"), /promotion download and extraction fields must remain linked|duplicate.*download/i],
|
["promotion transfer swap", (value: Record<string, any>) => (value.jobs.find((candidate: any) => candidate.id === "promotion").steps.find((step: any) => step.kind === "download").transferId = "vulnerability-provider-evidence"), /promotion download fields.*remain linked|duplicate.*download/i],
|
||||||
["raw provider upload", (value: Record<string, any>) => (value.jobs.find((candidate: any) => candidate.id === "vulnerability_provider").steps.find((step: any) => step.kind === "upload").paths = ["provider-evidence/untrusted/vulnerability-report.json"]), /provider archive, extraction, evidence, and upload fields must remain linked/i],
|
["raw provider upload", (value: Record<string, any>) => (value.jobs.find((candidate: any) => candidate.id === "vulnerability_provider").steps.find((step: any) => step.kind === "upload").paths = ["provider-evidence/untrusted/vulnerability-report.json"]), /provider archive, extraction, evidence, and upload fields must remain linked/i],
|
||||||
["intervening promotion step", (value: Record<string, any>) => value.jobs.find((candidate: any) => candidate.id === "promotion").steps.splice(-1, 0, { kind: "frozen-install" }), /promotion verification and upload must be immediately adjacent/i],
|
["intervening promotion step", (value: Record<string, any>) => { const steps = value.jobs.find((candidate: any) => candidate.id === "promotion").steps; steps.splice(steps.findIndex((step: any) => step.kind === "upload"), 0, { kind: "frozen-install" }); }, /promotion verification and upload must be immediately adjacent/i],
|
||||||
["promotion upload path drift", (value: Record<string, any>) => (value.jobs.find((candidate: any) => candidate.id === "promotion").steps.at(-1).paths[0] = ".release/promoted-staging/replaced.tar.gz"), /exact five typed paths/i],
|
["promotion upload path drift", (value: Record<string, any>) => (value.jobs.find((candidate: any) => candidate.id === "promotion").steps.find((step: any) => step.kind === "upload").paths[0] = ".release/promoted-staging/replaced.tar.gz"), /exact five typed paths/i],
|
||||||
["always promotion upload", (value: Record<string, any>) => (value.jobs.find((candidate: any) => candidate.id === "promotion").steps.at(-1).always = true), /promotion upload must not use always/i],
|
["always promotion upload", (value: Record<string, any>) => (value.jobs.find((candidate: any) => candidate.id === "promotion").steps.find((step: any) => step.kind === "upload").always = true), /promotion upload must not use always/i],
|
||||||
|
["provider nonce output step drift", (value: Record<string, any>) => (value.jobs.find((candidate: any) => candidate.id === "vulnerability_provider").steps.find((step: any) => step.kind === "run-provider").stepId = "renamed"), /provider.*linked|step identity/i],
|
||||||
|
["promotion nonce binding drift", (value: Record<string, any>) => (value.jobs.find((candidate: any) => candidate.id === "promotion").environment.find((entry: any) => entry.name === "VULNERABILITY_INVOCATION_NONCE").value = "5".repeat(64)), /job environment binding drift/i],
|
||||||
|
["missing promotion cleanup", (value: Record<string, any>) => { const job = value.jobs.find((candidate: any) => candidate.id === "promotion"); job.steps = job.steps.filter((step: any) => step.kind !== "cleanup-promotion"); }, /job step sequence drift|cleanup/i],
|
||||||
])("rejects semantic mutation: %s", async (_name, mutate, diagnostic) => {
|
])("rejects semantic mutation: %s", async (_name, mutate, diagnostic) => {
|
||||||
const contract = await loadCiGateContract(process.cwd());
|
const contract = await loadCiGateContract(process.cwd());
|
||||||
const candidate = JSON.parse(JSON.stringify(contract)) as Record<string, any>;
|
const candidate = JSON.parse(JSON.stringify(contract)) as Record<string, any>;
|
||||||
@@ -448,10 +451,25 @@ describe("CI workflow generation", () => {
|
|||||||
).toHaveLength(9);
|
).toHaveLength(9);
|
||||||
expect(first).not.toMatch(/corepack pnpm install --frozen-lockfile$/mu);
|
expect(first).not.toMatch(/corepack pnpm install --frozen-lockfile$/mu);
|
||||||
expect(first).toContain("verify-ci-candidate-archive.ts --archive");
|
expect(first).toContain("verify-ci-candidate-archive.ts --archive");
|
||||||
expect(first).toContain("--extract-to");
|
expect(first).not.toContain("--extract-to");
|
||||||
expect(first).not.toMatch(/\btar\s+[^\n]*--extract/u);
|
expect(first).not.toMatch(/\btar\s+[^\n]*--extract/u);
|
||||||
expect(first).toContain("node scripts/stage-verified-promotion.ts");
|
expect(first).toContain("node scripts/stage-verified-promotion.ts");
|
||||||
expect(first).toContain(".release/promoted-staging/release-candidate.tar.gz");
|
expect(first).toContain("outputs:\n invocation_nonce: ${{ steps.supervise_vulnerability.outputs.invocation_nonce }}");
|
||||||
|
expect(first).toContain("outputs:\n invocation_nonce: ${{ steps.supervise_provenance.outputs.invocation_nonce }}");
|
||||||
|
expect(first).toContain('VULNERABILITY_INVOCATION_NONCE: "${{ needs.vulnerability_provider.outputs.invocation_nonce }}"');
|
||||||
|
expect(first).toContain('PROVENANCE_INVOCATION_NONCE: "${{ needs.provenance_provider.outputs.invocation_nonce }}"');
|
||||||
|
expect(first).toContain("${{ steps.finalize.outputs.staging_root }}/release-candidate.tar.gz");
|
||||||
|
expect(first).not.toContain(".release/promoted-staging");
|
||||||
|
const finalizerIndex = first.indexOf("node scripts/stage-verified-promotion.ts");
|
||||||
|
const promotedUploadIndex = first.indexOf("Upload promoted release");
|
||||||
|
const cleanupStepIndex = first.indexOf("- name: Always remove private promotion staging");
|
||||||
|
const cleanupIndex = first.indexOf("node scripts/cleanup-verified-promotion.ts");
|
||||||
|
expect(finalizerIndex).toBeGreaterThan(0);
|
||||||
|
expect(promotedUploadIndex).toBeGreaterThan(finalizerIndex);
|
||||||
|
expect(cleanupStepIndex).toBeGreaterThan(promotedUploadIndex);
|
||||||
|
expect(cleanupIndex).toBeGreaterThan(cleanupStepIndex);
|
||||||
|
expect(first.slice(promotedUploadIndex, cleanupStepIndex)).not.toContain("if: always()");
|
||||||
|
expect(first.slice(cleanupStepIndex, cleanupIndex)).toContain("if: always()");
|
||||||
const actionUses = [...first.matchAll(/^\s+-?\s*uses: (.+)$/gmu)].map((match) => match[1]);
|
const actionUses = [...first.matchAll(/^\s+-?\s*uses: (.+)$/gmu)].map((match) => match[1]);
|
||||||
expect(actionUses).toHaveLength(32);
|
expect(actionUses).toHaveLength(32);
|
||||||
expect(new Set(actionUses)).toEqual(
|
expect(new Set(actionUses)).toEqual(
|
||||||
|
|||||||
@@ -0,0 +1,863 @@
|
|||||||
|
import {
|
||||||
|
createHash,
|
||||||
|
generateKeyPairSync,
|
||||||
|
sign,
|
||||||
|
type KeyObject,
|
||||||
|
} from "node:crypto";
|
||||||
|
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { localEvidenceAssessmentArtifactSchema } from "../../scripts/contracts/release-artifacts.ts";
|
||||||
|
import { verifyArchivedLocalEvidence } from "../../scripts/lib/local-release-evidence.ts";
|
||||||
|
import {
|
||||||
|
evaluatePromotionEvidence,
|
||||||
|
providerEvidenceSignaturePayload,
|
||||||
|
providerPublicKeyFingerprint,
|
||||||
|
} from "../../scripts/lib/provider-evidence.ts";
|
||||||
|
import { readProviderTrust } from "../../scripts/lib/promotion-verifier.ts";
|
||||||
|
import { superviseProviderEvidence } from "../../scripts/lib/provider-supervisor.ts";
|
||||||
|
import {
|
||||||
|
LOCAL_EVIDENCE_ASSESSMENT_PATH,
|
||||||
|
distSha256,
|
||||||
|
type ReleaseCandidateManifest,
|
||||||
|
} from "../../scripts/lib/release-candidate.ts";
|
||||||
|
import { supplyChainDigest } from "../../scripts/lib/supply-chain.ts";
|
||||||
|
|
||||||
|
const digest = (value: string): string =>
|
||||||
|
createHash("sha256").update(value).digest("hex");
|
||||||
|
const digestBytes = (value: Buffer): string =>
|
||||||
|
createHash("sha256").update(value).digest("hex");
|
||||||
|
|
||||||
|
function passingAssessment(): any {
|
||||||
|
return {
|
||||||
|
schemaVersion: 1 as const,
|
||||||
|
artifactType: "local-evidence-assessment" as const,
|
||||||
|
generatedAt: "2026-08-02T00:00:00.000Z",
|
||||||
|
status: "PASS" as const,
|
||||||
|
verifier: {
|
||||||
|
id: "clean-architecture-frontend-template/local-evidence-verifier",
|
||||||
|
version: "1",
|
||||||
|
sourceSha256: digest("verifier source"),
|
||||||
|
},
|
||||||
|
source: {
|
||||||
|
revision: "a".repeat(40),
|
||||||
|
sourceSetSha256: digest("source set"),
|
||||||
|
},
|
||||||
|
candidate: {
|
||||||
|
distSha256: digest("dist"),
|
||||||
|
lockfileSha256: digest("lockfile"),
|
||||||
|
sbomSha256: digest("sbom"),
|
||||||
|
},
|
||||||
|
policyInputs: [
|
||||||
|
{
|
||||||
|
path: "config/security/dependency-policy.json",
|
||||||
|
bytes: 3,
|
||||||
|
sha256: digest("{}\n"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
evidenceInputs: [
|
||||||
|
{ path: "pnpm-lock.yaml", bytes: 9, sha256: digest("lockfile\n") },
|
||||||
|
],
|
||||||
|
checks: {
|
||||||
|
release: "PASS" as const,
|
||||||
|
supplyChain: "PASS" as const,
|
||||||
|
dependencyPolicy: "PASS" as const,
|
||||||
|
licensePolicy: "PASS" as const,
|
||||||
|
vulnerabilityPolicy: "PASS" as const,
|
||||||
|
secretScan: "PASS" as const,
|
||||||
|
},
|
||||||
|
failures: [] as string[],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("security follow-up contracts", () => {
|
||||||
|
it("rejects a PASS local assessment with a failed check or failure diagnostic", () => {
|
||||||
|
const failedCheck = passingAssessment();
|
||||||
|
failedCheck.checks.secretScan = "FAIL";
|
||||||
|
const failureDiagnostic = passingAssessment();
|
||||||
|
failureDiagnostic.failures.push("secret scan failed");
|
||||||
|
|
||||||
|
expect(localEvidenceAssessmentArtifactSchema.safeParse(failedCheck).success).toBe(false);
|
||||||
|
expect(localEvidenceAssessmentArtifactSchema.safeParse(failureDiagnostic).success).toBe(false);
|
||||||
|
expect(localEvidenceAssessmentArtifactSchema.parse(passingAssessment()).status).toBe("PASS");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes archived verification from extracted members without checkout source or policy paths", async () => {
|
||||||
|
const fixture = await createArchivedAssessmentFixture();
|
||||||
|
try {
|
||||||
|
const result = await verifyArchivedLocalEvidence({
|
||||||
|
extractionRoot: fixture.root,
|
||||||
|
expectedManifest: fixture.manifest,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
status: "PASS",
|
||||||
|
identity: {
|
||||||
|
sourceRevision: "a".repeat(40),
|
||||||
|
sourceSetSha256: digest("source set"),
|
||||||
|
assessmentSha256: fixture.assessmentSha256,
|
||||||
|
},
|
||||||
|
failures: [],
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await rm(fixture.root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects archived verification when the assessment is absent", async () => {
|
||||||
|
const fixture = await createArchivedAssessmentFixture();
|
||||||
|
try {
|
||||||
|
await rm(path.join(fixture.root, LOCAL_EVIDENCE_ASSESSMENT_PATH));
|
||||||
|
const result = await verifyArchivedLocalEvidence({
|
||||||
|
extractionRoot: fixture.root,
|
||||||
|
expectedManifest: fixture.manifest,
|
||||||
|
});
|
||||||
|
expect(result.status).toBe("FAIL");
|
||||||
|
expect(result.failures).toContain("local evidence assessment is missing or invalid");
|
||||||
|
} finally {
|
||||||
|
await rm(fixture.root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects digest-bound raw identity evidence that is not valid under its strict producer schema", async () => {
|
||||||
|
const fixture = await createArchivedAssessmentFixture();
|
||||||
|
try {
|
||||||
|
const provenancePath = "artifacts/release/provenance.json";
|
||||||
|
const malformed = Buffer.from(
|
||||||
|
`${JSON.stringify({ predicate: { materials: { sourceSetSha256: digest("source set") } } })}\n`,
|
||||||
|
);
|
||||||
|
await writeFile(path.join(fixture.root, provenancePath), malformed);
|
||||||
|
const assessmentPath = path.join(fixture.root, LOCAL_EVIDENCE_ASSESSMENT_PATH);
|
||||||
|
const assessment = localEvidenceAssessmentArtifactSchema.parse(
|
||||||
|
JSON.parse(await readFile(assessmentPath, "utf8")) as unknown,
|
||||||
|
);
|
||||||
|
assessment.evidenceInputs = assessment.evidenceInputs.map((row) =>
|
||||||
|
row.path === provenancePath
|
||||||
|
? { path: provenancePath, bytes: malformed.byteLength, sha256: digestBytes(malformed) }
|
||||||
|
: row,
|
||||||
|
);
|
||||||
|
const assessmentBytes = Buffer.from(`${JSON.stringify(assessment)}\n`);
|
||||||
|
await writeFile(assessmentPath, assessmentBytes);
|
||||||
|
const files = fixture.manifest.files.map((row) => {
|
||||||
|
if (row.path === provenancePath) {
|
||||||
|
return { path: provenancePath, bytes: malformed.byteLength, sha256: digestBytes(malformed) };
|
||||||
|
}
|
||||||
|
if (row.path === LOCAL_EVIDENCE_ASSESSMENT_PATH) {
|
||||||
|
return {
|
||||||
|
path: LOCAL_EVIDENCE_ASSESSMENT_PATH,
|
||||||
|
bytes: assessmentBytes.byteLength,
|
||||||
|
sha256: digestBytes(assessmentBytes),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return row;
|
||||||
|
});
|
||||||
|
const manifest = { ...fixture.manifest, files, bundleSha256: supplyChainDigest(files) };
|
||||||
|
await writeFile(
|
||||||
|
path.join(fixture.root, "artifacts/release/release-candidate.json"),
|
||||||
|
`${JSON.stringify(manifest)}\n`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await verifyArchivedLocalEvidence({
|
||||||
|
extractionRoot: fixture.root,
|
||||||
|
expectedManifest: manifest,
|
||||||
|
});
|
||||||
|
expect(result.status).toBe("FAIL");
|
||||||
|
expect(result.failures).toContain(
|
||||||
|
"archived source/build/provenance identities are missing or invalid",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await rm(fixture.root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts signed provider v2 evidence only for the exact run, source, archive, and nonce", () => {
|
||||||
|
const now = Date.parse("2026-08-02T01:00:00.000Z");
|
||||||
|
const vulnerabilityKeys = generateKeyPairSync("ed25519");
|
||||||
|
const provenanceKeys = generateKeyPairSync("ed25519");
|
||||||
|
const expected = providerExpectedContext();
|
||||||
|
const vulnerability = signedProviderV2(
|
||||||
|
{
|
||||||
|
...expected,
|
||||||
|
schemaVersion: 2,
|
||||||
|
evidenceType: "vulnerability-report",
|
||||||
|
provider: "fixture-vulnerability",
|
||||||
|
issuedAt: "2026-08-02T01:00:00.000Z",
|
||||||
|
expiresAt: "2026-08-02T02:00:00.000Z",
|
||||||
|
run: { ...expected.run, invocationNonce: "1".repeat(64) },
|
||||||
|
findings: [],
|
||||||
|
},
|
||||||
|
"vulnerability-key",
|
||||||
|
vulnerabilityKeys.publicKey,
|
||||||
|
vulnerabilityKeys.privateKey,
|
||||||
|
);
|
||||||
|
const provenance = signedProviderV2(
|
||||||
|
{
|
||||||
|
...expected,
|
||||||
|
schemaVersion: 2,
|
||||||
|
evidenceType: "provenance-attestation",
|
||||||
|
provider: "fixture-provenance",
|
||||||
|
signer: "fixture-workload",
|
||||||
|
issuedAt: "2026-08-02T01:00:00.000Z",
|
||||||
|
expiresAt: "2026-08-02T02:00:00.000Z",
|
||||||
|
run: { ...expected.run, invocationNonce: "2".repeat(64) },
|
||||||
|
subject: { name: "dist", digest: { sha256: expected.candidate.distSha256 } },
|
||||||
|
},
|
||||||
|
"provenance-key",
|
||||||
|
provenanceKeys.publicKey,
|
||||||
|
provenanceKeys.privateKey,
|
||||||
|
);
|
||||||
|
const result = evaluatePromotionEvidence({
|
||||||
|
expected: {
|
||||||
|
...expected,
|
||||||
|
vulnerabilityInvocationNonce: "1".repeat(64),
|
||||||
|
provenanceInvocationNonce: "2".repeat(64),
|
||||||
|
},
|
||||||
|
localStatus: "PASS",
|
||||||
|
vulnerabilityReport: vulnerability,
|
||||||
|
provenanceAttestation: provenance,
|
||||||
|
vulnerabilityTrust: trust("vulnerability-key", vulnerabilityKeys.publicKey),
|
||||||
|
provenanceTrust: trust("provenance-key", provenanceKeys.publicKey),
|
||||||
|
nowEpochMs: () => now,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
status: "PASS",
|
||||||
|
vulnerabilityStatus: "PASS",
|
||||||
|
provenanceAttestationStatus: "PASS",
|
||||||
|
failures: [],
|
||||||
|
});
|
||||||
|
const replayed = evaluatePromotionEvidence({
|
||||||
|
expected: {
|
||||||
|
...expected,
|
||||||
|
run: { id: expected.run.id, attempt: 2 },
|
||||||
|
vulnerabilityInvocationNonce: "1".repeat(64),
|
||||||
|
provenanceInvocationNonce: "2".repeat(64),
|
||||||
|
},
|
||||||
|
localStatus: "PASS",
|
||||||
|
vulnerabilityReport: vulnerability,
|
||||||
|
provenanceAttestation: provenance,
|
||||||
|
vulnerabilityTrust: trust("vulnerability-key", vulnerabilityKeys.publicKey),
|
||||||
|
provenanceTrust: trust("provenance-key", provenanceKeys.publicKey),
|
||||||
|
nowEpochMs: () => now,
|
||||||
|
});
|
||||||
|
expect(replayed.status).toBe("FAIL_UNVERIFIED");
|
||||||
|
expect(replayed.failures).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
"vulnerability report run identity mismatch",
|
||||||
|
"provenance attestation run identity mismatch",
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["vulnerability", "provenance"] as const)(
|
||||||
|
"rejects correctly re-signed %s v2 context/time/replay drift",
|
||||||
|
(kind) => {
|
||||||
|
const now = Date.parse("2026-08-02T01:00:00.000Z");
|
||||||
|
const vulnerabilityKeys = generateKeyPairSync("ed25519");
|
||||||
|
const provenanceKeys = generateKeyPairSync("ed25519");
|
||||||
|
const expected = providerExpectedContext();
|
||||||
|
const baseVulnerability = providerUnsigned("vulnerability", expected);
|
||||||
|
const baseProvenance = providerUnsigned("provenance", expected);
|
||||||
|
const validVulnerability = signedProviderV2(
|
||||||
|
baseVulnerability,
|
||||||
|
"vulnerability-key",
|
||||||
|
vulnerabilityKeys.publicKey,
|
||||||
|
vulnerabilityKeys.privateKey,
|
||||||
|
);
|
||||||
|
const validProvenance = signedProviderV2(
|
||||||
|
baseProvenance,
|
||||||
|
"provenance-key",
|
||||||
|
provenanceKeys.publicKey,
|
||||||
|
provenanceKeys.privateKey,
|
||||||
|
);
|
||||||
|
const rawCases: Array<readonly [
|
||||||
|
string,
|
||||||
|
(value: Record<string, any>) => Record<string, any>,
|
||||||
|
RegExp,
|
||||||
|
]> = [
|
||||||
|
["schema v1", (value) => ({ ...value, schemaVersion: 1 }), /missing or invalid/u],
|
||||||
|
[
|
||||||
|
"evidence type",
|
||||||
|
(value) => ({
|
||||||
|
...value,
|
||||||
|
evidenceType:
|
||||||
|
kind === "vulnerability"
|
||||||
|
? "provenance-attestation"
|
||||||
|
: "vulnerability-report",
|
||||||
|
}),
|
||||||
|
/missing or invalid/u,
|
||||||
|
],
|
||||||
|
...(["archiveSha256", "bundleSha256", "distSha256", "lockfileSha256"] as const).map(
|
||||||
|
(field) => [
|
||||||
|
`candidate ${field}`,
|
||||||
|
(value: Record<string, any>) => ({
|
||||||
|
...value,
|
||||||
|
candidate: { ...value.candidate, [field]: "f".repeat(64) },
|
||||||
|
...(kind === "provenance" && field === "distSha256"
|
||||||
|
? {
|
||||||
|
subject: {
|
||||||
|
name: "dist",
|
||||||
|
digest: { sha256: "f".repeat(64) },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
}),
|
||||||
|
/candidate identity|subject dist/u,
|
||||||
|
] as const,
|
||||||
|
),
|
||||||
|
[
|
||||||
|
"different archive with same dist and lockfile",
|
||||||
|
(value) => ({
|
||||||
|
...value,
|
||||||
|
candidate: { ...value.candidate, archiveSha256: "e".repeat(64) },
|
||||||
|
}),
|
||||||
|
/candidate identity/u,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"source revision",
|
||||||
|
(value) => ({ ...value, source: { ...value.source, revision: "c".repeat(40) } }),
|
||||||
|
/source identity/u,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"source set",
|
||||||
|
(value) => ({ ...value, source: { ...value.source, sourceSetSha256: "c".repeat(64) } }),
|
||||||
|
/source identity/u,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"run id",
|
||||||
|
(value) => ({ ...value, run: { ...value.run, id: "other-run" } }),
|
||||||
|
/run identity/u,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"run attempt replay",
|
||||||
|
(value) => ({ ...value, run: { ...value.run, attempt: 2 } }),
|
||||||
|
/run identity/u,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"different nonce",
|
||||||
|
(value) => ({ ...value, run: { ...value.run, invocationNonce: "3".repeat(64) } }),
|
||||||
|
/invocation nonce/u,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"missing nonce",
|
||||||
|
(value) => {
|
||||||
|
const run = { ...value.run };
|
||||||
|
delete run.invocationNonce;
|
||||||
|
return { ...value, run };
|
||||||
|
},
|
||||||
|
/missing or invalid/u,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"uppercase nonce",
|
||||||
|
(value) => ({ ...value, run: { ...value.run, invocationNonce: "A".repeat(64) } }),
|
||||||
|
/missing or invalid/u,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"short nonce",
|
||||||
|
(value) => ({ ...value, run: { ...value.run, invocationNonce: "1".repeat(62) } }),
|
||||||
|
/missing or invalid/u,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"issued future boundary",
|
||||||
|
(value) => ({ ...value, issuedAt: "2026-08-02T01:05:00.001Z" }),
|
||||||
|
/future skew/u,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"expiry equality",
|
||||||
|
(value) => ({ ...value, expiresAt: "2026-08-02T01:00:00.000Z" }),
|
||||||
|
/expired/u,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"expiry past",
|
||||||
|
(value) => ({ ...value, expiresAt: "2026-08-02T00:59:59.999Z" }),
|
||||||
|
/expired/u,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"zero lifetime",
|
||||||
|
(value) => ({
|
||||||
|
...value,
|
||||||
|
issuedAt: "2026-08-02T01:01:00.000Z",
|
||||||
|
expiresAt: "2026-08-02T01:01:00.000Z",
|
||||||
|
}),
|
||||||
|
/not positive/u,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"negative lifetime",
|
||||||
|
(value) => ({
|
||||||
|
...value,
|
||||||
|
issuedAt: "2026-08-02T01:02:00.000Z",
|
||||||
|
expiresAt: "2026-08-02T01:01:59.999Z",
|
||||||
|
}),
|
||||||
|
/not positive/u,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"lifetime above two hours",
|
||||||
|
(value) => ({
|
||||||
|
...value,
|
||||||
|
issuedAt: "2026-08-02T01:00:00.000Z",
|
||||||
|
expiresAt: "2026-08-02T03:00:00.001Z",
|
||||||
|
}),
|
||||||
|
/exceeds two hours/u,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"wrong fingerprint",
|
||||||
|
(value) => ({
|
||||||
|
...value,
|
||||||
|
signature: {
|
||||||
|
...value.signature,
|
||||||
|
publicKeyFingerprint: `sha256:${"d".repeat(64)}`,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
/trust identity/u,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
const cases = rawCases.map(([name, mutate, failure]) => ({
|
||||||
|
name,
|
||||||
|
mutate,
|
||||||
|
failure,
|
||||||
|
}));
|
||||||
|
|
||||||
|
for (const testCase of cases) {
|
||||||
|
const base = kind === "vulnerability" ? baseVulnerability : baseProvenance;
|
||||||
|
const mutated = testCase.mutate(structuredClone(base));
|
||||||
|
const resigned = signedProviderV2(
|
||||||
|
mutated,
|
||||||
|
kind === "vulnerability" ? "vulnerability-key" : "provenance-key",
|
||||||
|
kind === "vulnerability" ? vulnerabilityKeys.publicKey : provenanceKeys.publicKey,
|
||||||
|
kind === "vulnerability" ? vulnerabilityKeys.privateKey : provenanceKeys.privateKey,
|
||||||
|
"signature" in mutated && mutated.signature?.publicKeyFingerprint
|
||||||
|
? mutated.signature.publicKeyFingerprint
|
||||||
|
: undefined,
|
||||||
|
);
|
||||||
|
const result = evaluatePromotionEvidence({
|
||||||
|
expected: {
|
||||||
|
...expected,
|
||||||
|
vulnerabilityInvocationNonce: "1".repeat(64),
|
||||||
|
provenanceInvocationNonce: "2".repeat(64),
|
||||||
|
},
|
||||||
|
localStatus: "PASS",
|
||||||
|
vulnerabilityReport:
|
||||||
|
kind === "vulnerability" ? resigned : validVulnerability,
|
||||||
|
provenanceAttestation:
|
||||||
|
kind === "provenance" ? resigned : validProvenance,
|
||||||
|
vulnerabilityTrust: trust("vulnerability-key", vulnerabilityKeys.publicKey),
|
||||||
|
provenanceTrust: trust("provenance-key", provenanceKeys.publicKey),
|
||||||
|
nowEpochMs: () => now,
|
||||||
|
});
|
||||||
|
expect(result.status, testCase.name).toBe("FAIL_UNVERIFIED");
|
||||||
|
expect(result.failures.join("\n"), testCase.name).toMatch(testCase.failure);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("canonicalizes provider fingerprints from DER SPKI across PEM wrapping and rejects Ed448", async () => {
|
||||||
|
const root = await mkdtemp(path.join(tmpdir(), "provider-fingerprint-"));
|
||||||
|
try {
|
||||||
|
const ed25519 = generateKeyPairSync("ed25519").publicKey;
|
||||||
|
const pem = ed25519.export({ type: "spki", format: "pem" }).toString();
|
||||||
|
const body = pem.replace(/-----[^-]+-----|\s/gu, "");
|
||||||
|
const wrapped = (width: number) =>
|
||||||
|
`-----BEGIN PUBLIC KEY-----\n${body.match(new RegExp(`.{1,${width}}`, "gu"))!.join("\n")}\n-----END PUBLIC KEY-----\n`;
|
||||||
|
await writeFile(path.join(root, "a.pem"), wrapped(64));
|
||||||
|
await writeFile(path.join(root, "b.pem"), wrapped(32));
|
||||||
|
const first = await readProviderTrust(root, "a.pem", "fixture-key");
|
||||||
|
const second = await readProviderTrust(root, "b.pem", "fixture-key");
|
||||||
|
expect(first?.publicKeyFingerprint).toBe(providerPublicKeyFingerprint(ed25519));
|
||||||
|
expect(second?.publicKeyFingerprint).toBe(first?.publicKeyFingerprint);
|
||||||
|
|
||||||
|
const ed448 = generateKeyPairSync("ed448").publicKey;
|
||||||
|
await writeFile(root + "/ed448.pem", ed448.export({ type: "spki", format: "pem" }));
|
||||||
|
await expect(readProviderTrust(root, "ed448.pem", "fixture-key")).resolves.toBeNull();
|
||||||
|
expect(() => providerPublicKeyFingerprint(ed448)).toThrow(/must be Ed25519/u);
|
||||||
|
} finally {
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("captures the downloaded archive pathname exactly once in the provider supervisor", async () => {
|
||||||
|
const keys = generateKeyPairSync("ed25519");
|
||||||
|
const expected = providerExpectedContext();
|
||||||
|
let captureCount = 0;
|
||||||
|
let receivedEnvironment: Readonly<Record<string, string>> | undefined;
|
||||||
|
const manifest: ReleaseCandidateManifest = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
distSha256: expected.candidate.distSha256,
|
||||||
|
lockfileSha256: expected.candidate.lockfileSha256,
|
||||||
|
bundleSha256: expected.candidate.bundleSha256,
|
||||||
|
files: [{ path: "pnpm-lock.yaml", bytes: 1, sha256: expected.candidate.lockfileSha256 }],
|
||||||
|
};
|
||||||
|
const result = await superviseProviderEvidence(
|
||||||
|
{
|
||||||
|
kind: "vulnerability",
|
||||||
|
archivePath: "/downloads/candidate.tar.gz",
|
||||||
|
expectedArchiveSha256: expected.candidate.archiveSha256,
|
||||||
|
expectedRun: {
|
||||||
|
id: expected.run.id,
|
||||||
|
attempt: expected.run.attempt,
|
||||||
|
sourceRevision: expected.source.revision,
|
||||||
|
},
|
||||||
|
trust: trust("vulnerability-key", keys.publicKey),
|
||||||
|
executeProvider: async ({ environment }) => {
|
||||||
|
receivedEnvironment = environment;
|
||||||
|
},
|
||||||
|
captureReport: async () => Buffer.from("{}\n"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
captureArchive: async (input) => {
|
||||||
|
captureCount += 1;
|
||||||
|
expect(input).toEqual({
|
||||||
|
archivePath: "/downloads/candidate.tar.gz",
|
||||||
|
expectedSha256: expected.candidate.archiveSha256,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
bytes: Buffer.from("captured archive"),
|
||||||
|
archiveSha256: expected.candidate.archiveSha256,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
withVerifiedCandidate: (async (input: any) =>
|
||||||
|
input.verify({ extractionRoot: "/captured/extraction", manifest })) as any,
|
||||||
|
verifyLocalEvidence: async () => ({
|
||||||
|
status: "PASS",
|
||||||
|
identity: {
|
||||||
|
sourceRevision: expected.source.revision,
|
||||||
|
sourceSetSha256: expected.source.sourceSetSha256,
|
||||||
|
assessmentSha256: digest("assessment"),
|
||||||
|
},
|
||||||
|
failures: [],
|
||||||
|
}),
|
||||||
|
validateUpload: (async (input: any) => {
|
||||||
|
expect("archivePath" in input).toBe(false);
|
||||||
|
return { sealed: true };
|
||||||
|
}) as any,
|
||||||
|
randomBytes: () => Buffer.alloc(32, 0x11),
|
||||||
|
nowEpochMs: () => Date.parse("2026-08-02T01:00:00.000Z"),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(captureCount).toBe(1);
|
||||||
|
expect(receivedEnvironment).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
PROVIDER_EVIDENCE_SCHEMA_VERSION: "2",
|
||||||
|
PROVIDER_INVOCATION_NONCE: "11".repeat(32),
|
||||||
|
PROVIDER_ISSUED_AT: "2026-08-02T01:00:00.000Z",
|
||||||
|
PROVIDER_EXPIRES_AT: "2026-08-02T02:00:00.000Z",
|
||||||
|
CI_RUN_ID: expected.run.id,
|
||||||
|
CI_RUN_ATTEMPT: "1",
|
||||||
|
SOURCE_REVISION: expected.source.revision,
|
||||||
|
CANDIDATE_ARCHIVE_SHA256: expected.candidate.archiveSha256,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(result.evidence).toEqual({ sealed: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function providerExpectedContext() {
|
||||||
|
return {
|
||||||
|
run: { id: "run-42", attempt: 1 },
|
||||||
|
source: { revision: "b".repeat(40), sourceSetSha256: digest("provider source") },
|
||||||
|
candidate: {
|
||||||
|
archiveSha256: digest("archive"),
|
||||||
|
bundleSha256: digest("bundle"),
|
||||||
|
distSha256: digest("provider dist"),
|
||||||
|
lockfileSha256: digest("provider lockfile"),
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fingerprint(publicKey: KeyObject): string {
|
||||||
|
return `sha256:${createHash("sha256")
|
||||||
|
.update(publicKey.export({ type: "spki", format: "der" }))
|
||||||
|
.digest("hex")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function trust(keyId: string, publicKey: KeyObject) {
|
||||||
|
return { keyId, publicKey, publicKeyFingerprint: fingerprint(publicKey) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function signedProviderV2(
|
||||||
|
unsigned: Record<string, unknown>,
|
||||||
|
keyId: string,
|
||||||
|
publicKey: KeyObject,
|
||||||
|
privateKey: KeyObject,
|
||||||
|
fingerprintOverride?: string,
|
||||||
|
) {
|
||||||
|
const { signature: existingSignature, ...payload } = unsigned;
|
||||||
|
const value = {
|
||||||
|
...payload,
|
||||||
|
signature: {
|
||||||
|
algorithm: "Ed25519" as const,
|
||||||
|
keyId,
|
||||||
|
publicKeyFingerprint:
|
||||||
|
fingerprintOverride ??
|
||||||
|
(existingSignature && typeof existingSignature === "object" &&
|
||||||
|
"publicKeyFingerprint" in existingSignature
|
||||||
|
? String(existingSignature.publicKeyFingerprint)
|
||||||
|
: fingerprint(publicKey)),
|
||||||
|
value: "",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
value.signature.value = sign(
|
||||||
|
null,
|
||||||
|
providerEvidenceSignaturePayload(value),
|
||||||
|
privateKey,
|
||||||
|
).toString("base64");
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function providerUnsigned(
|
||||||
|
kind: "vulnerability" | "provenance",
|
||||||
|
expected: ReturnType<typeof providerExpectedContext>,
|
||||||
|
): Record<string, any> {
|
||||||
|
const common = {
|
||||||
|
...expected,
|
||||||
|
schemaVersion: 2,
|
||||||
|
evidenceType:
|
||||||
|
kind === "vulnerability"
|
||||||
|
? "vulnerability-report"
|
||||||
|
: "provenance-attestation",
|
||||||
|
provider: `fixture-${kind}`,
|
||||||
|
issuedAt: "2026-08-02T01:00:00.000Z",
|
||||||
|
expiresAt: "2026-08-02T02:00:00.000Z",
|
||||||
|
run: {
|
||||||
|
...expected.run,
|
||||||
|
invocationNonce: kind === "vulnerability" ? "1".repeat(64) : "2".repeat(64),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return kind === "vulnerability"
|
||||||
|
? { ...common, findings: [] }
|
||||||
|
: {
|
||||||
|
...common,
|
||||||
|
signer: "fixture-workload",
|
||||||
|
subject: {
|
||||||
|
name: "dist",
|
||||||
|
digest: { sha256: expected.candidate.distSha256 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createArchivedAssessmentFixture(): Promise<{
|
||||||
|
root: string;
|
||||||
|
manifest: ReleaseCandidateManifest;
|
||||||
|
assessmentSha256: string;
|
||||||
|
}> {
|
||||||
|
const root = await mkdtemp(path.join(tmpdir(), "archived-assessment-"));
|
||||||
|
const sourceRevision = "a".repeat(40);
|
||||||
|
const sourceSetSha256 = digest("source set");
|
||||||
|
const releaseManifestBytes = Buffer.from(
|
||||||
|
`${JSON.stringify({
|
||||||
|
schemaVersion: 1,
|
||||||
|
appVersion: "1.0.0",
|
||||||
|
buildId: "build-1",
|
||||||
|
commitSha: sourceRevision,
|
||||||
|
configSchemaVersion: "1",
|
||||||
|
apiContractVersion: "1",
|
||||||
|
assetManifestHash: digest("vite manifest"),
|
||||||
|
releaseId: "release-1",
|
||||||
|
builtAt: "2026-08-02T00:00:00.000Z",
|
||||||
|
routeChunks: { home: "assets/home.js" },
|
||||||
|
})}\n`,
|
||||||
|
);
|
||||||
|
const distInputs = [
|
||||||
|
{ path: "dist/app.js", bytes: Buffer.byteLength("app\n"), sha256: digest("app\n"), gzipBytes: 0 },
|
||||||
|
{
|
||||||
|
path: "dist/release-manifest.json",
|
||||||
|
bytes: releaseManifestBytes.byteLength,
|
||||||
|
sha256: digestBytes(releaseManifestBytes),
|
||||||
|
gzipBytes: 0,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const candidateDist = distSha256(distInputs);
|
||||||
|
const sbomBytes = Buffer.from(
|
||||||
|
`${JSON.stringify({
|
||||||
|
bomFormat: "CycloneDX",
|
||||||
|
specVersion: "1.6",
|
||||||
|
serialNumber: "urn:uuid:00000000-0000-4000-8000-000000000001",
|
||||||
|
version: 1,
|
||||||
|
metadata: {
|
||||||
|
component: { type: "application", name: "fixture", version: "1.0.0" },
|
||||||
|
properties: [],
|
||||||
|
},
|
||||||
|
components: [],
|
||||||
|
dependencies: [],
|
||||||
|
})}\n`,
|
||||||
|
);
|
||||||
|
const sbomSha256 = digestBytes(sbomBytes);
|
||||||
|
const lockfileBytes = Buffer.from("lockfile\n");
|
||||||
|
const lockfileDigest = digestBytes(lockfileBytes);
|
||||||
|
const buildManifest = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
buildId: "build-1",
|
||||||
|
commitSha: sourceRevision,
|
||||||
|
releaseId: "release-1",
|
||||||
|
moduleInventoryHash: digest("module inventory"),
|
||||||
|
generatedAt: "2026-08-02T00:00:00.000Z",
|
||||||
|
buildContext: {
|
||||||
|
nodeVersion: "v24.0.0",
|
||||||
|
packageManagerVersion: "11.0.0",
|
||||||
|
runnerImage: "linux-x64",
|
||||||
|
sourceDateEpoch: "1785638400",
|
||||||
|
},
|
||||||
|
outputs: {
|
||||||
|
directory: "dist",
|
||||||
|
viteManifest: "dist/.vite/manifest.json",
|
||||||
|
moduleInventory: "artifacts/quality/vite-module-inventory.json",
|
||||||
|
routeChunks: { home: "assets/home.js" },
|
||||||
|
runtimeConfigSchema: "dist/runtime-config.schema.json",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const provenance = {
|
||||||
|
_type: "https://in-toto.io/Statement/v1",
|
||||||
|
subject: [{ name: "dist", digest: { sha256: candidateDist } }],
|
||||||
|
predicateType: "https://slsa.dev/provenance/v1",
|
||||||
|
predicate: {
|
||||||
|
buildDefinition: {
|
||||||
|
buildType: "https://vite.dev/build/v1",
|
||||||
|
externalParameters: {},
|
||||||
|
internalParameters: {},
|
||||||
|
resolvedDependencies: [
|
||||||
|
{ uri: "pnpm-lock.yaml", digest: { sha256: lockfileDigest } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
runDetails: {
|
||||||
|
builder: { id: "fixture-builder" },
|
||||||
|
metadata: { invocationId: "LOCAL_UNSIGNED" },
|
||||||
|
},
|
||||||
|
materials: { lockfileSha256: lockfileDigest, sourceSetSha256, sbomSha256 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const supplyVerification = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
localStatus: "PASS",
|
||||||
|
promotionStatus: "FAIL_UNVERIFIED",
|
||||||
|
lockfileSha256: lockfileDigest,
|
||||||
|
sourceSetSha256,
|
||||||
|
distSha256: candidateDist,
|
||||||
|
sbomSha256,
|
||||||
|
dependencyDiff: { added: [], removed: [], changed: [], upgrades: [] },
|
||||||
|
highRiskReview: [],
|
||||||
|
vulnerabilityStatus: "FAIL_UNVERIFIED",
|
||||||
|
provenanceAttestationStatus: "FAIL_UNVERIFIED",
|
||||||
|
failures: [],
|
||||||
|
};
|
||||||
|
const members = new Map<string, Buffer>([
|
||||||
|
["dist/app.js", Buffer.from("app\n")],
|
||||||
|
["dist/release-manifest.json", releaseManifestBytes],
|
||||||
|
["pnpm-lock.yaml", lockfileBytes],
|
||||||
|
["artifacts/release/build-manifest.json", Buffer.from(`${JSON.stringify(buildManifest)}\n`)],
|
||||||
|
["artifacts/release/provenance.json", Buffer.from(`${JSON.stringify(provenance)}\n`)],
|
||||||
|
[
|
||||||
|
"artifacts/security/supply-chain-verification.json",
|
||||||
|
Buffer.from(`${JSON.stringify(supplyVerification)}\n`),
|
||||||
|
],
|
||||||
|
["artifacts/release/sbom.cdx.json", sbomBytes],
|
||||||
|
]);
|
||||||
|
const evidenceInputs = [...members.entries()]
|
||||||
|
.map(([memberPath, bytes]) => ({
|
||||||
|
path: memberPath,
|
||||||
|
bytes: bytes.byteLength,
|
||||||
|
sha256: digestBytes(bytes),
|
||||||
|
}))
|
||||||
|
.sort((left, right) => (left.path < right.path ? -1 : left.path > right.path ? 1 : 0));
|
||||||
|
const policyPaths = [
|
||||||
|
"config/security/dependency-baseline.approval.json",
|
||||||
|
"config/security/dependency-baseline.json",
|
||||||
|
"config/security/dependency-change-evidence.json",
|
||||||
|
"config/security/dependency-policy.json",
|
||||||
|
"config/security/secret-scan-policy.json",
|
||||||
|
"config/security/vulnerability-exceptions.json",
|
||||||
|
"config/security/vulnerability-policy.json",
|
||||||
|
"schemas/artifacts/build-manifest.schema.json",
|
||||||
|
"schemas/artifacts/dependency-inventory.schema.json",
|
||||||
|
"schemas/artifacts/supply-chain-verification.schema.json",
|
||||||
|
"scripts/contracts/release-artifacts.ts",
|
||||||
|
"scripts/create-release-candidate.ts",
|
||||||
|
"scripts/generate-supply-chain.ts",
|
||||||
|
"scripts/lib/build-manifest-outputs.ts",
|
||||||
|
"scripts/lib/json-schema.ts",
|
||||||
|
"scripts/lib/local-policy-evidence.ts",
|
||||||
|
"scripts/lib/local-release-evidence.ts",
|
||||||
|
"scripts/lib/release-candidate.ts",
|
||||||
|
"scripts/lib/release-input-evidence.ts",
|
||||||
|
"scripts/lib/release-runtime-coherence.ts",
|
||||||
|
"scripts/lib/repository-file-inventory.ts",
|
||||||
|
"scripts/lib/secret-scan-evaluator.ts",
|
||||||
|
"scripts/lib/secret-scan-policy.ts",
|
||||||
|
"scripts/lib/supply-chain.ts",
|
||||||
|
"scripts/lib/validated-json-artifact.ts",
|
||||||
|
"src/contracts/release-artifacts.ts",
|
||||||
|
];
|
||||||
|
const sbomRow = evidenceInputs.find(
|
||||||
|
({ path: memberPath }) => memberPath === "artifacts/release/sbom.cdx.json",
|
||||||
|
)!;
|
||||||
|
const policyInputs = policyPaths.map((policyPath) => ({
|
||||||
|
path: policyPath,
|
||||||
|
bytes: 2,
|
||||||
|
sha256: digest(`policy:${policyPath}`),
|
||||||
|
}));
|
||||||
|
const verifierPaths = new Set([
|
||||||
|
"scripts/contracts/release-artifacts.ts",
|
||||||
|
"scripts/create-release-candidate.ts",
|
||||||
|
"scripts/generate-supply-chain.ts",
|
||||||
|
"scripts/lib/build-manifest-outputs.ts",
|
||||||
|
"scripts/lib/json-schema.ts",
|
||||||
|
"scripts/lib/local-policy-evidence.ts",
|
||||||
|
"scripts/lib/local-release-evidence.ts",
|
||||||
|
"scripts/lib/release-candidate.ts",
|
||||||
|
"scripts/lib/release-input-evidence.ts",
|
||||||
|
"scripts/lib/release-runtime-coherence.ts",
|
||||||
|
"scripts/lib/repository-file-inventory.ts",
|
||||||
|
"scripts/lib/secret-scan-evaluator.ts",
|
||||||
|
"scripts/lib/secret-scan-policy.ts",
|
||||||
|
"scripts/lib/supply-chain.ts",
|
||||||
|
"scripts/lib/validated-json-artifact.ts",
|
||||||
|
"src/contracts/release-artifacts.ts",
|
||||||
|
]);
|
||||||
|
const assessment = localEvidenceAssessmentArtifactSchema.parse({
|
||||||
|
...passingAssessment(),
|
||||||
|
verifier: {
|
||||||
|
id: "clean-architecture-frontend-template/local-evidence-verifier",
|
||||||
|
version: "1",
|
||||||
|
sourceSha256: supplyChainDigest(
|
||||||
|
policyInputs.filter(({ path: policyPath }) => verifierPaths.has(policyPath)),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
source: { revision: sourceRevision, sourceSetSha256 },
|
||||||
|
candidate: {
|
||||||
|
distSha256: candidateDist,
|
||||||
|
lockfileSha256: evidenceInputs.find(({ path: memberPath }) => memberPath === "pnpm-lock.yaml")!
|
||||||
|
.sha256,
|
||||||
|
sbomSha256: sbomRow.sha256,
|
||||||
|
},
|
||||||
|
policyInputs,
|
||||||
|
evidenceInputs,
|
||||||
|
});
|
||||||
|
const assessmentBytes = Buffer.from(`${JSON.stringify(assessment)}\n`);
|
||||||
|
members.set(LOCAL_EVIDENCE_ASSESSMENT_PATH, assessmentBytes);
|
||||||
|
for (const [memberPath, bytes] of members) {
|
||||||
|
await mkdir(path.dirname(path.join(root, memberPath)), { recursive: true });
|
||||||
|
await writeFile(path.join(root, memberPath), bytes);
|
||||||
|
}
|
||||||
|
const files = [...members.entries()]
|
||||||
|
.map(([memberPath, bytes]) => ({
|
||||||
|
path: memberPath,
|
||||||
|
bytes: bytes.byteLength,
|
||||||
|
sha256: digestBytes(bytes),
|
||||||
|
}))
|
||||||
|
.sort((left, right) => (left.path < right.path ? -1 : left.path > right.path ? 1 : 0));
|
||||||
|
const manifest: ReleaseCandidateManifest = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
distSha256: assessment.candidate.distSha256,
|
||||||
|
lockfileSha256: assessment.candidate.lockfileSha256,
|
||||||
|
bundleSha256: supplyChainDigest(files),
|
||||||
|
files,
|
||||||
|
};
|
||||||
|
await mkdir(path.join(root, "artifacts/release"), { recursive: true });
|
||||||
|
await writeFile(
|
||||||
|
path.join(root, "artifacts/release/release-candidate.json"),
|
||||||
|
`${JSON.stringify(manifest)}\n`,
|
||||||
|
);
|
||||||
|
return { root, manifest, assessmentSha256: digestBytes(assessmentBytes) };
|
||||||
|
}
|
||||||
+201
-195
@@ -23,6 +23,7 @@ import { checkSecurityFixtures } from "../../scripts/lib/security-fixture-check.
|
|||||||
import {
|
import {
|
||||||
evaluatePromotionEvidence,
|
evaluatePromotionEvidence,
|
||||||
providerEvidenceSignaturePayload,
|
providerEvidenceSignaturePayload,
|
||||||
|
providerPublicKeyFingerprint,
|
||||||
providerVerificationArtifactSchema,
|
providerVerificationArtifactSchema,
|
||||||
} from "../../scripts/lib/provider-evidence.ts";
|
} from "../../scripts/lib/provider-evidence.ts";
|
||||||
import {
|
import {
|
||||||
@@ -51,17 +52,41 @@ const dependency = {
|
|||||||
|
|
||||||
const candidateDistSha256 = "1".repeat(64);
|
const candidateDistSha256 = "1".repeat(64);
|
||||||
const lockfileSha256 = "2".repeat(64);
|
const lockfileSha256 = "2".repeat(64);
|
||||||
|
const NOW = Date.parse("2026-08-02T01:00:00.000Z");
|
||||||
|
const sourceIdentity = Object.freeze({
|
||||||
|
revision: "a".repeat(40),
|
||||||
|
sourceSetSha256: "b".repeat(64),
|
||||||
|
});
|
||||||
|
const localIdentity = Object.freeze({
|
||||||
|
sourceRevision: sourceIdentity.revision,
|
||||||
|
sourceSetSha256: sourceIdentity.sourceSetSha256,
|
||||||
|
assessmentSha256: "c".repeat(64),
|
||||||
|
});
|
||||||
|
const expectedProviderContext = Object.freeze({
|
||||||
|
run: Object.freeze({ id: "fixture-run", attempt: 1 }),
|
||||||
|
source: sourceIdentity,
|
||||||
|
candidate: Object.freeze({
|
||||||
|
archiveSha256: "3".repeat(64),
|
||||||
|
bundleSha256: "4".repeat(64),
|
||||||
|
distSha256: candidateDistSha256,
|
||||||
|
lockfileSha256,
|
||||||
|
}),
|
||||||
|
vulnerabilityInvocationNonce: "5".repeat(64),
|
||||||
|
provenanceInvocationNonce: "6".repeat(64),
|
||||||
|
});
|
||||||
|
|
||||||
function signedProviderEvidence(
|
function signedProviderEvidence(
|
||||||
value: Record<string, unknown>,
|
value: Record<string, unknown>,
|
||||||
keyId: string,
|
keyId: string,
|
||||||
privateKey: ReturnType<typeof generateKeyPairSync>["privateKey"],
|
privateKey: ReturnType<typeof generateKeyPairSync>["privateKey"],
|
||||||
|
publicKeyFingerprint: string,
|
||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
...value,
|
...value,
|
||||||
signature: {
|
signature: {
|
||||||
algorithm: "Ed25519",
|
algorithm: "Ed25519",
|
||||||
keyId,
|
keyId,
|
||||||
|
publicKeyFingerprint,
|
||||||
value: sign(
|
value: sign(
|
||||||
null,
|
null,
|
||||||
providerEvidenceSignaturePayload(value),
|
providerEvidenceSignaturePayload(value),
|
||||||
@@ -71,6 +96,53 @@ function signedProviderEvidence(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function providerPair(input: Readonly<{
|
||||||
|
vulnerabilityKeys: ReturnType<typeof generateKeyPairSync>;
|
||||||
|
provenanceKeys: ReturnType<typeof generateKeyPairSync>;
|
||||||
|
candidate?: typeof expectedProviderContext.candidate;
|
||||||
|
vulnerabilityFingerprint?: string;
|
||||||
|
provenanceFingerprint?: string;
|
||||||
|
}>) {
|
||||||
|
const candidate = input.candidate ?? expectedProviderContext.candidate;
|
||||||
|
const vulnerabilityFingerprint = input.vulnerabilityFingerprint ??
|
||||||
|
providerPublicKeyFingerprint(input.vulnerabilityKeys.publicKey);
|
||||||
|
const provenanceFingerprint = input.provenanceFingerprint ??
|
||||||
|
providerPublicKeyFingerprint(input.provenanceKeys.publicKey);
|
||||||
|
return {
|
||||||
|
vulnerabilityReport: signedProviderEvidence({
|
||||||
|
schemaVersion: 2,
|
||||||
|
evidenceType: "vulnerability-report",
|
||||||
|
provider: "fixture-vulnerability-provider",
|
||||||
|
issuedAt: "2026-08-02T01:00:00.000Z",
|
||||||
|
expiresAt: "2026-08-02T02:00:00.000Z",
|
||||||
|
run: { ...expectedProviderContext.run, invocationNonce: expectedProviderContext.vulnerabilityInvocationNonce },
|
||||||
|
source: expectedProviderContext.source,
|
||||||
|
candidate,
|
||||||
|
findings: [],
|
||||||
|
}, "fixture-vulnerability-key", input.vulnerabilityKeys.privateKey, vulnerabilityFingerprint),
|
||||||
|
provenanceAttestation: signedProviderEvidence({
|
||||||
|
schemaVersion: 2,
|
||||||
|
evidenceType: "provenance-attestation",
|
||||||
|
provider: "fixture-provenance-provider",
|
||||||
|
issuedAt: "2026-08-02T01:00:00.000Z",
|
||||||
|
expiresAt: "2026-08-02T02:00:00.000Z",
|
||||||
|
run: { ...expectedProviderContext.run, invocationNonce: expectedProviderContext.provenanceInvocationNonce },
|
||||||
|
source: expectedProviderContext.source,
|
||||||
|
candidate,
|
||||||
|
signer: "fixture-workload-identity",
|
||||||
|
subject: { name: "dist", digest: { sha256: candidate.distSha256 } },
|
||||||
|
}, "fixture-provenance-key", input.provenanceKeys.privateKey, provenanceFingerprint),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function providerTrust(
|
||||||
|
keyId: string,
|
||||||
|
publicKey: ReturnType<typeof generateKeyPairSync>["publicKey"],
|
||||||
|
publicKeyFingerprint = providerPublicKeyFingerprint(publicKey),
|
||||||
|
) {
|
||||||
|
return { keyId, publicKey, publicKeyFingerprint };
|
||||||
|
}
|
||||||
|
|
||||||
async function createMinimalCandidateTree(root: string) {
|
async function createMinimalCandidateTree(root: string) {
|
||||||
const rawLockfile = "lockfileVersion: '9.0'\n";
|
const rawLockfile = "lockfileVersion: '9.0'\n";
|
||||||
const rawLockfileSha256 = createHash("sha256")
|
const rawLockfileSha256 = createHash("sha256")
|
||||||
@@ -100,37 +172,54 @@ async function createMinimalCandidateTree(root: string) {
|
|||||||
|
|
||||||
async function writeProviderEnvironment(
|
async function writeProviderEnvironment(
|
||||||
root: string,
|
root: string,
|
||||||
distDigest: string,
|
candidate: Awaited<ReturnType<typeof createReleaseCandidateManifest>>,
|
||||||
candidateLockfileSha256: string,
|
overrides: Readonly<{ distSha256?: string }> = {},
|
||||||
) {
|
) {
|
||||||
const vulnerabilityKeys = generateKeyPairSync("ed25519");
|
const vulnerabilityKeys = generateKeyPairSync("ed25519");
|
||||||
const provenanceKeys = generateKeyPairSync("ed25519");
|
const provenanceKeys = generateKeyPairSync("ed25519");
|
||||||
|
const archiveBytes = "fixture archive\n";
|
||||||
|
const candidateIdentity = {
|
||||||
|
archiveSha256: createHash("sha256").update(archiveBytes).digest("hex"),
|
||||||
|
bundleSha256: candidate.bundleSha256,
|
||||||
|
distSha256: overrides.distSha256 ?? candidate.distSha256,
|
||||||
|
lockfileSha256: candidate.lockfileSha256,
|
||||||
|
};
|
||||||
const vulnerabilityReport = signedProviderEvidence(
|
const vulnerabilityReport = signedProviderEvidence(
|
||||||
{
|
{
|
||||||
schemaVersion: 1,
|
schemaVersion: 2,
|
||||||
|
evidenceType: "vulnerability-report",
|
||||||
provider: "fixture-vulnerability-provider",
|
provider: "fixture-vulnerability-provider",
|
||||||
generatedAt: "2026-08-01T00:00:00.000Z",
|
issuedAt: "2026-08-02T01:00:00.000Z",
|
||||||
scannedLockfileSha256: candidateLockfileSha256,
|
expiresAt: "2026-08-02T02:00:00.000Z",
|
||||||
scannedDistSha256: distDigest,
|
run: { id: "fixture-run", attempt: 1, invocationNonce: "5".repeat(64) },
|
||||||
|
source: sourceIdentity,
|
||||||
|
candidate: candidateIdentity,
|
||||||
findings: [],
|
findings: [],
|
||||||
},
|
},
|
||||||
"fixture-vulnerability-key",
|
"fixture-vulnerability-key",
|
||||||
vulnerabilityKeys.privateKey,
|
vulnerabilityKeys.privateKey,
|
||||||
|
providerPublicKeyFingerprint(vulnerabilityKeys.publicKey),
|
||||||
);
|
);
|
||||||
const provenanceAttestation = signedProviderEvidence(
|
const provenanceAttestation = signedProviderEvidence(
|
||||||
{
|
{
|
||||||
schemaVersion: 1,
|
schemaVersion: 2,
|
||||||
|
evidenceType: "provenance-attestation",
|
||||||
provider: "fixture-provenance-provider",
|
provider: "fixture-provenance-provider",
|
||||||
signer: "fixture-workload-identity",
|
signer: "fixture-workload-identity",
|
||||||
generatedAt: "2026-08-01T00:00:00.000Z",
|
issuedAt: "2026-08-02T01:00:00.000Z",
|
||||||
subject: { name: "dist", digest: { sha256: distDigest } },
|
expiresAt: "2026-08-02T02:00:00.000Z",
|
||||||
|
run: { id: "fixture-run", attempt: 1, invocationNonce: "6".repeat(64) },
|
||||||
|
source: sourceIdentity,
|
||||||
|
candidate: candidateIdentity,
|
||||||
|
subject: { name: "dist", digest: { sha256: candidateIdentity.distSha256 } },
|
||||||
},
|
},
|
||||||
"fixture-provenance-key",
|
"fixture-provenance-key",
|
||||||
provenanceKeys.privateKey,
|
provenanceKeys.privateKey,
|
||||||
|
providerPublicKeyFingerprint(provenanceKeys.publicKey),
|
||||||
);
|
);
|
||||||
await mkdir(path.join(root, "provider"), { recursive: true });
|
await mkdir(path.join(root, "provider"), { recursive: true });
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
writeFile(path.join(root, "provider/candidate.tar.gz"), "fixture archive\n"),
|
writeFile(path.join(root, "provider/candidate.tar.gz"), archiveBytes),
|
||||||
writeFile(
|
writeFile(
|
||||||
path.join(root, "provider/vulnerability.json"),
|
path.join(root, "provider/vulnerability.json"),
|
||||||
`${JSON.stringify(vulnerabilityReport)}\n`,
|
`${JSON.stringify(vulnerabilityReport)}\n`,
|
||||||
@@ -155,8 +244,13 @@ async function writeProviderEnvironment(
|
|||||||
return {
|
return {
|
||||||
CANDIDATE_ARCHIVE_PATH: "provider/candidate.tar.gz",
|
CANDIDATE_ARCHIVE_PATH: "provider/candidate.tar.gz",
|
||||||
CANDIDATE_ARCHIVE_SHA256: createHash("sha256")
|
CANDIDATE_ARCHIVE_SHA256: createHash("sha256")
|
||||||
.update("fixture archive\n")
|
.update(archiveBytes)
|
||||||
.digest("hex"),
|
.digest("hex"),
|
||||||
|
CI_RUN_ID: "fixture-run",
|
||||||
|
CI_RUN_ATTEMPT: "1",
|
||||||
|
EXPECTED_SOURCE_REVISION: sourceIdentity.revision,
|
||||||
|
VULNERABILITY_INVOCATION_NONCE: "5".repeat(64),
|
||||||
|
PROVENANCE_INVOCATION_NONCE: "6".repeat(64),
|
||||||
VULNERABILITY_REPORT_PATH: "provider/vulnerability.json",
|
VULNERABILITY_REPORT_PATH: "provider/vulnerability.json",
|
||||||
PROVENANCE_ATTESTATION_PATH: "provider/provenance.json",
|
PROVENANCE_ATTESTATION_PATH: "provider/provenance.json",
|
||||||
VULNERABILITY_PUBLIC_KEY_PATH: "provider/vulnerability.pem",
|
VULNERABILITY_PUBLIC_KEY_PATH: "provider/vulnerability.pem",
|
||||||
@@ -167,32 +261,37 @@ async function writeProviderEnvironment(
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("supply-chain policy", () => {
|
describe("supply-chain policy", () => {
|
||||||
it("emits a strict role-bound v2 verification record from exact input bytes", async () => {
|
it("emits a strict role-bound v3 verification record from exact input bytes", async () => {
|
||||||
const root = await mkdtemp(path.join(tmpdir(), "promotion-verification-v2-"));
|
const root = await mkdtemp(path.join(tmpdir(), "promotion-verification-v3-"));
|
||||||
try {
|
try {
|
||||||
const manifest = await createMinimalCandidateTree(root);
|
const manifest = await createMinimalCandidateTree(root);
|
||||||
const environment = await writeProviderEnvironment(
|
const environment = await writeProviderEnvironment(root, manifest);
|
||||||
root,
|
|
||||||
manifest.distSha256,
|
|
||||||
manifest.lockfileSha256,
|
|
||||||
);
|
|
||||||
const report = await verifyPromotionInputs({
|
const report = await verifyPromotionInputs({
|
||||||
artifactType: "provider-verification",
|
artifactType: "provider-verification",
|
||||||
repositoryRoot: root,
|
repositoryRoot: root,
|
||||||
environment,
|
environment,
|
||||||
verifyLocalEvidence: async () => ({ status: "PASS" as const, failures: [] }),
|
verifyLocalEvidence: async () => ({
|
||||||
} as Parameters<typeof verifyPromotionInputs>[0]);
|
status: "PASS" as const,
|
||||||
|
identity: localIdentity,
|
||||||
|
failures: [] as const,
|
||||||
|
}),
|
||||||
|
nowEpochMs: () => NOW,
|
||||||
|
});
|
||||||
expect(providerVerificationArtifactSchema.parse(report)).toEqual(
|
expect(providerVerificationArtifactSchema.parse(report)).toEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
schemaVersion: 2,
|
schemaVersion: 3,
|
||||||
artifactType: "provider-verification",
|
artifactType: "provider-verification",
|
||||||
candidateArchiveSha256: environment.CANDIDATE_ARCHIVE_SHA256,
|
candidate: expect.objectContaining({
|
||||||
vulnerabilityReportSha256: createHash("sha256")
|
archiveSha256: environment.CANDIDATE_ARCHIVE_SHA256,
|
||||||
.update(await readFile(path.join(root, environment.VULNERABILITY_REPORT_PATH!)))
|
}),
|
||||||
.digest("hex"),
|
providerEvidence: expect.objectContaining({
|
||||||
provenanceAttestationSha256: createHash("sha256")
|
vulnerabilityReportSha256: createHash("sha256")
|
||||||
.update(await readFile(path.join(root, environment.PROVENANCE_ATTESTATION_PATH!)))
|
.update(await readFile(path.join(root, environment.VULNERABILITY_REPORT_PATH!)))
|
||||||
.digest("hex"),
|
.digest("hex"),
|
||||||
|
provenanceAttestationSha256: createHash("sha256")
|
||||||
|
.update(await readFile(path.join(root, environment.PROVENANCE_ATTESTATION_PATH!)))
|
||||||
|
.digest("hex"),
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -204,13 +303,10 @@ describe("supply-chain policy", () => {
|
|||||||
const root = await mkdtemp(path.join(tmpdir(), "promotion-wiring-"));
|
const root = await mkdtemp(path.join(tmpdir(), "promotion-wiring-"));
|
||||||
try {
|
try {
|
||||||
const manifest = await createMinimalCandidateTree(root);
|
const manifest = await createMinimalCandidateTree(root);
|
||||||
const validEnvironment = await writeProviderEnvironment(
|
const validEnvironment = await writeProviderEnvironment(root, manifest);
|
||||||
root,
|
|
||||||
manifest.distSha256,
|
|
||||||
manifest.lockfileSha256,
|
|
||||||
);
|
|
||||||
const acceptLocalEvidence = async () => ({
|
const acceptLocalEvidence = async () => ({
|
||||||
status: "PASS" as const,
|
status: "PASS" as const,
|
||||||
|
identity: localIdentity,
|
||||||
failures: [] as const,
|
failures: [] as const,
|
||||||
});
|
});
|
||||||
const valid = await verifyPromotionInputs({
|
const valid = await verifyPromotionInputs({
|
||||||
@@ -218,23 +314,34 @@ describe("supply-chain policy", () => {
|
|||||||
repositoryRoot: root,
|
repositoryRoot: root,
|
||||||
environment: validEnvironment,
|
environment: validEnvironment,
|
||||||
verifyLocalEvidence: acceptLocalEvidence,
|
verifyLocalEvidence: acceptLocalEvidence,
|
||||||
|
nowEpochMs: () => NOW,
|
||||||
});
|
});
|
||||||
const absent = await verifyPromotionInputs({
|
const absent = await verifyPromotionInputs({
|
||||||
artifactType: "provider-verification",
|
artifactType: "provider-verification",
|
||||||
repositoryRoot: root,
|
repositoryRoot: root,
|
||||||
environment: {},
|
environment: {},
|
||||||
verifyLocalEvidence: acceptLocalEvidence,
|
verifyLocalEvidence: acceptLocalEvidence,
|
||||||
|
nowEpochMs: () => NOW,
|
||||||
|
});
|
||||||
|
const replayedNonce = await verifyPromotionInputs({
|
||||||
|
artifactType: "provider-verification",
|
||||||
|
repositoryRoot: root,
|
||||||
|
environment: {
|
||||||
|
...validEnvironment,
|
||||||
|
VULNERABILITY_INVOCATION_NONCE: "9".repeat(64),
|
||||||
|
},
|
||||||
|
verifyLocalEvidence: acceptLocalEvidence,
|
||||||
|
nowEpochMs: () => NOW,
|
||||||
|
});
|
||||||
|
const wrongEnvironment = await writeProviderEnvironment(root, manifest, {
|
||||||
|
distSha256: "3".repeat(64),
|
||||||
});
|
});
|
||||||
const wrongEnvironment = await writeProviderEnvironment(
|
|
||||||
root,
|
|
||||||
"3".repeat(64),
|
|
||||||
manifest.lockfileSha256,
|
|
||||||
);
|
|
||||||
const wrongDigest = await verifyPromotionInputs({
|
const wrongDigest = await verifyPromotionInputs({
|
||||||
artifactType: "provider-verification",
|
artifactType: "provider-verification",
|
||||||
repositoryRoot: root,
|
repositoryRoot: root,
|
||||||
environment: wrongEnvironment,
|
environment: wrongEnvironment,
|
||||||
verifyLocalEvidence: acceptLocalEvidence,
|
verifyLocalEvidence: acceptLocalEvidence,
|
||||||
|
nowEpochMs: () => NOW,
|
||||||
});
|
});
|
||||||
await writeFile(path.join(root, "dist/app.js"), "mutated\n");
|
await writeFile(path.join(root, "dist/app.js"), "mutated\n");
|
||||||
const postAttestationMutation = await verifyPromotionInputs({
|
const postAttestationMutation = await verifyPromotionInputs({
|
||||||
@@ -242,19 +349,23 @@ describe("supply-chain policy", () => {
|
|||||||
repositoryRoot: root,
|
repositoryRoot: root,
|
||||||
environment: validEnvironment,
|
environment: validEnvironment,
|
||||||
verifyLocalEvidence: acceptLocalEvidence,
|
verifyLocalEvidence: acceptLocalEvidence,
|
||||||
|
nowEpochMs: () => NOW,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect({
|
expect({
|
||||||
valid: valid.status,
|
valid: valid.status,
|
||||||
absent: absent.status,
|
absent: absent.status,
|
||||||
wrongDigest: wrongDigest.status,
|
wrongDigest: wrongDigest.status,
|
||||||
|
replayedNonce: replayedNonce.status,
|
||||||
postAttestationMutation: postAttestationMutation.status,
|
postAttestationMutation: postAttestationMutation.status,
|
||||||
}).toEqual({
|
}).toEqual({
|
||||||
valid: "PASS",
|
valid: "PASS",
|
||||||
absent: "FAIL_UNVERIFIED",
|
absent: "FAIL_UNVERIFIED",
|
||||||
wrongDigest: "FAIL_UNVERIFIED",
|
wrongDigest: "FAIL_UNVERIFIED",
|
||||||
|
replayedNonce: "FAIL_UNVERIFIED",
|
||||||
postAttestationMutation: "FAIL_UNVERIFIED",
|
postAttestationMutation: "FAIL_UNVERIFIED",
|
||||||
});
|
});
|
||||||
|
expect(replayedNonce.failures).toContain("vulnerability report invocation nonce mismatch");
|
||||||
} finally {
|
} finally {
|
||||||
await rm(root, { recursive: true, force: true });
|
await rm(root, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
@@ -264,11 +375,7 @@ describe("supply-chain policy", () => {
|
|||||||
const root = await mkdtemp(path.join(tmpdir(), "promotion-local-status-"));
|
const root = await mkdtemp(path.join(tmpdir(), "promotion-local-status-"));
|
||||||
try {
|
try {
|
||||||
const manifest = await createMinimalCandidateTree(root);
|
const manifest = await createMinimalCandidateTree(root);
|
||||||
const environment = await writeProviderEnvironment(
|
const environment = await writeProviderEnvironment(root, manifest);
|
||||||
root,
|
|
||||||
manifest.distSha256,
|
|
||||||
manifest.lockfileSha256,
|
|
||||||
);
|
|
||||||
const localVerificationPath = path.join(
|
const localVerificationPath = path.join(
|
||||||
root,
|
root,
|
||||||
"artifacts/security/supply-chain-verification.json",
|
"artifacts/security/supply-chain-verification.json",
|
||||||
@@ -278,12 +385,13 @@ describe("supply-chain policy", () => {
|
|||||||
artifactType: "provider-verification",
|
artifactType: "provider-verification",
|
||||||
repositoryRoot: root,
|
repositoryRoot: root,
|
||||||
environment,
|
environment,
|
||||||
|
nowEpochMs: () => NOW,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.status).toBe("FAIL_UNVERIFIED");
|
expect(result.status).toBe("FAIL_UNVERIFIED");
|
||||||
expect(result.failures).toEqual(
|
expect(result.failures).toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
expect.stringMatching(/executable schema mismatch/u),
|
"local evidence assessment is missing or invalid",
|
||||||
"local supply-chain evidence is not PASS",
|
"local supply-chain evidence is not PASS",
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
@@ -393,16 +501,13 @@ describe("supply-chain policy", () => {
|
|||||||
|
|
||||||
it("fails promotion when external provider evidence is absent", () => {
|
it("fails promotion when external provider evidence is absent", () => {
|
||||||
const result = evaluatePromotionEvidence({
|
const result = evaluatePromotionEvidence({
|
||||||
candidate: {
|
expected: expectedProviderContext,
|
||||||
distSha256: candidateDistSha256,
|
|
||||||
lockfileSha256,
|
|
||||||
},
|
|
||||||
currentDistSha256: candidateDistSha256,
|
|
||||||
localStatus: "PASS",
|
localStatus: "PASS",
|
||||||
vulnerabilityReport: null,
|
vulnerabilityReport: null,
|
||||||
provenanceAttestation: null,
|
provenanceAttestation: null,
|
||||||
vulnerabilityTrust: null,
|
vulnerabilityTrust: null,
|
||||||
provenanceTrust: null,
|
provenanceTrust: null,
|
||||||
|
nowEpochMs: () => NOW,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.status).toBe("FAIL_UNVERIFIED");
|
expect(result.status).toBe("FAIL_UNVERIFIED");
|
||||||
@@ -411,50 +516,25 @@ describe("supply-chain policy", () => {
|
|||||||
it("passes only signed provider evidence for the exact immutable candidate", () => {
|
it("passes only signed provider evidence for the exact immutable candidate", () => {
|
||||||
const vulnerabilityKeys = generateKeyPairSync("ed25519");
|
const vulnerabilityKeys = generateKeyPairSync("ed25519");
|
||||||
const provenanceKeys = generateKeyPairSync("ed25519");
|
const provenanceKeys = generateKeyPairSync("ed25519");
|
||||||
const vulnerabilityReport = signedProviderEvidence(
|
const { vulnerabilityReport, provenanceAttestation } = providerPair({
|
||||||
{
|
vulnerabilityKeys,
|
||||||
schemaVersion: 1,
|
provenanceKeys,
|
||||||
provider: "fixture-vulnerability-provider",
|
});
|
||||||
generatedAt: "2026-08-01T00:00:00.000Z",
|
|
||||||
scannedLockfileSha256: lockfileSha256,
|
|
||||||
scannedDistSha256: candidateDistSha256,
|
|
||||||
findings: [],
|
|
||||||
},
|
|
||||||
"fixture-vulnerability-key",
|
|
||||||
vulnerabilityKeys.privateKey,
|
|
||||||
);
|
|
||||||
const provenanceAttestation = signedProviderEvidence(
|
|
||||||
{
|
|
||||||
schemaVersion: 1,
|
|
||||||
provider: "fixture-provenance-provider",
|
|
||||||
signer: "fixture-workload-identity",
|
|
||||||
generatedAt: "2026-08-01T00:00:00.000Z",
|
|
||||||
subject: {
|
|
||||||
name: "dist",
|
|
||||||
digest: { sha256: candidateDistSha256 },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"fixture-provenance-key",
|
|
||||||
provenanceKeys.privateKey,
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = evaluatePromotionEvidence({
|
const result = evaluatePromotionEvidence({
|
||||||
candidate: {
|
expected: expectedProviderContext,
|
||||||
distSha256: candidateDistSha256,
|
|
||||||
lockfileSha256,
|
|
||||||
},
|
|
||||||
currentDistSha256: candidateDistSha256,
|
|
||||||
localStatus: "PASS",
|
localStatus: "PASS",
|
||||||
vulnerabilityReport,
|
vulnerabilityReport,
|
||||||
provenanceAttestation,
|
provenanceAttestation,
|
||||||
vulnerabilityTrust: {
|
vulnerabilityTrust: providerTrust(
|
||||||
keyId: "fixture-vulnerability-key",
|
"fixture-vulnerability-key",
|
||||||
publicKey: vulnerabilityKeys.publicKey,
|
vulnerabilityKeys.publicKey,
|
||||||
},
|
),
|
||||||
provenanceTrust: {
|
provenanceTrust: providerTrust(
|
||||||
keyId: "fixture-provenance-key",
|
"fixture-provenance-key",
|
||||||
publicKey: provenanceKeys.publicKey,
|
provenanceKeys.publicKey,
|
||||||
},
|
),
|
||||||
|
nowEpochMs: () => NOW,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result).toMatchObject({
|
expect(result).toMatchObject({
|
||||||
@@ -469,54 +549,27 @@ describe("supply-chain policy", () => {
|
|||||||
const vulnerabilityKeys = generateKeyPairSync("ed25519");
|
const vulnerabilityKeys = generateKeyPairSync("ed25519");
|
||||||
const provenanceKeys = generateKeyPairSync("ed25519");
|
const provenanceKeys = generateKeyPairSync("ed25519");
|
||||||
const wrongDistSha256 = "3".repeat(64);
|
const wrongDistSha256 = "3".repeat(64);
|
||||||
const vulnerabilityReport = signedProviderEvidence(
|
const { vulnerabilityReport, provenanceAttestation } = providerPair({
|
||||||
{
|
vulnerabilityKeys,
|
||||||
schemaVersion: 1,
|
provenanceKeys,
|
||||||
provider: "fixture-vulnerability-provider",
|
candidate: { ...expectedProviderContext.candidate, distSha256: wrongDistSha256 },
|
||||||
generatedAt: "2026-08-01T00:00:00.000Z",
|
});
|
||||||
scannedLockfileSha256: lockfileSha256,
|
|
||||||
scannedDistSha256: wrongDistSha256,
|
|
||||||
findings: [],
|
|
||||||
},
|
|
||||||
"fixture-vulnerability-key",
|
|
||||||
vulnerabilityKeys.privateKey,
|
|
||||||
);
|
|
||||||
const provenanceAttestation = signedProviderEvidence(
|
|
||||||
{
|
|
||||||
schemaVersion: 1,
|
|
||||||
provider: "fixture-provenance-provider",
|
|
||||||
signer: "fixture-workload-identity",
|
|
||||||
generatedAt: "2026-08-01T00:00:00.000Z",
|
|
||||||
subject: { name: "dist", digest: { sha256: wrongDistSha256 } },
|
|
||||||
},
|
|
||||||
"fixture-provenance-key",
|
|
||||||
provenanceKeys.privateKey,
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = evaluatePromotionEvidence({
|
const result = evaluatePromotionEvidence({
|
||||||
candidate: {
|
expected: expectedProviderContext,
|
||||||
distSha256: candidateDistSha256,
|
|
||||||
lockfileSha256,
|
|
||||||
},
|
|
||||||
currentDistSha256: candidateDistSha256,
|
|
||||||
localStatus: "PASS",
|
localStatus: "PASS",
|
||||||
vulnerabilityReport,
|
vulnerabilityReport,
|
||||||
provenanceAttestation,
|
provenanceAttestation,
|
||||||
vulnerabilityTrust: {
|
vulnerabilityTrust: providerTrust("fixture-vulnerability-key", vulnerabilityKeys.publicKey),
|
||||||
keyId: "fixture-vulnerability-key",
|
provenanceTrust: providerTrust("fixture-provenance-key", provenanceKeys.publicKey),
|
||||||
publicKey: vulnerabilityKeys.publicKey,
|
nowEpochMs: () => NOW,
|
||||||
},
|
|
||||||
provenanceTrust: {
|
|
||||||
keyId: "fixture-provenance-key",
|
|
||||||
publicKey: provenanceKeys.publicKey,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.status).toBe("FAIL_UNVERIFIED");
|
expect(result.status).toBe("FAIL_UNVERIFIED");
|
||||||
expect(result.failures).toEqual(
|
expect(result.failures).toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
"vulnerability report dist digest mismatch",
|
"vulnerability report candidate identity mismatch",
|
||||||
"provenance attestation dist digest mismatch",
|
"provenance attestation candidate identity mismatch",
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -524,103 +577,56 @@ describe("supply-chain policy", () => {
|
|||||||
it("rejects candidate bytes changed after provider attestation", () => {
|
it("rejects candidate bytes changed after provider attestation", () => {
|
||||||
const vulnerabilityKeys = generateKeyPairSync("ed25519");
|
const vulnerabilityKeys = generateKeyPairSync("ed25519");
|
||||||
const provenanceKeys = generateKeyPairSync("ed25519");
|
const provenanceKeys = generateKeyPairSync("ed25519");
|
||||||
const vulnerabilityReport = signedProviderEvidence(
|
const { vulnerabilityReport, provenanceAttestation } = providerPair({
|
||||||
{
|
vulnerabilityKeys,
|
||||||
schemaVersion: 1,
|
provenanceKeys,
|
||||||
provider: "fixture-vulnerability-provider",
|
});
|
||||||
generatedAt: "2026-08-01T00:00:00.000Z",
|
|
||||||
scannedLockfileSha256: lockfileSha256,
|
|
||||||
scannedDistSha256: candidateDistSha256,
|
|
||||||
findings: [],
|
|
||||||
},
|
|
||||||
"fixture-vulnerability-key",
|
|
||||||
vulnerabilityKeys.privateKey,
|
|
||||||
);
|
|
||||||
const provenanceAttestation = signedProviderEvidence(
|
|
||||||
{
|
|
||||||
schemaVersion: 1,
|
|
||||||
provider: "fixture-provenance-provider",
|
|
||||||
signer: "fixture-workload-identity",
|
|
||||||
generatedAt: "2026-08-01T00:00:00.000Z",
|
|
||||||
subject: {
|
|
||||||
name: "dist",
|
|
||||||
digest: { sha256: candidateDistSha256 },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"fixture-provenance-key",
|
|
||||||
provenanceKeys.privateKey,
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = evaluatePromotionEvidence({
|
const result = evaluatePromotionEvidence({
|
||||||
candidate: {
|
expected: {
|
||||||
distSha256: candidateDistSha256,
|
...expectedProviderContext,
|
||||||
lockfileSha256,
|
candidate: { ...expectedProviderContext.candidate, distSha256: "4".repeat(64) },
|
||||||
},
|
},
|
||||||
currentDistSha256: "4".repeat(64),
|
|
||||||
localStatus: "PASS",
|
localStatus: "PASS",
|
||||||
vulnerabilityReport,
|
vulnerabilityReport,
|
||||||
provenanceAttestation,
|
provenanceAttestation,
|
||||||
vulnerabilityTrust: {
|
vulnerabilityTrust: providerTrust("fixture-vulnerability-key", vulnerabilityKeys.publicKey),
|
||||||
keyId: "fixture-vulnerability-key",
|
provenanceTrust: providerTrust("fixture-provenance-key", provenanceKeys.publicKey),
|
||||||
publicKey: vulnerabilityKeys.publicKey,
|
nowEpochMs: () => NOW,
|
||||||
},
|
|
||||||
provenanceTrust: {
|
|
||||||
keyId: "fixture-provenance-key",
|
|
||||||
publicKey: provenanceKeys.publicKey,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.status).toBe("FAIL_UNVERIFIED");
|
expect(result.status).toBe("FAIL_UNVERIFIED");
|
||||||
expect(result.failures).toContain(
|
expect(result.failures).toContain("vulnerability report candidate identity mismatch");
|
||||||
"candidate dist bytes changed after immutable build",
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects Ed448 keys mislabeled as Ed25519 evidence", () => {
|
it("rejects Ed448 keys mislabeled as Ed25519 evidence", () => {
|
||||||
const vulnerabilityKeys = generateKeyPairSync("ed448");
|
const vulnerabilityKeys = generateKeyPairSync("ed448");
|
||||||
const provenanceKeys = generateKeyPairSync("ed448");
|
const provenanceKeys = generateKeyPairSync("ed448");
|
||||||
const vulnerabilityReport = signedProviderEvidence(
|
const fakeFingerprint = `sha256:${"7".repeat(64)}`;
|
||||||
{
|
const { vulnerabilityReport, provenanceAttestation } = providerPair({
|
||||||
schemaVersion: 1,
|
vulnerabilityKeys,
|
||||||
provider: "fixture-vulnerability-provider",
|
provenanceKeys,
|
||||||
generatedAt: "2026-08-01T00:00:00.000Z",
|
vulnerabilityFingerprint: fakeFingerprint,
|
||||||
scannedLockfileSha256: lockfileSha256,
|
provenanceFingerprint: fakeFingerprint,
|
||||||
scannedDistSha256: candidateDistSha256,
|
});
|
||||||
findings: [],
|
|
||||||
},
|
|
||||||
"fixture-vulnerability-key",
|
|
||||||
vulnerabilityKeys.privateKey,
|
|
||||||
);
|
|
||||||
const provenanceAttestation = signedProviderEvidence(
|
|
||||||
{
|
|
||||||
schemaVersion: 1,
|
|
||||||
provider: "fixture-provenance-provider",
|
|
||||||
signer: "fixture-workload-identity",
|
|
||||||
generatedAt: "2026-08-01T00:00:00.000Z",
|
|
||||||
subject: {
|
|
||||||
name: "dist",
|
|
||||||
digest: { sha256: candidateDistSha256 },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"fixture-provenance-key",
|
|
||||||
provenanceKeys.privateKey,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
evaluatePromotionEvidence({
|
evaluatePromotionEvidence({
|
||||||
candidate: { distSha256: candidateDistSha256, lockfileSha256 },
|
expected: expectedProviderContext,
|
||||||
currentDistSha256: candidateDistSha256,
|
|
||||||
localStatus: "PASS",
|
localStatus: "PASS",
|
||||||
vulnerabilityReport,
|
vulnerabilityReport,
|
||||||
provenanceAttestation,
|
provenanceAttestation,
|
||||||
vulnerabilityTrust: {
|
vulnerabilityTrust: {
|
||||||
keyId: "fixture-vulnerability-key",
|
keyId: "fixture-vulnerability-key",
|
||||||
publicKey: vulnerabilityKeys.publicKey,
|
publicKey: vulnerabilityKeys.publicKey,
|
||||||
|
publicKeyFingerprint: fakeFingerprint,
|
||||||
},
|
},
|
||||||
provenanceTrust: {
|
provenanceTrust: {
|
||||||
keyId: "fixture-provenance-key",
|
keyId: "fixture-provenance-key",
|
||||||
publicKey: provenanceKeys.publicKey,
|
publicKey: provenanceKeys.publicKey,
|
||||||
|
publicKeyFingerprint: fakeFingerprint,
|
||||||
},
|
},
|
||||||
|
nowEpochMs: () => NOW,
|
||||||
}).status,
|
}).status,
|
||||||
).toBe("FAIL_UNVERIFIED");
|
).toBe("FAIL_UNVERIFIED");
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user