@xemahq/repo-build-tooling 0.17.0 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +2 -2
- package/src/promote-image-predicate.mjs +9 -4
- package/src/promote-image-provenance.mjs +98 -38
- package/src/promote-image.mjs +73 -44
- package/src/promote-image.test.mjs +151 -24
package/README.md
CHANGED
|
@@ -30,8 +30,8 @@ pnpm exec xema-scrub-swagger-paths dist
|
|
|
30
30
|
| `xema-service-bootstrap [--check] [--filter <key>] [--root <dir>]` | Generates `src/generated/*.generated.ts` service and worker descriptors from the repository's `xema-biome.json` manifests; `--check` fails on drift |
|
|
31
31
|
| `xema-scrub-swagger-paths <dist-dir>...` | Rewrites `@nestjs/swagger` plugin output that names a private install path, then fails the build if any leak remains (`--self-test` available) |
|
|
32
32
|
| `xema-composed-image <command>` | Build-time half of a composed-process image: `members`, `filters`, `dirs`, `pins`, `index`, `load`, `stage`, `pnpm-context`, `sync-injected`, `prisma-check`, `migrate`, `coordinate`, `load-image`, `smoke-refusal`, `dockerfile` |
|
|
33
|
-
| `xema-promote-image --service <name> --dev-ref <repo> --release-ref <repo> --tag <tag> --commit <sha> --main-ref <ref> --signer-repository <owner/repo> --develop-workflow <file> --release-workflow <file>... --promotion-predicate-out <path> -- <tag command>` | Publishes the develop image's digest under the release tag instead of rebuilding: `reused` (the release tag is signed by a main workflow, or is the proven develop digest), `copied` (the proven develop digest, digest-preserving; fails closed if the release digest differs) or `miss` (build it). A develop digest is proven only if its SLSA v1 provenance is signed by the develop workflow at `refs/heads/develop`, its revision is an ancestor of the main ref, and the tag command prints the same tag at that revision and at `--commit`; any failed leg is an error, never a miss. On a promotion it writes the `https://xema.dev/attestations/image-promotion/v1` predicate for the caller to attest. Writes `result`, `digest`, `source`, `revision`, `evidence`, `provenance_digest`, `promotion_predicate`, `promotion_predicate_type` to `$GITHUB_OUTPUT`. Needs `docker`, `cosign`, `git` and full history. It does not sign |
|
|
34
33
|
| `xema-arm64-supplement-needed` | Decides whether an arm64 supplement is still owed at `REGISTRY/IMAGE:CONTENT_TAG` (env: `REGISTRY`, `IMAGE`, `CONTENT_TAG`, `REGISTRY_USER`, `REGISTRY_TOKEN`). `needed=false` only when the coordinate serves an index whose linux/arm64 child is proven by its own image config; any other resolvable state is `needed=true`; a 401/403/5xx or an unresolvable child is an error. Writes `needed=true\|false` to `$GITHUB_OUTPUT`. Replaces the generated `arm64-supplement-needed.mjs` copy. |
|
|
34
|
+
| `xema-promote-image --service <name> --dev-ref <repo> --release-ref <repo> --tag <tag> --commit <sha> --main-ref <ref> --signer-repository <owner/repo> --develop-workflow <file> --release-workflow <file>... --promotion-predicate-out <path> -- <tag command>` | Publishes the develop image's digest under the release tag instead of rebuilding: `reused` (the release digest carries an `image-promotion/v1` attestation signed by a `--release-workflow` identity on main, with exactly one verified statement about the digest and a `ghcr.io/<owner>/dev/<name>` source; no dev-ref probe), `copied` (the proven develop digest, digest-preserving; fails closed if the release digest differs) or `miss` (build it; a reuse whose release digest has no `image-promotion/v1` attestation, a legacy image, is a miss with `reason=reuse-no-promotion` on the stdout line; a signature or identity failure, or a source outside `dev/`, is an error; a copy whose dev digest carries no SLSA v1 provenance at all, an image reused on develop from before provenance, is a miss with `reason=copy-no-develop-provenance`, while provenance that is present but fails a leg is an error). A develop digest is proven only if its SLSA v1 provenance is signed by the develop workflow at `refs/heads/develop`, its revision is an ancestor of the main ref, and the tag command prints the same tag at that revision and at `--commit`; any failed leg is an error, never a miss. On every non-miss result it writes the `https://xema.dev/attestations/image-promotion/v1` predicate for the caller to attest: a copy states the facts just proved; a reuse states this run's `release` and `promotedCommit` and carries `source` and `developProvenance` from the earlier promotion. Writes `result`, `digest`, `source`, `revision`, `evidence`, `provenance_digest`, `promotion_predicate`, `promotion_predicate_type` to `$GITHUB_OUTPUT`. Needs `docker`, `cosign` (major v2, the same as the develop attest step and xema-deploy's verify; it runs `cosign version` first and refuses any other major or an unparsable version), `git` and full history. It does not sign |
|
|
35
35
|
| `xema-check-no-vendored-deps` | Fails on a `workspace:` range for a package the repository does not own |
|
|
36
36
|
| `xema-check-workspace-ranges` | Fails on a `workspace:` range the local package does not satisfy |
|
|
37
37
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xemahq/repo-build-tooling",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
4
4
|
"description": "Dev-time build tooling shared by every Xema repository. Ships as plain ESM, depending only on @xemahq/release-policy for registry access, so the published artifact is the reviewed source.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Neuralchowder Inc. <developer@xema.dev> (https://xema.dev)",
|
|
@@ -48,6 +48,6 @@
|
|
|
48
48
|
"test": "node src/scrub-swagger-plugin-paths.mjs --self-test && node --test src/check-workspace-range-matches-local.test.mjs && node --test src/check-no-vendored-deps.test.mjs && node --test src/readiness.test.mjs && node --test src/composed-image.test.mjs && node --test src/composed-image-coordinate-cli.test.mjs && node --test src/declaration-refusal.test.mjs && node --test src/smoke-refusal.test.mjs && node --test src/load-image.test.mjs && node --test src/composed-image-coordinate.test.mjs && node --test src/composed-image-dockerfile.test.mjs && node --test src/service-bootstrap.test.mjs && node --test src/service-bootstrap-protocols.test.mjs && node --test src/composed-image-prebuilt-dockerfile.test.mjs && node --test src/composed-image-in-image-closure.test.mjs && node --test src/composed-image-stage.test.mjs && node --test src/composed-image-pnpm-context.test.mjs && node --test src/composed-image-sync-injected.test.mjs && node --test src/composed-image-prisma-check.test.mjs && node --test src/composed-migrate.test.mjs && node --test src/promote-image.test.mjs && node --test src/arm64-supplement-needed.test.mjs"
|
|
49
49
|
},
|
|
50
50
|
"xemaRelease": {
|
|
51
|
-
"sourceDigest": "sha256:
|
|
51
|
+
"sourceDigest": "sha256:aba8ae8794d81525b38c0dfcfefae6abb58b1075eebdc18d261e661c0e07b2fd"
|
|
52
52
|
}
|
|
53
53
|
}
|
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
// `xema-promote-image` proved before the copy (promote-image-provenance.mjs).
|
|
4
4
|
|
|
5
5
|
import { writeFileSync } from 'node:fs';
|
|
6
|
-
import { SLSA_PROVENANCE_V1 } from './promote-image-provenance.mjs';
|
|
6
|
+
import { IMAGE_PROMOTION_PREDICATE_TYPE, SLSA_PROVENANCE_V1 } from './promote-image-provenance.mjs';
|
|
7
7
|
|
|
8
|
-
export
|
|
8
|
+
export { IMAGE_PROMOTION_PREDICATE_TYPE };
|
|
9
9
|
|
|
10
10
|
export function promotionPredicate({ releaseRef, tag, digest, source, revision, commit, provenanceDigest, signerPattern }) {
|
|
11
11
|
return {
|
|
@@ -16,7 +16,12 @@ export function promotionPredicate({ releaseRef, tag, digest, source, revision,
|
|
|
16
16
|
};
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
/** The predicate of a REUSE: this run's release and commit, with `source` and `developProvenance` carried from main's earlier promotion. */
|
|
20
|
+
export function reusedPromotionPredicate({ releaseRef, tag, digest, commit, source, developProvenance }) {
|
|
21
|
+
return { release: { repository: releaseRef, tag, digest }, source, promotedCommit: commit, developProvenance };
|
|
22
|
+
}
|
|
23
|
+
|
|
19
24
|
/** Writes the predicate to a path that must not exist yet, so a stale file is never attested. */
|
|
20
|
-
export function writePromotionPredicate(path,
|
|
21
|
-
writeFileSync(path, `${JSON.stringify(
|
|
25
|
+
export function writePromotionPredicate(path, predicate) {
|
|
26
|
+
writeFileSync(path, `${JSON.stringify(predicate, null, 2)}\n`, { flag: 'wx' });
|
|
22
27
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// The trust half of `xema-promote-image`: what must be PROVEN about a develop
|
|
2
|
-
// digest before its bytes may be published under a release tag, and how a
|
|
3
|
-
//
|
|
2
|
+
// digest before its bytes may be published under a release tag, and how a release digest main
|
|
3
|
+
// already promoted is reused.
|
|
4
4
|
//
|
|
5
5
|
// A develop digest is promotable only on a CONJUNCTION, each leg proven from the
|
|
6
6
|
// develop lane's SIGNED provenance and never from a tag:
|
|
@@ -20,7 +20,7 @@ export const GITHUB_OIDC_ISSUER = 'https://token.actions.githubusercontent.com';
|
|
|
20
20
|
export const SLSA_PROVENANCE_V1 = 'https://slsa.dev/provenance/v1';
|
|
21
21
|
const COSIGN_SLSA_V1_TYPE = 'slsaprovenance1';
|
|
22
22
|
const COMMIT = /^[0-9a-f]{40}$/;
|
|
23
|
-
const
|
|
23
|
+
export const IMAGE_PROMOTION_PREDICATE_TYPE = 'https://xema.dev/attestations/image-promotion/v1';
|
|
24
24
|
|
|
25
25
|
const escapeRegExp = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
26
26
|
|
|
@@ -30,22 +30,7 @@ export function workflowIdentityPattern(repository, files, branch) {
|
|
|
30
30
|
return `^${escapeRegExp(`${GITHUB_SERVER}/${repository}/.github/workflows/`)}(?:${alternatives})${escapeRegExp(`@refs/heads/${branch}`)}$`;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
|
|
34
|
-
export function releaseSignedByMain({ subject, repository, releaseWorkflows }, run = runTool) {
|
|
35
|
-
const result = run('cosign', [
|
|
36
|
-
'verify',
|
|
37
|
-
'--certificate-identity-regexp', workflowIdentityPattern(repository, releaseWorkflows, 'main'),
|
|
38
|
-
'--certificate-oidc-issuer', GITHUB_OIDC_ISSUER,
|
|
39
|
-
subject,
|
|
40
|
-
]);
|
|
41
|
-
if (result.error) throw new Error(`could not run cosign to verify ${subject}: ${result.error.message}`);
|
|
42
|
-
if (result.status === 0) return true;
|
|
43
|
-
const detail = describeFailure(result);
|
|
44
|
-
if (UNSIGNED.test(detail)) return false;
|
|
45
|
-
throw new Error(`verifying main's signature on ${subject} failed (not a "no matching signatures"): ${detail}`);
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
function readStatements(stdout, subject) {
|
|
33
|
+
function readStatements(stdout, subject, label) {
|
|
49
34
|
const byDigest = new Map();
|
|
50
35
|
for (const line of stdout.split('\n').map((text) => text.trim()).filter(Boolean)) {
|
|
51
36
|
let payload;
|
|
@@ -54,41 +39,116 @@ function readStatements(stdout, subject) {
|
|
|
54
39
|
payload = Buffer.from(JSON.parse(line).payload, 'base64');
|
|
55
40
|
statement = JSON.parse(payload.toString('utf8'));
|
|
56
41
|
} catch (error) {
|
|
57
|
-
throw new Error(
|
|
42
|
+
throw new Error(`${label}: cosign returned an unreadable attestation for ${subject}`, { cause: error });
|
|
58
43
|
}
|
|
59
44
|
byDigest.set(`sha256:${createHash('sha256').update(payload).digest('hex')}`, statement);
|
|
60
45
|
}
|
|
61
46
|
return byDigest;
|
|
62
47
|
}
|
|
63
48
|
|
|
64
|
-
/**
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
) {
|
|
69
|
-
const subject = `${devRef}@${digest}`;
|
|
70
|
-
const signerPattern = workflowIdentityPattern(repository, [developWorkflow], 'develop');
|
|
71
|
-
|
|
72
|
-
// (a) signer
|
|
49
|
+
/**
|
|
50
|
+
* Runs `cosign verify-attestation` of `type` on `subject` under `pattern` and requires exactly one distinct verified
|
|
51
|
+
* statement whose subject is the digest. Returns `{ statement, payloadDigest }`.
|
|
52
|
+
*/
|
|
53
|
+
function verifiedStatement({ subject, digest, type, predicateType, what, noun, pattern, label }, run) {
|
|
73
54
|
const verified = run('cosign', [
|
|
74
55
|
'verify-attestation',
|
|
75
|
-
'--type',
|
|
76
|
-
'--certificate-identity-regexp',
|
|
56
|
+
'--type', type,
|
|
57
|
+
'--certificate-identity-regexp', pattern,
|
|
77
58
|
'--certificate-oidc-issuer', GITHUB_OIDC_ISSUER,
|
|
78
59
|
subject,
|
|
79
60
|
]);
|
|
80
|
-
if (verified.error
|
|
81
|
-
|
|
61
|
+
if (verified.error) throw new Error(`${label}: could not run cosign to verify ${subject}: ${verified.error.message}`);
|
|
62
|
+
if (verified.status !== 0) {
|
|
63
|
+
const detail = describeFailure(verified);
|
|
64
|
+
throw new Error(`${label}: ${subject} has no ${what} signed by ${pattern}: ${detail}`);
|
|
82
65
|
}
|
|
83
|
-
const statements = readStatements(verified.stdout, subject);
|
|
66
|
+
const statements = readStatements(verified.stdout, subject, label);
|
|
84
67
|
if (statements.size !== 1) {
|
|
85
|
-
throw new Error(
|
|
68
|
+
throw new Error(`${label}: ${subject} carries ${statements.size} distinct verified ${noun} statements; exactly one is promotable`);
|
|
86
69
|
}
|
|
87
|
-
const [[
|
|
70
|
+
const [[payloadDigest, statement]] = statements;
|
|
88
71
|
const hex = digest.slice('sha256:'.length);
|
|
89
|
-
if (statement.predicateType !==
|
|
90
|
-
throw new Error(
|
|
72
|
+
if (statement.predicateType !== predicateType || !statement.subject?.some((entry) => entry?.digest?.sha256 === hex)) {
|
|
73
|
+
throw new Error(`${label}: the verified statement is not ${what} about ${subject}`);
|
|
74
|
+
}
|
|
75
|
+
return { statement, payloadDigest };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The predicate types of every attestation on `subject`, listed without verifying any signature. A registry error is an error. */
|
|
79
|
+
function attestationTypes(subject, label, run) {
|
|
80
|
+
const listed = run('cosign', ['download', 'attestation', subject]);
|
|
81
|
+
if (listed.error) throw new Error(`${label}: could not run cosign to list the attestations of ${subject}: ${listed.error.message}`);
|
|
82
|
+
if (listed.status !== 0) throw new Error(`${label}: listing the attestations of ${subject} failed: ${describeFailure(listed)}`);
|
|
83
|
+
return listed.stdout.split('\n').map((text) => text.trim()).filter(Boolean).map((line) => {
|
|
84
|
+
try {
|
|
85
|
+
return JSON.parse(Buffer.from(JSON.parse(line).payload, 'base64').toString('utf8')).predicateType;
|
|
86
|
+
} catch (error) {
|
|
87
|
+
throw new Error(`${label}: cosign listed an unreadable attestation for ${subject}`, { cause: error });
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Reuse of a release digest main already promoted. Verifies its image-promotion/v1 attestation under one of main's
|
|
94
|
+
* release workflows (anchored identity, GitHub OIDC issuer) and returns `{ source, developProvenance }` copied from it.
|
|
95
|
+
* Returns `undefined` when the digest carries no promotion attestation (a legacy image); a registry error, a signature or identity
|
|
96
|
+
* failure, or a source outside `<registry>/<owner>/dev/`, is an error.
|
|
97
|
+
*/
|
|
98
|
+
export function reuseEarlierPromotion({ releaseRef, tag, digest, repository, releaseWorkflows }, run = runTool) {
|
|
99
|
+
const subject = `${releaseRef}@${digest}`;
|
|
100
|
+
const label = 'reuse';
|
|
101
|
+
// (a) presence, without identity verification: only the predicate types are read
|
|
102
|
+
if (!attestationTypes(subject, label, run).includes(IMAGE_PROMOTION_PREDICATE_TYPE)) return undefined;
|
|
103
|
+
// (b) a promotion attestation is present: any verification failure is an error
|
|
104
|
+
const found = verifiedStatement({
|
|
105
|
+
subject, digest, type: IMAGE_PROMOTION_PREDICATE_TYPE, predicateType: IMAGE_PROMOTION_PREDICATE_TYPE,
|
|
106
|
+
what: 'image-promotion/v1 attestation', noun: 'promotion', pattern: workflowIdentityPattern(repository, releaseWorkflows, 'main'),
|
|
107
|
+
label,
|
|
108
|
+
}, run);
|
|
109
|
+
const { source, developProvenance, release } = found.statement.predicate ?? {};
|
|
110
|
+
for (const [field, expected, stated] of [['digest', digest, release?.digest], ['repository', releaseRef, release?.repository], ['tag', tag, release?.tag]]) {
|
|
111
|
+
if (stated !== expected) throw new Error(`${label}: the promotion attestation of ${subject} states release ${field} ${stated}, not ${expected}`);
|
|
112
|
+
}
|
|
113
|
+
const owner = repository.split('/')[0].toLowerCase();
|
|
114
|
+
const devSource = new RegExp(`^ghcr\\.io/${escapeRegExp(owner)}/dev/[a-z0-9][a-z0-9._/-]*@${escapeRegExp(digest)}$`);
|
|
115
|
+
if (typeof source?.reference !== 'string' || !devSource.test(source.reference)) {
|
|
116
|
+
throw new Error(`${label}: the promotion attestation of ${subject} names source '${source?.reference}', not a ghcr.io/${owner}/dev/<name>@${digest} reference`);
|
|
117
|
+
}
|
|
118
|
+
if (typeof source.revision !== 'string' || !COMMIT.test(source.revision)) {
|
|
119
|
+
throw new Error(`${label}: the promotion attestation of ${subject} names no 40-hex source revision`);
|
|
91
120
|
}
|
|
121
|
+
const { predicateType, payloadDigest, signerIdentityPattern } = developProvenance ?? {};
|
|
122
|
+
if (![predicateType, payloadDigest, signerIdentityPattern].every((value) => typeof value === 'string' && value !== '')) {
|
|
123
|
+
throw new Error(`${label}: the promotion attestation of ${subject} carries no developProvenance`);
|
|
124
|
+
}
|
|
125
|
+
return {
|
|
126
|
+
source: { reference: source.reference, revision: source.revision },
|
|
127
|
+
developProvenance: { predicateType, payloadDigest, signerIdentityPattern },
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Whether `<devRef>@<digest>` carries any SLSA v1 provenance attestation, listed without verifying a signature. A digest
|
|
133
|
+
* with none (an image reused on develop from before provenance) is not promotable but not an error; a registry error is.
|
|
134
|
+
*/
|
|
135
|
+
export function hasDevelopProvenance({ devRef, digest }, run = runTool) {
|
|
136
|
+
return attestationTypes(`${devRef}@${digest}`, 'copy', run).includes(SLSA_PROVENANCE_V1);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Proves legs (a), (b) and (c) for `<devRef>@<digest>`; returns `{ revision, provenanceDigest, signerPattern }`. */
|
|
140
|
+
export function verifyDevelopProvenance(
|
|
141
|
+
{ devRef, digest, repository, developWorkflow, mainRef, commit, tag, tagCommand },
|
|
142
|
+
run = runTool,
|
|
143
|
+
) {
|
|
144
|
+
const subject = `${devRef}@${digest}`;
|
|
145
|
+
const signerPattern = workflowIdentityPattern(repository, [developWorkflow], 'develop');
|
|
146
|
+
|
|
147
|
+
// (a) signer
|
|
148
|
+
const { statement, payloadDigest: provenanceDigest } = verifiedStatement(
|
|
149
|
+
{ subject, digest, type: COSIGN_SLSA_V1_TYPE, predicateType: SLSA_PROVENANCE_V1, what: 'SLSA v1 provenance', noun: 'provenance', pattern: signerPattern, label: 'leg (a) signer' },
|
|
150
|
+
run,
|
|
151
|
+
);
|
|
92
152
|
const revision = statement.predicate?.runDetails?.metadata?.buildkit_metadata?.vcs?.revision;
|
|
93
153
|
if (typeof revision !== 'string' || !COMMIT.test(revision)) {
|
|
94
154
|
throw new Error(`leg (a) signer: the provenance of ${subject} names no 40-hex vcs.revision`);
|
package/src/promote-image.mjs
CHANGED
|
@@ -3,20 +3,24 @@
|
|
|
3
3
|
// DEVELOP repository already holds for one content-addressed tag, instead of
|
|
4
4
|
// rebuilding them, and only when the develop bytes are PROVEN promotable.
|
|
5
5
|
//
|
|
6
|
-
// 1. `<release-ref>:<tag>` exists:
|
|
7
|
-
// -
|
|
8
|
-
//
|
|
6
|
+
// 1. `<release-ref>:<tag>` exists: its image-promotion/v1 attestation must verify under one of main's
|
|
7
|
+
// `--release-workflow` identities (exactly one statement about the digest), and its source must be a
|
|
8
|
+
// `ghcr.io/<owner>/dev/<name>` reference -> reused (evidence=prior-promotion)
|
|
9
|
+
// - no image-promotion/v1 attestation (a legacy image) -> miss (reason=reuse-no-promotion): the caller builds
|
|
10
|
+
// - a signature / identity failure, or a source outside dev/ -> error, never a miss
|
|
9
11
|
// 2. else `<dev-ref>:<tag>` exists: prove it (legs a, b, c in
|
|
10
12
|
// promote-image-provenance.mjs), copy it by digest (`imagetools create`
|
|
11
13
|
// from `<dev-ref>@<digest>`, so an index stays whole), re-probe, and refuse
|
|
12
14
|
// unless the release digest equals the source -> copied (evidence=develop-provenance)
|
|
15
|
+
// - no SLSA provenance on the dev digest at all (reused on develop from before the swap) -> miss (reason=copy-no-develop-provenance)
|
|
16
|
+
// - provenance present but any leg fails -> error, never a miss
|
|
13
17
|
// 3. else -> miss: the caller builds.
|
|
14
18
|
//
|
|
15
|
-
//
|
|
16
|
-
// (`https://xema.dev/attestations/image-promotion/v1`) to
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
// promotion, never a signature the release side trusts.
|
|
19
|
+
// On every non-miss result it writes the promotion predicate
|
|
20
|
+
// (`https://xema.dev/attestations/image-promotion/v1`) to `--promotion-predicate-out`, for the caller to
|
|
21
|
+
// `cosign attest` under main's own identity. A copy states the facts it just proved; a reuse states this run's
|
|
22
|
+
// release and commit and carries `source` and `developProvenance` from main's earlier promotion. It signs
|
|
23
|
+
// nothing itself; the develop identity is evidence for the promotion, never a signature the release side trusts.
|
|
20
24
|
//
|
|
21
25
|
// `--tag` is the caller's content key, and everything after `--` is the command
|
|
22
26
|
// that computes it (e.g. `node tooling/release/compute-image-tag.mjs --service
|
|
@@ -30,22 +34,45 @@
|
|
|
30
34
|
// Outputs: `result`, `digest`, `source`, `revision`, `evidence`,
|
|
31
35
|
// `provenance_digest`, `promotion_predicate` and `promotion_predicate_type` appended to `$GITHUB_OUTPUT`
|
|
32
36
|
// (required; empty values where a fact does not apply), and one stdout line
|
|
33
|
-
// `promote-image result=<r> digest=<d> src=<repository>@<d> revision=<sha> evidence=<e
|
|
37
|
+
// `promote-image result=<r> digest=<d> src=<repository>@<d> revision=<sha> evidence=<e>`, with ` reason=<why>` appended
|
|
38
|
+
// to a miss that has a reason.
|
|
34
39
|
|
|
35
40
|
import { appendFileSync, existsSync } from 'node:fs';
|
|
36
41
|
import process from 'node:process';
|
|
37
42
|
import { describeErrorChain } from '@xemahq/release-policy/registry';
|
|
38
43
|
import { invokedDirectly } from './readiness.mjs';
|
|
39
|
-
import { IMAGE_PROMOTION_PREDICATE_TYPE, writePromotionPredicate } from './promote-image-predicate.mjs';
|
|
40
|
-
import {
|
|
44
|
+
import { IMAGE_PROMOTION_PREDICATE_TYPE, promotionPredicate, reusedPromotionPredicate, writePromotionPredicate } from './promote-image-predicate.mjs';
|
|
45
|
+
import { hasDevelopProvenance, reuseEarlierPromotion, verifyDevelopProvenance } from './promote-image-provenance.mjs';
|
|
41
46
|
import { copyByDigest, probeDigest, runTool } from './promote-image-registry.mjs';
|
|
42
47
|
|
|
43
48
|
export const PromoteResult = Object.freeze({ REUSED: 'reused', COPIED: 'copied', MISS: 'miss' });
|
|
44
49
|
export const PromoteEvidence = Object.freeze({
|
|
45
|
-
|
|
50
|
+
PRIOR_PROMOTION: 'prior-promotion',
|
|
46
51
|
DEVELOP_PROVENANCE: 'develop-provenance',
|
|
47
52
|
NONE: 'none',
|
|
48
53
|
});
|
|
54
|
+
export const PromoteMissReason = Object.freeze({ NO_PROMOTION: 'reuse-no-promotion', NO_DEVELOP_PROVENANCE: 'copy-no-develop-provenance' });
|
|
55
|
+
|
|
56
|
+
// The one cosign major shared by the develop attest step, this verb and xema-deploy's verify (xema-deploy pins
|
|
57
|
+
// v2.x in scripts/install-cosign.sh and refuses anything else). v2 `attest` stores `.att` tags; v3 stores
|
|
58
|
+
// bundles/referrers, so a mixed pair makes `cosign download attestation` see nothing and every promotion
|
|
59
|
+
// would silently become a build.
|
|
60
|
+
export const COSIGN_MAJOR = 2;
|
|
61
|
+
|
|
62
|
+
/** Runs `cosign version` once and refuses unless its GitVersion is major `COSIGN_MAJOR`. */
|
|
63
|
+
export function assertCosignMajor(run = runTool) {
|
|
64
|
+
const result = run('cosign', ['version']);
|
|
65
|
+
if (result.error) throw new Error(`could not run cosign to read its version: ${result.error.message}`);
|
|
66
|
+
if (result.status !== 0) throw new Error(`cosign version failed: ${(result.stderr || result.stdout || `exit ${result.status}`).trim()}`);
|
|
67
|
+
const match = /^GitVersion:\s*v?(\d+)\.\d+\.\d+/m.exec(result.stdout ?? '');
|
|
68
|
+
if (match === null) throw new Error('cosign version printed no parsable GitVersion; refusing to promote with an unknown cosign');
|
|
69
|
+
if (Number(match[1]) !== COSIGN_MAJOR) {
|
|
70
|
+
throw new Error(
|
|
71
|
+
`cosign major ${match[1]} found, but this verb needs cosign v${COSIGN_MAJOR}.x: v2 and v3 store attestations differently, ` +
|
|
72
|
+
'so a mismatch with the develop attest step would make every promotion silently a build. Refusing.',
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
49
76
|
|
|
50
77
|
const USAGE =
|
|
51
78
|
'usage: xema-promote-image --service <name> --dev-ref <registry/repository> --release-ref <registry/repository> ' +
|
|
@@ -115,29 +142,32 @@ export function parsePromoteArguments(argv) {
|
|
|
115
142
|
|
|
116
143
|
const MISS = Object.freeze({ result: PromoteResult.MISS, digest: '', source: '', evidence: PromoteEvidence.NONE });
|
|
117
144
|
|
|
118
|
-
/**
|
|
145
|
+
/**
|
|
146
|
+
* Runs the steps. Returns `{ result, digest, source, evidence, predicate?, revision?, provenanceDigest?, reason? }`;
|
|
147
|
+
* `predicate` is set on every non-miss result, `reason` on a miss that has one.
|
|
148
|
+
*/
|
|
119
149
|
export function promoteImage(options, run = runTool) {
|
|
120
|
-
const { service, devRef, releaseRef, tag, repository, releaseWorkflows } = options;
|
|
150
|
+
const { service, devRef, releaseRef, tag, commit, repository, releaseWorkflows } = options;
|
|
151
|
+
assertCosignMajor(run);
|
|
121
152
|
const releaseTagged = `${releaseRef}:${tag}`;
|
|
122
153
|
const released = probeDigest(releaseTagged, run);
|
|
123
154
|
if (released !== undefined) {
|
|
124
|
-
const
|
|
125
|
-
if (
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
)
|
|
134
|
-
}
|
|
135
|
-
const proof = verifyDevelopProvenance({ ...options, digest: released }, run);
|
|
136
|
-
return { result: PromoteResult.REUSED, digest: released, source: `${devRef}@${released}`, evidence: PromoteEvidence.DEVELOP_PROVENANCE, proof };
|
|
155
|
+
const earlier = reuseEarlierPromotion({ releaseRef, tag, digest: released, repository, releaseWorkflows }, run);
|
|
156
|
+
if (earlier === undefined) return { ...MISS, reason: PromoteMissReason.NO_PROMOTION };
|
|
157
|
+
return {
|
|
158
|
+
result: PromoteResult.REUSED,
|
|
159
|
+
digest: released,
|
|
160
|
+
source: earlier.source.reference,
|
|
161
|
+
evidence: PromoteEvidence.PRIOR_PROMOTION,
|
|
162
|
+
revision: earlier.source.revision,
|
|
163
|
+
provenanceDigest: earlier.developProvenance.payloadDigest,
|
|
164
|
+
predicate: reusedPromotionPredicate({ releaseRef, tag, digest: released, commit, ...earlier }),
|
|
165
|
+
};
|
|
137
166
|
}
|
|
138
167
|
const developed = probeDigest(`${devRef}:${tag}`, run);
|
|
139
168
|
if (developed === undefined) return MISS;
|
|
140
169
|
|
|
170
|
+
if (!hasDevelopProvenance({ devRef, digest: developed }, run)) return { ...MISS, reason: PromoteMissReason.NO_DEVELOP_PROVENANCE };
|
|
141
171
|
const proof = verifyDevelopProvenance({ ...options, digest: developed }, run);
|
|
142
172
|
const source = `${devRef}@${developed}`;
|
|
143
173
|
copyByDigest(source, releaseTagged, run);
|
|
@@ -148,7 +178,15 @@ export function promoteImage(options, run = runTool) {
|
|
|
148
178
|
'the copy did not preserve the digest, so the release tag does not name the develop bytes. Refusing.',
|
|
149
179
|
);
|
|
150
180
|
}
|
|
151
|
-
return {
|
|
181
|
+
return {
|
|
182
|
+
result: PromoteResult.COPIED,
|
|
183
|
+
digest: developed,
|
|
184
|
+
source,
|
|
185
|
+
evidence: PromoteEvidence.DEVELOP_PROVENANCE,
|
|
186
|
+
revision: proof.revision,
|
|
187
|
+
provenanceDigest: proof.provenanceDigest,
|
|
188
|
+
predicate: promotionPredicate({ releaseRef, tag, digest: developed, source, commit, ...proof }),
|
|
189
|
+
};
|
|
152
190
|
}
|
|
153
191
|
|
|
154
192
|
export function main(argv, { githubOutput, run = runTool, write = (text) => process.stdout.write(text) } = {}) {
|
|
@@ -157,29 +195,20 @@ export function main(argv, { githubOutput, run = runTool, write = (text) => proc
|
|
|
157
195
|
if (existsSync(options.predicateOut)) {
|
|
158
196
|
throw new Error(`--promotion-predicate-out ${options.predicateOut} already exists; refusing to leave a stale predicate to attest`);
|
|
159
197
|
}
|
|
160
|
-
const { result, digest, source, evidence,
|
|
161
|
-
if (
|
|
162
|
-
writePromotionPredicate(options.predicateOut, {
|
|
163
|
-
releaseRef: options.releaseRef,
|
|
164
|
-
tag: options.tag,
|
|
165
|
-
digest,
|
|
166
|
-
source,
|
|
167
|
-
commit: options.commit,
|
|
168
|
-
...proof,
|
|
169
|
-
});
|
|
170
|
-
}
|
|
198
|
+
const { result, digest, source, evidence, predicate, revision, provenanceDigest, reason } = promoteImage(options, run);
|
|
199
|
+
if (predicate !== undefined) writePromotionPredicate(options.predicateOut, predicate);
|
|
171
200
|
const outputs = {
|
|
172
201
|
result,
|
|
173
202
|
digest,
|
|
174
203
|
source,
|
|
175
|
-
revision:
|
|
204
|
+
revision: revision ?? '',
|
|
176
205
|
evidence,
|
|
177
|
-
provenance_digest:
|
|
178
|
-
promotion_predicate:
|
|
179
|
-
promotion_predicate_type:
|
|
206
|
+
provenance_digest: provenanceDigest ?? '',
|
|
207
|
+
promotion_predicate: predicate === undefined ? '' : options.predicateOut,
|
|
208
|
+
promotion_predicate_type: predicate === undefined ? '' : IMAGE_PROMOTION_PREDICATE_TYPE,
|
|
180
209
|
};
|
|
181
210
|
appendFileSync(githubOutput, Object.entries(outputs).map(([key, value]) => `${key}=${value}\n`).join(''));
|
|
182
|
-
write(`promote-image result=${result} digest=${digest} src=${source} revision=${outputs.revision} evidence=${evidence}\n`);
|
|
211
|
+
write(`promote-image result=${result} digest=${digest} src=${source} revision=${outputs.revision} evidence=${evidence}${reason === undefined ? '' : ` reason=${reason}`}\n`);
|
|
183
212
|
}
|
|
184
213
|
|
|
185
214
|
if (invokedDirectly(process.argv[1], import.meta.url)) {
|
|
@@ -23,7 +23,8 @@ const PROMOTION_TYPE = 'https://xema.dev/attestations/image-promotion/v1';
|
|
|
23
23
|
// answering from one JSON state file:
|
|
24
24
|
// tags / errors / copyDigest `docker buildx imagetools inspect|create`
|
|
25
25
|
// signatures[subject] the identity `cosign verify` finds
|
|
26
|
-
//
|
|
26
|
+
// downloadErrors[subject] what `cosign download attestation` fails with
|
|
27
|
+
// attestations[subject] { identity, statements, type? } (type defaults to slsaprovenance1) `cosign verify-attestation` finds
|
|
27
28
|
// ancestors revisions `git merge-base --is-ancestor` accepts
|
|
28
29
|
// hashes[commit] what the tag command prints for `--commit <commit>`
|
|
29
30
|
// Every call is recorded in `calls` as [tool, ...args].
|
|
@@ -50,8 +51,21 @@ if (tool === 'docker') {
|
|
|
50
51
|
if (args[2] === 'create') { state.tags[args[4]] = state.copyDigest ?? args[5].split('@')[1]; save(); process.exit(0); }
|
|
51
52
|
fail('unexpected docker ' + args.join(' '), 2);
|
|
52
53
|
}
|
|
54
|
+
if (tool === 'cosign' && args[0] === 'version') {
|
|
55
|
+
process.stdout.write(state.cosignVersion ?? 'GitVersion: v2.6.3\\nGitCommit: abc\\n');
|
|
56
|
+
process.exit(0);
|
|
57
|
+
}
|
|
53
58
|
if (tool === 'cosign') {
|
|
54
59
|
const subject = args[args.length - 1];
|
|
60
|
+
if (args[0] === 'download' && args[1] === 'attestation') {
|
|
61
|
+
if (state.downloadErrors[subject]) fail(state.downloadErrors[subject]);
|
|
62
|
+
const listed = state.attestations[subject];
|
|
63
|
+
if (!listed) fail('Error: no attestations found for ' + subject + '\\n');
|
|
64
|
+
for (const statement of listed.statements) {
|
|
65
|
+
process.stdout.write(JSON.stringify({ payloadType: 'application/vnd.in-toto+json', payload: Buffer.from(statement).toString('base64'), signatures: [] }) + '\\n');
|
|
66
|
+
}
|
|
67
|
+
process.exit(0);
|
|
68
|
+
}
|
|
55
69
|
const pattern = new RegExp(flag('--certificate-identity-regexp'));
|
|
56
70
|
if (flag('--certificate-oidc-issuer') !== 'https://token.actions.githubusercontent.com') fail('wrong issuer', 2);
|
|
57
71
|
if (args[0] === 'verify') {
|
|
@@ -60,7 +74,10 @@ if (tool === 'cosign') {
|
|
|
60
74
|
}
|
|
61
75
|
if (args[0] === 'verify-attestation') {
|
|
62
76
|
const found = state.attestations[subject];
|
|
63
|
-
if (!found || !pattern.test(found.identity)
|
|
77
|
+
if (!found || !pattern.test(found.identity)) fail('Error: no matching attestations: none of the expected identities matched\\n');
|
|
78
|
+
const wanted = flag('--type');
|
|
79
|
+
const have = found.type ?? 'slsaprovenance1';
|
|
80
|
+
if (wanted !== have) fail('Error: none of the attestations matched the predicate type: ' + wanted + ', found: ' + have + '\\n');
|
|
64
81
|
for (const statement of found.statements) {
|
|
65
82
|
process.stdout.write(JSON.stringify({ payloadType: 'application/vnd.in-toto+json', payload: Buffer.from(statement).toString('base64'), signatures: [] }) + '\\n');
|
|
66
83
|
}
|
|
@@ -108,7 +125,7 @@ function scenario(state) {
|
|
|
108
125
|
fs.mkdirSync(binDir);
|
|
109
126
|
for (const tool of ['docker', 'cosign', 'git', 'tag-command']) fs.writeFileSync(path.join(binDir, tool), SHIM, { mode: 0o755 });
|
|
110
127
|
const stateFile = path.join(dir, 'state.json');
|
|
111
|
-
fs.writeFileSync(stateFile, JSON.stringify({ tags: {}, errors: {}, signatures: {}, attestations: {}, ancestors: [], hashes: {}, calls: [], ...state }));
|
|
128
|
+
fs.writeFileSync(stateFile, JSON.stringify({ tags: {}, errors: {}, downloadErrors: {}, signatures: {}, attestations: {}, ancestors: [], hashes: {}, calls: [], ...state }));
|
|
112
129
|
const output = path.join(dir, 'github-output');
|
|
113
130
|
fs.writeFileSync(output, '');
|
|
114
131
|
const predicate = path.join(dir, 'promotion.json');
|
|
@@ -155,24 +172,100 @@ function refused(state, pattern) {
|
|
|
155
172
|
}
|
|
156
173
|
}
|
|
157
174
|
|
|
158
|
-
|
|
175
|
+
const sha = (text) => `sha256:${createHash('sha256').update(text).digest('hex')}`;
|
|
176
|
+
const EARLIER_PROVENANCE = {
|
|
177
|
+
predicateType: 'https://slsa.dev/provenance/v1',
|
|
178
|
+
payloadDigest: sha('earlier-provenance'),
|
|
179
|
+
signerIdentityPattern: '^https://github\\.com/acme/svc/\\.github/workflows/(?:develop-images\\.yaml)@refs/heads/develop$',
|
|
180
|
+
};
|
|
181
|
+
const EARLIER_COMMIT = '5'.repeat(40);
|
|
182
|
+
|
|
183
|
+
// A release digest main already promoted: its image-promotion/v1 attestation, signed by a main workflow.
|
|
184
|
+
function promotedRelease({ source = `${DEV}@${OTHER}`, identity = MAIN_IDENTITY, type = PROMOTION_TYPE, extra = {} } = {}) {
|
|
185
|
+
const statement = JSON.stringify({
|
|
186
|
+
_type: 'https://in-toto.io/Statement/v1',
|
|
187
|
+
subject: [{ name: RELEASE, digest: { sha256: OTHER.slice('sha256:'.length) } }],
|
|
188
|
+
predicateType: PROMOTION_TYPE,
|
|
189
|
+
predicate: {
|
|
190
|
+
release: { repository: RELEASE, tag: TAG, digest: OTHER },
|
|
191
|
+
source: { reference: source, revision: REVISION },
|
|
192
|
+
promotedCommit: EARLIER_COMMIT,
|
|
193
|
+
developProvenance: EARLIER_PROVENANCE,
|
|
194
|
+
...extra,
|
|
195
|
+
},
|
|
196
|
+
});
|
|
197
|
+
return {
|
|
198
|
+
tags: { [`${RELEASE}:${TAG}`]: OTHER },
|
|
199
|
+
attestations: { [`${RELEASE}@${OTHER}`]: { identity, type, statements: [statement] } },
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
test('a release digest main already promoted is reused: source and developProvenance carry over, release and commit are this run', () => {
|
|
204
|
+
const { run, predicatePath, cleanup } = scenario(promotedRelease());
|
|
205
|
+
try {
|
|
206
|
+
const r = run();
|
|
207
|
+
assert.equal(r.status, 0, r.stderr);
|
|
208
|
+
assert.equal(r.stdout, `promote-image result=reused digest=${OTHER} src=${DEV}@${OTHER} revision=${REVISION} evidence=prior-promotion\n`);
|
|
209
|
+
assert.deepEqual(r.outputs, {
|
|
210
|
+
result: 'reused', digest: OTHER, source: `${DEV}@${OTHER}`, revision: REVISION, evidence: 'prior-promotion',
|
|
211
|
+
provenance_digest: EARLIER_PROVENANCE.payloadDigest, promotion_predicate: predicatePath, promotion_predicate_type: PROMOTION_TYPE,
|
|
212
|
+
});
|
|
213
|
+
assert.deepEqual(r.predicate, {
|
|
214
|
+
release: { repository: RELEASE, tag: TAG, digest: OTHER },
|
|
215
|
+
source: { reference: `${DEV}@${OTHER}`, revision: REVISION },
|
|
216
|
+
promotedCommit: COMMIT,
|
|
217
|
+
developProvenance: EARLIER_PROVENANCE,
|
|
218
|
+
});
|
|
219
|
+
assert.deepEqual(creates(r.state), []);
|
|
220
|
+
assert.equal(r.state.calls.some((call) => call[0] === 'docker' && call[3] === `${DEV}:${TAG}`), false);
|
|
221
|
+
} finally {
|
|
222
|
+
cleanup();
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test('a release digest with only SLSA provenance is a miss with a reason', () => {
|
|
159
227
|
const { run, cleanup } = scenario({
|
|
160
228
|
tags: { [`${RELEASE}:${TAG}`]: OTHER },
|
|
161
|
-
|
|
229
|
+
attestations: { [`${RELEASE}@${OTHER}`]: { identity: MAIN_IDENTITY, statements: [provenance(OTHER)] } },
|
|
162
230
|
});
|
|
163
231
|
try {
|
|
164
232
|
const r = run();
|
|
165
233
|
assert.equal(r.status, 0, r.stderr);
|
|
166
|
-
assert.equal(r.stdout,
|
|
167
|
-
assert.equal(r.outputs.result, '
|
|
168
|
-
assert.equal(r.outputs.promotion_predicate, '');
|
|
234
|
+
assert.equal(r.stdout, 'promote-image result=miss digest= src= revision= evidence=none reason=reuse-no-promotion\n');
|
|
235
|
+
assert.equal(r.outputs.result, 'miss');
|
|
169
236
|
assert.equal(r.predicate, undefined);
|
|
170
|
-
assert.deepEqual(creates(r.state), []);
|
|
171
237
|
} finally {
|
|
172
238
|
cleanup();
|
|
173
239
|
}
|
|
174
240
|
});
|
|
175
241
|
|
|
242
|
+
test('a registry error listing the attestations is an error, not a miss', () => {
|
|
243
|
+
refused(
|
|
244
|
+
{ tags: { [`${RELEASE}:${TAG}`]: OTHER }, downloadErrors: { [`${RELEASE}@${OTHER}`]: 'Error: dial tcp: lookup ghcr.io: no such host\n' } },
|
|
245
|
+
/reuse: listing the attestations of .* failed: .*no such host/,
|
|
246
|
+
);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test('a promotion attestation signed by the wrong identity is refused, not a miss', () => {
|
|
250
|
+
refused(promotedRelease({ identity: DEVELOP_IDENTITY }), /reuse: .* has no image-promotion\/v1 attestation signed by/);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test('a promotion attestation whose source is outside dev/ is refused', () => {
|
|
254
|
+
refused(promotedRelease({ source: `ghcr.io/acme/svc@${OTHER}` }), /names source 'ghcr\.io\/acme\/svc@sha256:e+', not a ghcr\.io\/acme\/dev\//);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test('a promotion attestation stating another release repository is refused', () => {
|
|
258
|
+
refused(promotedRelease({ extra: { release: { repository: 'ghcr.io/acme/other', tag: TAG, digest: OTHER } } }), /states release repository ghcr\.io\/acme\/other, not /);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test('a promotion attestation stating another release tag is refused', () => {
|
|
262
|
+
refused(promotedRelease({ extra: { release: { repository: RELEASE, tag: 'a'.repeat(64), digest: OTHER } } }), /states release tag a+, not c+/);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test('a promotion attestation stating another release digest is refused', () => {
|
|
266
|
+
refused(promotedRelease({ extra: { release: { repository: RELEASE, tag: TAG, digest: DIGEST } } }), /states release digest sha256:d+, not /);
|
|
267
|
+
});
|
|
268
|
+
|
|
176
269
|
test('a proven develop digest is copied by digest and the promotion predicate states the facts', () => {
|
|
177
270
|
const { run, predicatePath, cleanup } = scenario(provenDevelop());
|
|
178
271
|
try {
|
|
@@ -200,22 +293,33 @@ test('a proven develop digest is copied by digest and the promotion predicate st
|
|
|
200
293
|
}
|
|
201
294
|
});
|
|
202
295
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
assert.equal(r.outputs.result, 'reused');
|
|
209
|
-
assert.equal(r.outputs.evidence, 'develop-provenance');
|
|
210
|
-
assert.equal(r.predicate.source.revision, REVISION);
|
|
211
|
-
assert.deepEqual(creates(r.state), []);
|
|
212
|
-
} finally {
|
|
213
|
-
cleanup();
|
|
214
|
-
}
|
|
296
|
+
const SBOM = JSON.stringify({
|
|
297
|
+
_type: 'https://in-toto.io/Statement/v1',
|
|
298
|
+
subject: [{ name: DEV, digest: { sha256: DIGEST.slice('sha256:'.length) } }],
|
|
299
|
+
predicateType: 'https://spdx.dev/Document',
|
|
300
|
+
predicate: {},
|
|
215
301
|
});
|
|
216
302
|
|
|
217
|
-
|
|
218
|
-
|
|
303
|
+
for (const [name, statements] of [['no attestations', []], ['only an SBOM attestation', [SBOM]]]) {
|
|
304
|
+
test(`a dev digest with ${name} is a miss with a reason, not an error`, () => {
|
|
305
|
+
const { run, cleanup } = scenario(provenDevelop({ attestations: { [`${DEV}@${DIGEST}`]: { identity: DEVELOP_IDENTITY, statements } } }));
|
|
306
|
+
try {
|
|
307
|
+
const r = run();
|
|
308
|
+
assert.equal(r.status, 0, r.stderr);
|
|
309
|
+
assert.equal(r.stdout, 'promote-image result=miss digest= src= revision= evidence=none reason=copy-no-develop-provenance\n');
|
|
310
|
+
assert.equal(r.predicate, undefined);
|
|
311
|
+
assert.deepEqual(creates(r.state), []);
|
|
312
|
+
} finally {
|
|
313
|
+
cleanup();
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
test('a registry error listing the dev attestations is an error, not a miss', () => {
|
|
319
|
+
refused(
|
|
320
|
+
provenDevelop({ downloadErrors: { [`${DEV}@${DIGEST}`]: 'Error: dial tcp: lookup ghcr.io: no such host\n' } }),
|
|
321
|
+
/copy: listing the attestations of .* failed: .*no such host/,
|
|
322
|
+
);
|
|
219
323
|
});
|
|
220
324
|
|
|
221
325
|
test('leg (a): provenance signed by a unit-branch workflow is refused before any copy', () => {
|
|
@@ -266,7 +370,7 @@ test('a definite not-found in both repositories is a miss, not an error', () =>
|
|
|
266
370
|
assert.equal(r.outputs.result, 'miss');
|
|
267
371
|
assert.equal(r.outputs.digest, '');
|
|
268
372
|
assert.equal(r.predicate, undefined);
|
|
269
|
-
assert.equal(r.state.calls.length,
|
|
373
|
+
assert.equal(r.state.calls.length, 3);
|
|
270
374
|
} finally {
|
|
271
375
|
cleanup();
|
|
272
376
|
}
|
|
@@ -324,3 +428,26 @@ test('missing, malformed or unknown flags are refused before any external call',
|
|
|
324
428
|
cleanup();
|
|
325
429
|
}
|
|
326
430
|
});
|
|
431
|
+
|
|
432
|
+
test('cosign of the pinned major passes and is asked once, before any other cosign call', () => {
|
|
433
|
+
const { run, cleanup } = scenario(provenDevelop());
|
|
434
|
+
try {
|
|
435
|
+
const r = run();
|
|
436
|
+
assert.equal(r.status, 0, r.stderr);
|
|
437
|
+
const cosign = r.state.calls.filter((call) => call[0] === 'cosign');
|
|
438
|
+
assert.deepEqual(cosign[0], ['cosign', 'version']);
|
|
439
|
+
assert.equal(cosign.filter((call) => call[1] === 'version').length, 1);
|
|
440
|
+
} finally {
|
|
441
|
+
cleanup();
|
|
442
|
+
}
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
test('a cosign of another major is refused before any registry call', () => {
|
|
446
|
+
const r = refused({ ...provenDevelop(), cosignVersion: 'GitVersion: v3.0.2\n' }, /cosign major 3 found, but this verb needs cosign v2\.x/);
|
|
447
|
+
assert.deepEqual(r.state.calls, [['cosign', 'version']]);
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
test('an unparsable cosign version is refused', () => {
|
|
451
|
+
const r = refused({ ...provenDevelop(), cosignVersion: 'GitVersion: devel\n' }, /no parsable GitVersion/);
|
|
452
|
+
assert.deepEqual(r.state.calls, [['cosign', 'version']]);
|
|
453
|
+
});
|