@mcpg-dev/pulumi-policy 0.1.0-beta.8
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/.github/ISSUE_TEMPLATE/config.yml +8 -0
- package/.github/workflows/ci.yml +29 -0
- package/.github/workflows/release.yml +18 -0
- package/CONTRIBUTING.md +24 -0
- package/README.md +117 -0
- package/bin/index.d.ts +1 -0
- package/bin/index.js +26 -0
- package/bin/validators.d.ts +25 -0
- package/bin/validators.js +378 -0
- package/bin/validators.test.d.ts +1 -0
- package/bin/validators.test.js +74 -0
- package/package.json +21 -0
- package/src/index.ts +26 -0
- package/src/validators.test.ts +89 -0
- package/src/validators.ts +335 -0
- package/tsconfig.json +15 -0
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
blank_issues_enabled: true
|
|
2
|
+
contact_links:
|
|
3
|
+
- name: Security report
|
|
4
|
+
url: mailto:security@mcpg.dev
|
|
5
|
+
about: Please report vulnerabilities privately by email, not as a public issue.
|
|
6
|
+
- name: Pull requests
|
|
7
|
+
url: https://github.com/mcpg-dev
|
|
8
|
+
about: PRs cannot be merged here — development happens upstream. File an issue instead.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Per-push checks for the npm package. Publishing lives in release.yml.
|
|
2
|
+
name: CI
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
workflow_dispatch:
|
|
8
|
+
jobs:
|
|
9
|
+
check:
|
|
10
|
+
name: install · build · typecheck · test
|
|
11
|
+
if: vars.OSS_CI_ENABLED == 'true'
|
|
12
|
+
runs-on: ubuntu-latest
|
|
13
|
+
steps:
|
|
14
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
15
|
+
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
|
16
|
+
with:
|
|
17
|
+
node-version: 22
|
|
18
|
+
# Unlocked on purpose: no lockfile ships with the source, so the
|
|
19
|
+
# install resolves fresh.
|
|
20
|
+
- run: npm install --no-audit --no-fund
|
|
21
|
+
- run: npm run build
|
|
22
|
+
- name: Typecheck
|
|
23
|
+
run: |
|
|
24
|
+
set -euo pipefail
|
|
25
|
+
if jq -e '.scripts.typecheck' package.json >/dev/null; then npm run typecheck; fi
|
|
26
|
+
- name: Test
|
|
27
|
+
run: |
|
|
28
|
+
set -euo pipefail
|
|
29
|
+
if jq -e '.scripts.test' package.json >/dev/null; then npm test; fi
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Thin shim: fires on the v{version} release tag and calls the shared
|
|
2
|
+
# non-Rust release workflow with this repo's tier. Managed by the release
|
|
3
|
+
# pipeline — changes made here are overwritten.
|
|
4
|
+
name: Release
|
|
5
|
+
on:
|
|
6
|
+
push:
|
|
7
|
+
tags: ["v*"]
|
|
8
|
+
workflow_dispatch:
|
|
9
|
+
permissions:
|
|
10
|
+
contents: write
|
|
11
|
+
id-token: write
|
|
12
|
+
packages: write
|
|
13
|
+
jobs:
|
|
14
|
+
release:
|
|
15
|
+
uses: mcpg-dev/.github/.github/workflows/release-nonrust.yml@v1
|
|
16
|
+
with:
|
|
17
|
+
tier: npm
|
|
18
|
+
secrets: inherit
|
package/CONTRIBUTING.md
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Contributing
|
|
2
|
+
|
|
3
|
+
Development of this project happens upstream; this repository publishes
|
|
4
|
+
each release as a tagged snapshot.
|
|
5
|
+
|
|
6
|
+
## What this means
|
|
7
|
+
|
|
8
|
+
- **Pull requests cannot be merged here.** The contents are replaced
|
|
9
|
+
wholesale at every release, so a PR opened against this repository has
|
|
10
|
+
nothing durable to land on. PRs are closed automatically with a link to
|
|
11
|
+
this page.
|
|
12
|
+
- **Issues are very welcome.** Bug reports, feature requests, and questions
|
|
13
|
+
filed here are read and triaged. Please include the release version you
|
|
14
|
+
are using and a minimal reproduction.
|
|
15
|
+
- **Security reports:** please email **security@mcpg.dev** rather than
|
|
16
|
+
opening a public issue.
|
|
17
|
+
|
|
18
|
+
## Versions
|
|
19
|
+
|
|
20
|
+
Releases are git tags (`v{version}`); each tagged commit is a complete
|
|
21
|
+
snapshot of this project at that release. Dependency updates arrive with
|
|
22
|
+
the next release; this repository runs no Dependabot/Renovate.
|
|
23
|
+
|
|
24
|
+
Thanks for using MCPG.
|
package/README.md
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# @mcpg/pulumi-policy
|
|
2
|
+
|
|
3
|
+
A Pulumi **CrossGuard** policy pack that enforces the MCPG Kubernetes operator's
|
|
4
|
+
admission rules at `pulumi preview`, before anything reaches a cluster. It
|
|
5
|
+
inspects the `mcpg.dev` custom resources a program declares — gateways,
|
|
6
|
+
plugins, plugin sets, revocation lists, clusters, routes, tenants and plugin
|
|
7
|
+
mirrors — and reports the violations the operator's admission webhook would
|
|
8
|
+
return, locally and with no API-server round trip.
|
|
9
|
+
|
|
10
|
+
## Quick start
|
|
11
|
+
|
|
12
|
+
The pack is referenced at preview/update time rather than imported into your
|
|
13
|
+
program:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm i --save-dev @mcpg/pulumi-policy
|
|
17
|
+
pulumi preview --policy-pack node_modules/@mcpg/pulumi-policy
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
A violation is printed as `[<rule-id>] <message>` — for example
|
|
21
|
+
`[gateway.workloadIdentity.oneOf] workloadIdentity must be exactly one of
|
|
22
|
+
aws|gcp|azure|spiffe`. The rule id is stable, and is the same identifier the
|
|
23
|
+
MCPG Terraform provider's plan-time validators emit.
|
|
24
|
+
|
|
25
|
+
`main` points at `bin/index.js`, so a source checkout has to be compiled before
|
|
26
|
+
Pulumi can load it:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
npm install && npm run build # tsc → bin/
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Rules
|
|
33
|
+
|
|
34
|
+
| Kind | What it checks |
|
|
35
|
+
|---|---|
|
|
36
|
+
| `MCPGGateway` | `spec.image` present; `replicas` ≥ 1 when set; at most one of `workloadIdentity.{aws,gcp,azure,spiffe}`; a present `ingress` block carries a non-empty `ingressClassName`, at least one host, and non-empty `paths` per host. |
|
|
37
|
+
| `MCPGPlugin` | `pluginId` non-empty and reverse-DNS; `version` non-empty; `pluginClass` is a known plugin class; `oci.image` carries a registry component and a tag or digest; the artifact is digest-pinned **or** carries a cosign identity; `trust.signingKeyRef.secretName` present (Ed25519 signing is the mandatory baseline); a cosign block has a non-empty, anchored `certificateIdentityRegexp` and an `oidcIssuer`; an SLSA block sets `configMapName`, `sourceUri` and `sourceTag`. |
|
|
38
|
+
| `MCPGPluginSet` | `entries` non-empty; each entry has a reverse-DNS `id` and a `pluginRef.name`; ids unique; `capabilityGrants` keys name a declared entry and grant a non-empty capability list. |
|
|
39
|
+
| `MCPGRevocationList` | `version == 1`; every `artifactSha256` is 64 hex characters; every revocation carries a `reason`; no duplicate hashes. |
|
|
40
|
+
| `MCPGCluster` | the `single_node` backend takes no `config`; every other backend requires one; a plaintext coordinator address (`redis://`, `http://` Consul, non-`https://` etcd endpoints, NATS with `tls.require_tls: false`) is rejected unless `config.allow_insecure_transport: true`; `credentialRefs` entries carry `name` + `secretName` and unique names. |
|
|
41
|
+
| `MCPGRoute` | `gatewayRef.name` non-empty; `match.tools` lists at least one tool; tool ids non-empty and unique; no empty entry in `identityChain` / `policyChain` / `auditChain`. |
|
|
42
|
+
| `MCPGTenant` | `namespaces` non-empty, with entries non-empty and unique; each `allowedPlugins` entry sets `name` or `registryPrefix`; quota fields are non-negative; `identityAttribute.key` non-empty when that block is set. |
|
|
43
|
+
| `MCPGPluginMirror` | `endpoint.service.{namespace,name,port}` set; `upstream.registry` present and shaped like a registry host; `upstream.namespace` present; `auth.secretRef.secretName` present when `auth` is set. |
|
|
44
|
+
|
|
45
|
+
Two behaviours exist specifically so the preview verdict matches the cluster's:
|
|
46
|
+
|
|
47
|
+
- A `certificateIdentityRegexp` using lookahead, lookbehind or a back-reference
|
|
48
|
+
is rejected even though JavaScript would compile it, because the operator
|
|
49
|
+
compiles the pattern with an RE2-style engine that will not.
|
|
50
|
+
- SHA-256 hashes are accepted in either case, and duplicate detection is
|
|
51
|
+
case-insensitive, matching the operator's lowercase-then-deduplicate
|
|
52
|
+
behaviour.
|
|
53
|
+
|
|
54
|
+
Cross-resource and client-backed admission checks are deliberately **not**
|
|
55
|
+
mirrored: the per-tenant replica cap and plugin allowlist need cluster state,
|
|
56
|
+
and `image.tag` is filled in by the mutating webhook, so enforcing it at preview
|
|
57
|
+
would false-reject a program that relies on defaulting. Treat the pack as a
|
|
58
|
+
high-coverage local gate, not a guarantee of admission acceptance.
|
|
59
|
+
|
|
60
|
+
## Validator library
|
|
61
|
+
|
|
62
|
+
The rules live in [`src/validators.ts`](src/validators.ts) as pure functions
|
|
63
|
+
with no Pulumi runtime dependency, which is what lets the same logic back both
|
|
64
|
+
the policy pack and a cross-tool contract test:
|
|
65
|
+
|
|
66
|
+
- `validateByType(type, spec)` — dispatch by Pulumi type token.
|
|
67
|
+
- `validateGatewaySpec`, `validatePluginSpec`, `validatePluginSetSpec`,
|
|
68
|
+
`validateRevocationListSpec`, `validateClusterSpec`, `validateRouteSpec`,
|
|
69
|
+
`validateTenantSpec`, `validatePluginMirrorSpec` — one per kind, each
|
|
70
|
+
returning `Finding[]` (`{ rule, message }`); an empty array means accept.
|
|
71
|
+
- `isSha256Hex`, `isAnchoredRegexp`, `countWorkloadIdentities`,
|
|
72
|
+
`findDuplicates` — the shared predicates.
|
|
73
|
+
|
|
74
|
+
The MCPG Terraform provider ships a Go port of these same functions, and a
|
|
75
|
+
shared fixture corpus asserts both sides return identical verdicts, so a rule
|
|
76
|
+
relaxed on one side fails the build.
|
|
77
|
+
|
|
78
|
+
## Configuration
|
|
79
|
+
|
|
80
|
+
The pack takes no configuration. It registers one policy,
|
|
81
|
+
`mcpg-admission-mirror`, declared at `enforcementLevel: "mandatory"`, so a
|
|
82
|
+
violation blocks the update instead of emitting an advisory.
|
|
83
|
+
|
|
84
|
+
Resources are dispatched on the Pulumi type-token suffix (`…:MCPGGateway`,
|
|
85
|
+
`…:MCPGPlugin`, …) and read from `props.spec`. Any resource whose token matches
|
|
86
|
+
no rule set yields no findings, so the pack is safe to apply across a whole
|
|
87
|
+
stack.
|
|
88
|
+
|
|
89
|
+
## Build and test
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
npm run build # tsc → bin/
|
|
93
|
+
node --test bin/validators.test.js # unit tests over the pure validators
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Inside the MCPG workspace, from the repo root:
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
pnpm --filter ./iac/pulumi/policy exec tsc -b --noEmit # tsc --noEmit
|
|
100
|
+
pnpm --filter ./iac/pulumi/policy test # builds, then runs the validator tests
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Licence
|
|
104
|
+
|
|
105
|
+
Apache-2.0.
|
|
106
|
+
|
|
107
|
+
## See also
|
|
108
|
+
|
|
109
|
+
- <https://mcpg.dev/docs/self-hosting/pulumi> — installing MCPG with Pulumi,
|
|
110
|
+
including the `@mcpg/pulumi` components and the `@mcpg/pulumi-crds` typed SDK
|
|
111
|
+
this pack guards.
|
|
112
|
+
- <https://mcpg.dev/docs/reference/operator-crds> — the `mcpg.dev` CRDs and the
|
|
113
|
+
admission rules being mirrored.
|
|
114
|
+
- <https://mcpg.dev/docs/security/plugin-security> — signing, trust roots and
|
|
115
|
+
revocation, which the plugin and revocation-list rules enforce.
|
|
116
|
+
- <https://mcpg.dev/docs/self-hosting/terraform-provider> — the same rules
|
|
117
|
+
enforced as Terraform plan-time validation.
|
package/bin/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/bin/index.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const policy_1 = require("@pulumi/policy");
|
|
4
|
+
const validators_1 = require("./validators");
|
|
5
|
+
function report(findings, reportViolation) {
|
|
6
|
+
for (const f of findings) {
|
|
7
|
+
reportViolation(`[${f.rule}] ${f.message}`);
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
// CrossGuard pack mirroring the operator's admission webhook. Evaluated at
|
|
11
|
+
// `pulumi preview` (local — no operator round-trip). The same accept/reject
|
|
12
|
+
// verdicts the admission webhook would produce must hold here (the shared
|
|
13
|
+
// contract corpus under iac/contract/).
|
|
14
|
+
new policy_1.PolicyPack("mcpg-admission-mirror", {
|
|
15
|
+
policies: [
|
|
16
|
+
{
|
|
17
|
+
name: "mcpg-admission-mirror",
|
|
18
|
+
description: "Mirror the MCPG operator's CRD admission rules at preview time.",
|
|
19
|
+
enforcementLevel: "mandatory",
|
|
20
|
+
validateResource: (args, reportViolation) => {
|
|
21
|
+
const spec = args.props?.spec;
|
|
22
|
+
report((0, validators_1.validateByType)(args.type, spec), reportViolation);
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
],
|
|
26
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export interface Finding {
|
|
2
|
+
rule: string;
|
|
3
|
+
message: string;
|
|
4
|
+
}
|
|
5
|
+
export declare function isSha256Hex(s: string): boolean;
|
|
6
|
+
/** cosign certificateIdentityRegexp must be anchored with ^ and $, compile, AND
|
|
7
|
+
* be RE2-compatible (the operator compiles it with the Rust regex crate). */
|
|
8
|
+
export declare function isAnchoredRegexp(s: string): boolean;
|
|
9
|
+
export declare function countWorkloadIdentities(wi: any): number;
|
|
10
|
+
export declare function findDuplicates<T>(items: T[], key: (t: T) => string): string[];
|
|
11
|
+
export declare function validateGatewaySpec(spec: any): Finding[];
|
|
12
|
+
export declare function validatePluginSpec(spec: any): Finding[];
|
|
13
|
+
export declare function validatePluginSetSpec(spec: any): Finding[];
|
|
14
|
+
export declare function validateRevocationListSpec(spec: any): Finding[];
|
|
15
|
+
/** Mirrors the MCPGCluster admission rules (validators/cluster.rs). */
|
|
16
|
+
export declare function validateClusterSpec(spec: any): Finding[];
|
|
17
|
+
/** Mirrors the MCPGRoute admission rules (validators/route.rs). Tenant-unset is
|
|
18
|
+
* an admit-with-warning in the webhook, so it is NOT a reject here. */
|
|
19
|
+
export declare function validateRouteSpec(spec: any): Finding[];
|
|
20
|
+
/** Mirrors the MCPGTenant admission rules (validators/tenant.rs). */
|
|
21
|
+
export declare function validateTenantSpec(spec: any): Finding[];
|
|
22
|
+
/** Mirrors the MCPGPluginMirror admission rules (validators/plugin_mirror.rs). */
|
|
23
|
+
export declare function validatePluginMirrorSpec(spec: any): Finding[];
|
|
24
|
+
/** Dispatch by Pulumi resource type token (…:MCPGGateway etc.). */
|
|
25
|
+
export declare function validateByType(type: string, spec: any): Finding[];
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Admission-mirror validation helpers — PURE functions so they can be shared
|
|
3
|
+
// by the CrossGuard pack (preview-time) and the contract corpus, and unit
|
|
4
|
+
// tested without a Pulumi runtime. Mirror k8s/operator/src/admission/validators/.
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.isSha256Hex = isSha256Hex;
|
|
7
|
+
exports.isAnchoredRegexp = isAnchoredRegexp;
|
|
8
|
+
exports.countWorkloadIdentities = countWorkloadIdentities;
|
|
9
|
+
exports.findDuplicates = findDuplicates;
|
|
10
|
+
exports.validateGatewaySpec = validateGatewaySpec;
|
|
11
|
+
exports.validatePluginSpec = validatePluginSpec;
|
|
12
|
+
exports.validatePluginSetSpec = validatePluginSetSpec;
|
|
13
|
+
exports.validateRevocationListSpec = validateRevocationListSpec;
|
|
14
|
+
exports.validateClusterSpec = validateClusterSpec;
|
|
15
|
+
exports.validateRouteSpec = validateRouteSpec;
|
|
16
|
+
exports.validateTenantSpec = validateTenantSpec;
|
|
17
|
+
exports.validatePluginMirrorSpec = validatePluginMirrorSpec;
|
|
18
|
+
exports.validateByType = validateByType;
|
|
19
|
+
const blank = (s) => typeof s !== "string" || s.trim() === "";
|
|
20
|
+
// pluginClass values the operator accepts (mcpg_plugin_protocol::abi::ALL_KINDS,
|
|
21
|
+
// the single source of truth in libs/plugin-protocol/src/abi.rs).
|
|
22
|
+
const KNOWN_PLUGIN_CLASSES = [
|
|
23
|
+
"tool_gate", "transform", "identity_provider", "backend", "watch_strategy",
|
|
24
|
+
"http_route", "audit_sink", "log_sink", "telemetry_sink", "metrics_sink",
|
|
25
|
+
"store", "cache", "secret_provider", "config_provider", "policy_engine",
|
|
26
|
+
"cluster", "transport", "catalog_provider", "credential_issuer",
|
|
27
|
+
"approval_notifier", "content_store",
|
|
28
|
+
];
|
|
29
|
+
// Constructs RE2 (the Go provider + the Rust `regex` crate the operator uses)
|
|
30
|
+
// cannot compile but JS RegExp can — lookahead/lookbehind + back-references.
|
|
31
|
+
// Rejecting them keeps the Pulumi verdict identical to the operator.
|
|
32
|
+
const RE2_INCOMPATIBLE = /\(\?<?[=!]|\\[1-9]|\\k</;
|
|
33
|
+
function isSha256Hex(s) {
|
|
34
|
+
// The operator accepts any 64 ascii-hexdigit string (case-insensitive) and
|
|
35
|
+
// lowercases for dedup — uppercase hashes are valid.
|
|
36
|
+
return /^[0-9a-fA-F]{64}$/.test(s);
|
|
37
|
+
}
|
|
38
|
+
/** cosign certificateIdentityRegexp must be anchored with ^ and $, compile, AND
|
|
39
|
+
* be RE2-compatible (the operator compiles it with the Rust regex crate). */
|
|
40
|
+
function isAnchoredRegexp(s) {
|
|
41
|
+
if (typeof s !== "string" || !s.startsWith("^") || !s.endsWith("$"))
|
|
42
|
+
return false;
|
|
43
|
+
if (RE2_INCOMPATIBLE.test(s))
|
|
44
|
+
return false;
|
|
45
|
+
try {
|
|
46
|
+
new RegExp(s);
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const WORKLOAD_IDENTITY_KEYS = ["aws", "gcp", "azure", "spiffe"];
|
|
54
|
+
function countWorkloadIdentities(wi) {
|
|
55
|
+
if (!wi || typeof wi !== "object")
|
|
56
|
+
return 0;
|
|
57
|
+
return WORKLOAD_IDENTITY_KEYS.filter((k) => wi[k] != null).length;
|
|
58
|
+
}
|
|
59
|
+
function findDuplicates(items, key) {
|
|
60
|
+
const seen = new Set();
|
|
61
|
+
const dups = new Set();
|
|
62
|
+
for (const it of items) {
|
|
63
|
+
const k = key(it);
|
|
64
|
+
if (seen.has(k))
|
|
65
|
+
dups.add(k);
|
|
66
|
+
seen.add(k);
|
|
67
|
+
}
|
|
68
|
+
return [...dups];
|
|
69
|
+
}
|
|
70
|
+
function validateGatewaySpec(spec) {
|
|
71
|
+
const f = [];
|
|
72
|
+
if (!spec?.image)
|
|
73
|
+
f.push({ rule: "gateway.image.required", message: "spec.image is required" });
|
|
74
|
+
// replicas, when set, must be ≥ 1 (gateway.rs:65). Absent ⇒ defaulted ⇒ ok.
|
|
75
|
+
if (typeof spec?.replicas === "number" && spec.replicas < 1) {
|
|
76
|
+
f.push({ rule: "gateway.replicas.min", message: "spec.replicas must be ≥ 1" });
|
|
77
|
+
}
|
|
78
|
+
if (spec?.workloadIdentity && countWorkloadIdentities(spec.workloadIdentity) > 1) {
|
|
79
|
+
f.push({ rule: "gateway.workloadIdentity.oneOf", message: "workloadIdentity must be exactly one of aws|gcp|azure|spiffe" });
|
|
80
|
+
}
|
|
81
|
+
// Ingress sub-shape (gateway.rs:114-130) when an ingress block is present.
|
|
82
|
+
const ing = spec?.ingress;
|
|
83
|
+
if (ing) {
|
|
84
|
+
if (blank(ing.ingressClassName))
|
|
85
|
+
f.push({ rule: "gateway.ingress.ingressClassName", message: "spec.ingress.ingressClassName must not be empty" });
|
|
86
|
+
const hosts = ing.hosts ?? [];
|
|
87
|
+
if (hosts.length === 0)
|
|
88
|
+
f.push({ rule: "gateway.ingress.hosts", message: "spec.ingress.hosts must not be empty when ingress is set" });
|
|
89
|
+
hosts.forEach((h, i) => {
|
|
90
|
+
if (blank(h?.host))
|
|
91
|
+
f.push({ rule: "gateway.ingress.host", message: `spec.ingress.hosts[${i}].host is empty` });
|
|
92
|
+
if (!Array.isArray(h?.paths) || h.paths.length === 0)
|
|
93
|
+
f.push({ rule: "gateway.ingress.paths", message: `spec.ingress.hosts[${i}].paths must not be empty` });
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
// NB: image.tag-non-empty is enforced by the operator AFTER the mutating
|
|
97
|
+
// webhook defaults an empty/absent tag, so it is not a plan-time reject
|
|
98
|
+
// (would false-reject the rely-on-defaulting path). The per-gateway replica
|
|
99
|
+
// cap (tenant_guard) is a cross-resource, client-backed check — both are
|
|
100
|
+
// intentionally NOT mirrored at plan time.
|
|
101
|
+
return f;
|
|
102
|
+
}
|
|
103
|
+
function validatePluginSpec(spec) {
|
|
104
|
+
const f = [];
|
|
105
|
+
const oci = spec?.oci ?? {};
|
|
106
|
+
const trust = spec?.trust ?? {};
|
|
107
|
+
// Identity + class + version (plugin.rs:56-83).
|
|
108
|
+
if (blank(spec?.pluginId))
|
|
109
|
+
f.push({ rule: "plugin.pluginId.nonEmpty", message: "spec.pluginId must not be empty" });
|
|
110
|
+
else if (!spec.pluginId.includes("."))
|
|
111
|
+
f.push({ rule: "plugin.pluginId.reverseDns", message: "spec.pluginId is not reverse-DNS form (e.g. dev.mcpg.identity.workload)" });
|
|
112
|
+
if (blank(spec?.version))
|
|
113
|
+
f.push({ rule: "plugin.version.nonEmpty", message: "spec.version must not be empty" });
|
|
114
|
+
if (!KNOWN_PLUGIN_CLASSES.includes(spec?.pluginClass))
|
|
115
|
+
f.push({ rule: "plugin.pluginClass.known", message: `spec.pluginClass is not a known PluginClass` });
|
|
116
|
+
// OCI image reference shape (plugin.rs:84-99).
|
|
117
|
+
const img = typeof oci.image === "string" ? oci.image.trim() : "";
|
|
118
|
+
if (img === "")
|
|
119
|
+
f.push({ rule: "plugin.oci.image.nonEmpty", message: "spec.oci.image must not be empty" });
|
|
120
|
+
else {
|
|
121
|
+
if (!img.includes("/"))
|
|
122
|
+
f.push({ rule: "plugin.oci.image.registry", message: "spec.oci.image lacks a registry component (<registry>/<path>)" });
|
|
123
|
+
if (!img.includes(":") && !img.includes("@"))
|
|
124
|
+
f.push({ rule: "plugin.oci.image.tagOrDigest", message: "spec.oci.image lacks a tag or digest pin (:tag or @sha256:...)" });
|
|
125
|
+
}
|
|
126
|
+
// Trust anchor: digest pin OR cosign identity (plugin.rs:101-112).
|
|
127
|
+
const digestPinned = img.includes("@sha256:");
|
|
128
|
+
const hasCosign = trust.cosignIdentity != null;
|
|
129
|
+
if (!digestPinned && !hasCosign) {
|
|
130
|
+
f.push({ rule: "plugin.trust.anchor", message: "plugin must be digest-pinned OR carry a cosign identity" });
|
|
131
|
+
}
|
|
132
|
+
// signingKeyRef (Ed25519) is the MANDATORY baseline (plugin.rs:119-124).
|
|
133
|
+
const skr = trust.signingKeyRef;
|
|
134
|
+
if (blank(skr?.secretName)) {
|
|
135
|
+
f.push({ rule: "plugin.trust.signingKeyRef.required", message: "spec.trust.signingKeyRef.secretName is required (Ed25519 signing is the mandatory trust baseline)" });
|
|
136
|
+
}
|
|
137
|
+
// key defaults to release.pub; only an explicit empty key is invalid.
|
|
138
|
+
if (skr && skr.key !== undefined && blank(skr.key)) {
|
|
139
|
+
f.push({ rule: "plugin.trust.signingKeyRef.key", message: "spec.trust.signingKeyRef.key must not be empty when set" });
|
|
140
|
+
}
|
|
141
|
+
// Cosign sub-shape (plugin.rs:126-163).
|
|
142
|
+
if (hasCosign) {
|
|
143
|
+
if (blank(trust.cosignIdentity.certificateIdentityRegexp)) {
|
|
144
|
+
f.push({ rule: "plugin.cosign.regexpNonEmpty", message: "cosign certificateIdentityRegexp must not be empty" });
|
|
145
|
+
}
|
|
146
|
+
else if (!isAnchoredRegexp(trust.cosignIdentity.certificateIdentityRegexp)) {
|
|
147
|
+
f.push({ rule: "plugin.cosign.anchoredRegexp", message: "cosign certificateIdentityRegexp must be anchored with ^ and $ and compile (RE2)" });
|
|
148
|
+
}
|
|
149
|
+
if (blank(trust.cosignIdentity.oidcIssuer)) {
|
|
150
|
+
f.push({ rule: "plugin.cosign.oidcIssuer", message: "cosign oidcIssuer is required when cosign is set" });
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
// SLSA provenance sub-shape (plugin.rs:166-178).
|
|
154
|
+
const slsa = trust.slsaProvenance;
|
|
155
|
+
if (slsa) {
|
|
156
|
+
if (blank(slsa.configMapName))
|
|
157
|
+
f.push({ rule: "plugin.slsa.configMapName", message: "spec.trust.slsaProvenance.configMapName must not be empty" });
|
|
158
|
+
if (blank(slsa.sourceUri))
|
|
159
|
+
f.push({ rule: "plugin.slsa.sourceUri", message: "spec.trust.slsaProvenance.sourceUri must not be empty" });
|
|
160
|
+
if (blank(slsa.sourceTag))
|
|
161
|
+
f.push({ rule: "plugin.slsa.sourceTag", message: "spec.trust.slsaProvenance.sourceTag must not be empty" });
|
|
162
|
+
}
|
|
163
|
+
return f;
|
|
164
|
+
}
|
|
165
|
+
function validatePluginSetSpec(spec) {
|
|
166
|
+
const f = [];
|
|
167
|
+
const entries = spec?.entries ?? [];
|
|
168
|
+
if (entries.length === 0)
|
|
169
|
+
f.push({ rule: "pluginSet.entries.nonEmpty", message: "entries must be non-empty" });
|
|
170
|
+
const ids = new Set();
|
|
171
|
+
entries.forEach((e, i) => {
|
|
172
|
+
const id = e?.id;
|
|
173
|
+
if (blank(id))
|
|
174
|
+
f.push({ rule: "pluginSet.entries.id.nonEmpty", message: `spec.entries[${i}].id must not be empty` });
|
|
175
|
+
else if (!id.includes("."))
|
|
176
|
+
f.push({ rule: "pluginSet.entries.id.reverseDns", message: `spec.entries[${i}].id is not reverse-DNS form` });
|
|
177
|
+
if (blank(e?.pluginRef?.name))
|
|
178
|
+
f.push({ rule: "pluginSet.entries.pluginRef.name", message: `spec.entries[${i}].pluginRef.name must not be empty` });
|
|
179
|
+
if (typeof id === "string")
|
|
180
|
+
ids.add(id);
|
|
181
|
+
});
|
|
182
|
+
const dups = findDuplicates(entries, (e) => String(e?.id));
|
|
183
|
+
if (dups.length)
|
|
184
|
+
f.push({ rule: "pluginSet.entries.uniqueId", message: `duplicate entry ids: ${dups.join(",")}` });
|
|
185
|
+
// capabilityGrants is a MAP (id → [capabilities]); keys must name an entry
|
|
186
|
+
// id and each grant list must be non-empty (plugin_set.rs:85-100).
|
|
187
|
+
const grants = spec?.capabilityGrants;
|
|
188
|
+
if (grants && typeof grants === "object" && !Array.isArray(grants)) {
|
|
189
|
+
for (const id of Object.keys(grants)) {
|
|
190
|
+
if (!ids.has(id))
|
|
191
|
+
f.push({ rule: "pluginSet.capabilityGrants.unknownId", message: `capabilityGrants['${id}'] names an id not in entries` });
|
|
192
|
+
else if (!Array.isArray(grants[id]) || grants[id].length === 0)
|
|
193
|
+
f.push({ rule: "pluginSet.capabilityGrants.empty", message: `capabilityGrants['${id}'] must not be empty` });
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return f;
|
|
197
|
+
}
|
|
198
|
+
function validateRevocationListSpec(spec) {
|
|
199
|
+
const f = [];
|
|
200
|
+
if (spec?.version !== 1)
|
|
201
|
+
f.push({ rule: "revocationList.version", message: "version must be 1" });
|
|
202
|
+
const revs = spec?.revocations ?? [];
|
|
203
|
+
for (const r of revs) {
|
|
204
|
+
if (!isSha256Hex(String(r?.artifactSha256 ?? ""))) {
|
|
205
|
+
f.push({ rule: "revocationList.sha256", message: "artifactSha256 must be 64 hex chars" });
|
|
206
|
+
}
|
|
207
|
+
// empty reason defeats the audit trail (revocation_list.rs:86).
|
|
208
|
+
if (blank(r?.reason)) {
|
|
209
|
+
f.push({ rule: "revocationList.reason", message: "revocation reason must not be empty" });
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
// dedup is case-insensitive in the operator (hashes lowercased), so ABCD…
|
|
213
|
+
// and abcd… collide.
|
|
214
|
+
const dups = findDuplicates(revs, (r) => String(r?.artifactSha256 ?? "").toLowerCase());
|
|
215
|
+
if (dups.length)
|
|
216
|
+
f.push({ rule: "revocationList.noDuplicates", message: `duplicate hashes: ${dups.join(",")}` });
|
|
217
|
+
return f;
|
|
218
|
+
}
|
|
219
|
+
/** Mirrors the MCPGCluster admission rules (validators/cluster.rs). */
|
|
220
|
+
function validateClusterSpec(spec) {
|
|
221
|
+
const f = [];
|
|
222
|
+
const backend = spec?.backend ?? "single_node";
|
|
223
|
+
const singleNode = backend === "" || backend === "single_node";
|
|
224
|
+
const configEmpty = !spec?.config || Object.keys(spec.config).length === 0;
|
|
225
|
+
if (singleNode && !configEmpty) {
|
|
226
|
+
f.push({ rule: "cluster.singleNode.noConfig", message: "spec.config must be empty for the single_node backend (it takes no parameters)" });
|
|
227
|
+
}
|
|
228
|
+
if (!singleNode && configEmpty) {
|
|
229
|
+
f.push({ rule: "cluster.backend.configRequired", message: "spec.config must not be empty for an external backend — it needs at least a connection address" });
|
|
230
|
+
}
|
|
231
|
+
// Transport security: reject a plaintext coordinator unless opted out
|
|
232
|
+
// with `spec.config.allow_insecure_transport: true`. Mirrors the gateway
|
|
233
|
+
// boot guard + the operator admission webhook (validators/cluster.rs).
|
|
234
|
+
if (!singleNode && !configEmpty && spec?.config?.allow_insecure_transport !== true) {
|
|
235
|
+
const c = spec.config;
|
|
236
|
+
const lead = (s) => (typeof s === "string" ? s.replace(/^\s+/, "") : "");
|
|
237
|
+
let insecure = null;
|
|
238
|
+
if (backend === "redis" && lead(c?.url).startsWith("redis://")) {
|
|
239
|
+
insecure = "the redis `url` uses the plaintext `redis://` scheme (use `rediss://`)";
|
|
240
|
+
}
|
|
241
|
+
else if (backend === "consul" && lead(c?.address).startsWith("http://")) {
|
|
242
|
+
insecure = "the consul `address` uses the plaintext `http://` scheme (use `https://`)";
|
|
243
|
+
}
|
|
244
|
+
else if (backend === "etcd" && Array.isArray(c?.endpoints) &&
|
|
245
|
+
c.endpoints.some((e) => !lead(e).startsWith("https://"))) {
|
|
246
|
+
insecure = "an etcd `endpoint` is not an `https://` URL (use `https://`)";
|
|
247
|
+
}
|
|
248
|
+
else if (backend === "nats" && c?.tls?.require_tls === false) {
|
|
249
|
+
insecure = "nats `tls.require_tls` is set to `false` (plaintext)";
|
|
250
|
+
}
|
|
251
|
+
if (insecure) {
|
|
252
|
+
f.push({ rule: "cluster.transport.insecure", message: `spec.config: ${insecure}. Set spec.config.allow_insecure_transport: true to accept plaintext (local/dev only).` });
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
const seen = new Set();
|
|
256
|
+
const refs = spec?.credentialRefs ?? [];
|
|
257
|
+
refs.forEach((c, i) => {
|
|
258
|
+
if (blank(c?.name)) {
|
|
259
|
+
f.push({ rule: "cluster.credentialRefs.name", message: `spec.credentialRefs[${i}].name must not be empty` });
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
if (blank(c?.secretName)) {
|
|
263
|
+
f.push({ rule: "cluster.credentialRefs.secretName", message: `spec.credentialRefs[${i}].secretName must not be empty` });
|
|
264
|
+
}
|
|
265
|
+
if (seen.has(c.name)) {
|
|
266
|
+
f.push({ rule: "cluster.credentialRefs.uniqueName", message: `spec.credentialRefs name '${c.name}' is duplicated` });
|
|
267
|
+
}
|
|
268
|
+
seen.add(c.name);
|
|
269
|
+
});
|
|
270
|
+
return f;
|
|
271
|
+
}
|
|
272
|
+
/** Mirrors the MCPGRoute admission rules (validators/route.rs). Tenant-unset is
|
|
273
|
+
* an admit-with-warning in the webhook, so it is NOT a reject here. */
|
|
274
|
+
function validateRouteSpec(spec) {
|
|
275
|
+
const f = [];
|
|
276
|
+
if (blank(spec?.gatewayRef?.name))
|
|
277
|
+
f.push({ rule: "route.gatewayRef.name", message: "spec.gatewayRef.name must not be empty" });
|
|
278
|
+
const tools = spec?.match?.tools ?? [];
|
|
279
|
+
if (tools.length === 0)
|
|
280
|
+
f.push({ rule: "route.match.tools.nonEmpty", message: "spec.match.tools must list at least one tool" });
|
|
281
|
+
const seen = new Set();
|
|
282
|
+
tools.forEach((t, i) => {
|
|
283
|
+
const id = typeof t?.id === "string" ? t.id.trim() : "";
|
|
284
|
+
if (id === "") {
|
|
285
|
+
f.push({ rule: "route.match.tools.id", message: `spec.match.tools[${i}].id must not be empty` });
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
if (seen.has(id))
|
|
289
|
+
f.push({ rule: "route.match.tools.uniqueId", message: `spec.match.tools contains duplicate tool id '${id}'` });
|
|
290
|
+
seen.add(id);
|
|
291
|
+
});
|
|
292
|
+
for (const chain of ["identityChain", "policyChain", "auditChain"]) {
|
|
293
|
+
const ids = spec?.[chain] ?? [];
|
|
294
|
+
ids.forEach((id, i) => {
|
|
295
|
+
if (blank(id))
|
|
296
|
+
f.push({ rule: "route.chain.nonEmptyId", message: `spec.${chain}[${i}] must not be empty` });
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
return f;
|
|
300
|
+
}
|
|
301
|
+
/** Mirrors the MCPGTenant admission rules (validators/tenant.rs). */
|
|
302
|
+
function validateTenantSpec(spec) {
|
|
303
|
+
const f = [];
|
|
304
|
+
const namespaces = spec?.namespaces ?? [];
|
|
305
|
+
if (namespaces.length === 0)
|
|
306
|
+
f.push({ rule: "tenant.namespaces.nonEmpty", message: "spec.namespaces must not be empty" });
|
|
307
|
+
const seen = new Set();
|
|
308
|
+
for (const ns of namespaces) {
|
|
309
|
+
if (blank(ns)) {
|
|
310
|
+
f.push({ rule: "tenant.namespaces.nonEmptyEntry", message: "spec.namespaces[] entries must not be empty" });
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
if (seen.has(ns))
|
|
314
|
+
f.push({ rule: "tenant.namespaces.unique", message: `spec.namespaces lists '${ns}' more than once` });
|
|
315
|
+
seen.add(ns);
|
|
316
|
+
}
|
|
317
|
+
(spec?.allowedPlugins ?? []).forEach((a, i) => {
|
|
318
|
+
const nameSet = !blank(a?.name);
|
|
319
|
+
const prefixSet = !blank(a?.registryPrefix);
|
|
320
|
+
if (!nameSet && !prefixSet)
|
|
321
|
+
f.push({ rule: "tenant.allowedPlugins.matcher", message: `spec.allowedPlugins[${i}] must set name or registryPrefix` });
|
|
322
|
+
});
|
|
323
|
+
if (spec?.quotas) {
|
|
324
|
+
for (const field of ["maxGateways", "maxPluginSets", "maxRoutes", "maxReplicasPerGateway"]) {
|
|
325
|
+
const v = spec.quotas[field];
|
|
326
|
+
if (typeof v === "number" && v < 0)
|
|
327
|
+
f.push({ rule: "tenant.quotas.nonNegative", message: `spec.quotas.${field} must be ≥ 0` });
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
if (spec?.identityAttribute && blank(spec.identityAttribute.key)) {
|
|
331
|
+
f.push({ rule: "tenant.identityAttribute.key", message: "spec.identityAttribute.key must not be empty when set" });
|
|
332
|
+
}
|
|
333
|
+
return f;
|
|
334
|
+
}
|
|
335
|
+
/** Mirrors the MCPGPluginMirror admission rules (validators/plugin_mirror.rs). */
|
|
336
|
+
function validatePluginMirrorSpec(spec) {
|
|
337
|
+
const f = [];
|
|
338
|
+
const svc = spec?.endpoint?.service ?? {};
|
|
339
|
+
if (blank(svc.namespace))
|
|
340
|
+
f.push({ rule: "pluginMirror.endpoint.service.namespace", message: "spec.endpoint.service.namespace must not be empty" });
|
|
341
|
+
if (blank(svc.name))
|
|
342
|
+
f.push({ rule: "pluginMirror.endpoint.service.name", message: "spec.endpoint.service.name must not be empty" });
|
|
343
|
+
if (!svc.port)
|
|
344
|
+
f.push({ rule: "pluginMirror.endpoint.service.port", message: "spec.endpoint.service.port must be in 1..=65535" });
|
|
345
|
+
const up = spec?.upstream ?? {};
|
|
346
|
+
if (blank(up.registry)) {
|
|
347
|
+
f.push({ rule: "pluginMirror.upstream.registry", message: "spec.upstream.registry must not be empty" });
|
|
348
|
+
}
|
|
349
|
+
else if (!up.registry.includes(".") && !up.registry.includes(":")) {
|
|
350
|
+
f.push({ rule: "pluginMirror.upstream.registryHost", message: `spec.upstream.registry '${up.registry}' does not look like a registry host` });
|
|
351
|
+
}
|
|
352
|
+
if (blank(up.namespace))
|
|
353
|
+
f.push({ rule: "pluginMirror.upstream.namespace", message: "spec.upstream.namespace must not be empty" });
|
|
354
|
+
if (spec?.auth && blank(spec.auth.secretRef?.secretName)) {
|
|
355
|
+
f.push({ rule: "pluginMirror.auth.secretName", message: "spec.auth.secretRef.secretName must not be empty when auth is set" });
|
|
356
|
+
}
|
|
357
|
+
return f;
|
|
358
|
+
}
|
|
359
|
+
/** Dispatch by Pulumi resource type token (…:MCPGGateway etc.). */
|
|
360
|
+
function validateByType(type, spec) {
|
|
361
|
+
if (type.endsWith(":MCPGGateway"))
|
|
362
|
+
return validateGatewaySpec(spec);
|
|
363
|
+
if (type.endsWith(":MCPGPlugin"))
|
|
364
|
+
return validatePluginSpec(spec);
|
|
365
|
+
if (type.endsWith(":MCPGPluginSet"))
|
|
366
|
+
return validatePluginSetSpec(spec);
|
|
367
|
+
if (type.endsWith(":MCPGRevocationList"))
|
|
368
|
+
return validateRevocationListSpec(spec);
|
|
369
|
+
if (type.endsWith(":MCPGCluster"))
|
|
370
|
+
return validateClusterSpec(spec);
|
|
371
|
+
if (type.endsWith(":MCPGRoute"))
|
|
372
|
+
return validateRouteSpec(spec);
|
|
373
|
+
if (type.endsWith(":MCPGTenant"))
|
|
374
|
+
return validateTenantSpec(spec);
|
|
375
|
+
if (type.endsWith(":MCPGPluginMirror"))
|
|
376
|
+
return validatePluginMirrorSpec(spec);
|
|
377
|
+
return [];
|
|
378
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
// Unit tests for the admission-mirror validators (pure; node:test, no deps).
|
|
7
|
+
const node_test_1 = require("node:test");
|
|
8
|
+
const strict_1 = __importDefault(require("node:assert/strict"));
|
|
9
|
+
const validators_1 = require("./validators");
|
|
10
|
+
(0, node_test_1.test)("gateway: two workload identities rejected", () => {
|
|
11
|
+
const f = (0, validators_1.validateGatewaySpec)({ image: {}, workloadIdentity: { aws: {}, gcp: {} } });
|
|
12
|
+
strict_1.default.ok(f.some((x) => x.rule === "gateway.workloadIdentity.oneOf"));
|
|
13
|
+
});
|
|
14
|
+
(0, node_test_1.test)("gateway: minimal accepted", () => {
|
|
15
|
+
strict_1.default.deepEqual((0, validators_1.validateGatewaySpec)({ image: { repository: "x", tag: "y" } }), []);
|
|
16
|
+
});
|
|
17
|
+
(0, node_test_1.test)("plugin: tag-only with no cosign rejected", () => {
|
|
18
|
+
const f = (0, validators_1.validatePluginSpec)({ oci: { image: "ghcr.io/x:1.0" }, trust: {} });
|
|
19
|
+
strict_1.default.ok(f.some((x) => x.rule === "plugin.trust.anchor"));
|
|
20
|
+
});
|
|
21
|
+
(0, node_test_1.test)("plugin: cosign regexp must be anchored", () => {
|
|
22
|
+
const f = (0, validators_1.validatePluginSpec)({
|
|
23
|
+
oci: { image: "ghcr.io/x:1.0" },
|
|
24
|
+
trust: { cosignIdentity: { certificateIdentityRegexp: "https://github.com/.+", oidcIssuer: "x" } },
|
|
25
|
+
});
|
|
26
|
+
strict_1.default.ok(f.some((x) => x.rule === "plugin.cosign.anchoredRegexp"));
|
|
27
|
+
});
|
|
28
|
+
(0, node_test_1.test)("plugin: fully-formed digest-pinned accepted", () => {
|
|
29
|
+
strict_1.default.deepEqual((0, validators_1.validatePluginSpec)({
|
|
30
|
+
pluginId: "dev.mcpg.backend.sql",
|
|
31
|
+
pluginClass: "backend",
|
|
32
|
+
version: "1.4.2",
|
|
33
|
+
oci: { image: "ghcr.io/x@sha256:" + "a".repeat(64) },
|
|
34
|
+
trust: { signingKeyRef: { secretName: "k" } },
|
|
35
|
+
}), []);
|
|
36
|
+
});
|
|
37
|
+
(0, node_test_1.test)("plugin: bad class + non-reverse-DNS id rejected (adm-3)", () => {
|
|
38
|
+
const f = (0, validators_1.validatePluginSpec)({
|
|
39
|
+
pluginId: "noDotId",
|
|
40
|
+
pluginClass: "frobnicator",
|
|
41
|
+
version: "1.0",
|
|
42
|
+
oci: { image: "ghcr.io/x@sha256:" + "a".repeat(64) },
|
|
43
|
+
trust: { signingKeyRef: { secretName: "k" } },
|
|
44
|
+
});
|
|
45
|
+
strict_1.default.ok(f.some((x) => x.rule === "plugin.pluginId.reverseDns"));
|
|
46
|
+
strict_1.default.ok(f.some((x) => x.rule === "plugin.pluginClass.known"));
|
|
47
|
+
});
|
|
48
|
+
(0, node_test_1.test)("plugin: missing signingKeyRef rejected (trust-9)", () => {
|
|
49
|
+
const f = (0, validators_1.validatePluginSpec)({ oci: { image: "ghcr.io/x@sha256:" + "a".repeat(64) }, trust: {} });
|
|
50
|
+
strict_1.default.ok(f.some((x) => x.rule === "plugin.trust.signingKeyRef.required"));
|
|
51
|
+
});
|
|
52
|
+
(0, node_test_1.test)("revocationList: bad sha + duplicates rejected", () => {
|
|
53
|
+
const f = (0, validators_1.validateRevocationListSpec)({
|
|
54
|
+
version: 1,
|
|
55
|
+
revocations: [{ artifactSha256: "xyz" }, { artifactSha256: "a".repeat(64) }, { artifactSha256: "a".repeat(64) }],
|
|
56
|
+
});
|
|
57
|
+
strict_1.default.ok(f.some((x) => x.rule === "revocationList.sha256"));
|
|
58
|
+
strict_1.default.ok(f.some((x) => x.rule === "revocationList.noDuplicates"));
|
|
59
|
+
});
|
|
60
|
+
(0, node_test_1.test)("helpers", () => {
|
|
61
|
+
// case-insensitive 64-hex (the operator accepts uppercase — adm-2/trust-5/parity-5).
|
|
62
|
+
strict_1.default.equal((0, validators_1.isSha256Hex)("a".repeat(64)), true);
|
|
63
|
+
strict_1.default.equal((0, validators_1.isSha256Hex)("A".repeat(64)), true);
|
|
64
|
+
strict_1.default.equal((0, validators_1.isSha256Hex)("a".repeat(63)), false);
|
|
65
|
+
strict_1.default.equal((0, validators_1.isSha256Hex)("g".repeat(64)), false);
|
|
66
|
+
strict_1.default.equal((0, validators_1.isAnchoredRegexp)("^x$"), true);
|
|
67
|
+
strict_1.default.equal((0, validators_1.isAnchoredRegexp)("x"), false);
|
|
68
|
+
// RE2-incompatible lookahead rejected to match Go/operator (parity-1).
|
|
69
|
+
strict_1.default.equal((0, validators_1.isAnchoredRegexp)("^(?=.*x).*$"), false);
|
|
70
|
+
});
|
|
71
|
+
(0, node_test_1.test)("dispatch: ours validated, non-MCPG ignored", () => {
|
|
72
|
+
strict_1.default.ok((0, validators_1.validateByType)("kubernetes:mcpg.dev/v1alpha1:MCPGGateway", {}).length > 0);
|
|
73
|
+
strict_1.default.deepEqual((0, validators_1.validateByType)("kubernetes:core/v1:ConfigMap", {}), []);
|
|
74
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mcpg-dev/pulumi-policy",
|
|
3
|
+
"version": "0.1.0-beta.8",
|
|
4
|
+
"repository": "https://github.com/mcpg-dev/pulumi-mcpg-policy",
|
|
5
|
+
"description": "Pulumi CrossGuard policy pack mirroring MCPG operator admission rules.",
|
|
6
|
+
"license": "Apache-2.0",
|
|
7
|
+
"main": "bin/index.js",
|
|
8
|
+
"scripts": {
|
|
9
|
+
"build": "tsc",
|
|
10
|
+
"typecheck": "tsc --noEmit",
|
|
11
|
+
"test": "node --test bin/validators.test.js"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"@pulumi/policy": "^1.13.0",
|
|
15
|
+
"@pulumi/pulumi": "^3.142.0"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"@types/node": "^20",
|
|
19
|
+
"typescript": "^5.4.0"
|
|
20
|
+
}
|
|
21
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { PolicyPack } from "@pulumi/policy";
|
|
2
|
+
import { validateByType, Finding } from "./validators";
|
|
3
|
+
|
|
4
|
+
function report(findings: Finding[], reportViolation: (message: string) => void): void {
|
|
5
|
+
for (const f of findings) {
|
|
6
|
+
reportViolation(`[${f.rule}] ${f.message}`);
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// CrossGuard pack mirroring the operator's admission webhook. Evaluated at
|
|
11
|
+
// `pulumi preview` (local — no operator round-trip). The same accept/reject
|
|
12
|
+
// verdicts the admission webhook would produce must hold here (the shared
|
|
13
|
+
// contract corpus under iac/contract/).
|
|
14
|
+
new PolicyPack("mcpg-admission-mirror", {
|
|
15
|
+
policies: [
|
|
16
|
+
{
|
|
17
|
+
name: "mcpg-admission-mirror",
|
|
18
|
+
description: "Mirror the MCPG operator's CRD admission rules at preview time.",
|
|
19
|
+
enforcementLevel: "mandatory",
|
|
20
|
+
validateResource: (args, reportViolation) => {
|
|
21
|
+
const spec = (args.props as any)?.spec;
|
|
22
|
+
report(validateByType(args.type, spec), reportViolation);
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
],
|
|
26
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Unit tests for the admission-mirror validators (pure; node:test, no deps).
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import assert from "node:assert/strict";
|
|
4
|
+
import {
|
|
5
|
+
validateByType,
|
|
6
|
+
validateGatewaySpec,
|
|
7
|
+
validatePluginSpec,
|
|
8
|
+
validateRevocationListSpec,
|
|
9
|
+
isAnchoredRegexp,
|
|
10
|
+
isSha256Hex,
|
|
11
|
+
} from "./validators";
|
|
12
|
+
|
|
13
|
+
test("gateway: two workload identities rejected", () => {
|
|
14
|
+
const f = validateGatewaySpec({ image: {}, workloadIdentity: { aws: {}, gcp: {} } });
|
|
15
|
+
assert.ok(f.some((x) => x.rule === "gateway.workloadIdentity.oneOf"));
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("gateway: minimal accepted", () => {
|
|
19
|
+
assert.deepEqual(validateGatewaySpec({ image: { repository: "x", tag: "y" } }), []);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("plugin: tag-only with no cosign rejected", () => {
|
|
23
|
+
const f = validatePluginSpec({ oci: { image: "ghcr.io/x:1.0" }, trust: {} });
|
|
24
|
+
assert.ok(f.some((x) => x.rule === "plugin.trust.anchor"));
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("plugin: cosign regexp must be anchored", () => {
|
|
28
|
+
const f = validatePluginSpec({
|
|
29
|
+
oci: { image: "ghcr.io/x:1.0" },
|
|
30
|
+
trust: { cosignIdentity: { certificateIdentityRegexp: "https://github.com/.+", oidcIssuer: "x" } },
|
|
31
|
+
});
|
|
32
|
+
assert.ok(f.some((x) => x.rule === "plugin.cosign.anchoredRegexp"));
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("plugin: fully-formed digest-pinned accepted", () => {
|
|
36
|
+
assert.deepEqual(
|
|
37
|
+
validatePluginSpec({
|
|
38
|
+
pluginId: "dev.mcpg.backend.sql",
|
|
39
|
+
pluginClass: "backend",
|
|
40
|
+
version: "1.4.2",
|
|
41
|
+
oci: { image: "ghcr.io/x@sha256:" + "a".repeat(64) },
|
|
42
|
+
trust: { signingKeyRef: { secretName: "k" } },
|
|
43
|
+
}),
|
|
44
|
+
[],
|
|
45
|
+
);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("plugin: bad class + non-reverse-DNS id rejected (adm-3)", () => {
|
|
49
|
+
const f = validatePluginSpec({
|
|
50
|
+
pluginId: "noDotId",
|
|
51
|
+
pluginClass: "frobnicator",
|
|
52
|
+
version: "1.0",
|
|
53
|
+
oci: { image: "ghcr.io/x@sha256:" + "a".repeat(64) },
|
|
54
|
+
trust: { signingKeyRef: { secretName: "k" } },
|
|
55
|
+
});
|
|
56
|
+
assert.ok(f.some((x) => x.rule === "plugin.pluginId.reverseDns"));
|
|
57
|
+
assert.ok(f.some((x) => x.rule === "plugin.pluginClass.known"));
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("plugin: missing signingKeyRef rejected (trust-9)", () => {
|
|
61
|
+
const f = validatePluginSpec({ oci: { image: "ghcr.io/x@sha256:" + "a".repeat(64) }, trust: {} });
|
|
62
|
+
assert.ok(f.some((x) => x.rule === "plugin.trust.signingKeyRef.required"));
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("revocationList: bad sha + duplicates rejected", () => {
|
|
66
|
+
const f = validateRevocationListSpec({
|
|
67
|
+
version: 1,
|
|
68
|
+
revocations: [{ artifactSha256: "xyz" }, { artifactSha256: "a".repeat(64) }, { artifactSha256: "a".repeat(64) }],
|
|
69
|
+
});
|
|
70
|
+
assert.ok(f.some((x) => x.rule === "revocationList.sha256"));
|
|
71
|
+
assert.ok(f.some((x) => x.rule === "revocationList.noDuplicates"));
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("helpers", () => {
|
|
75
|
+
// case-insensitive 64-hex (the operator accepts uppercase — adm-2/trust-5/parity-5).
|
|
76
|
+
assert.equal(isSha256Hex("a".repeat(64)), true);
|
|
77
|
+
assert.equal(isSha256Hex("A".repeat(64)), true);
|
|
78
|
+
assert.equal(isSha256Hex("a".repeat(63)), false);
|
|
79
|
+
assert.equal(isSha256Hex("g".repeat(64)), false);
|
|
80
|
+
assert.equal(isAnchoredRegexp("^x$"), true);
|
|
81
|
+
assert.equal(isAnchoredRegexp("x"), false);
|
|
82
|
+
// RE2-incompatible lookahead rejected to match Go/operator (parity-1).
|
|
83
|
+
assert.equal(isAnchoredRegexp("^(?=.*x).*$"), false);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("dispatch: ours validated, non-MCPG ignored", () => {
|
|
87
|
+
assert.ok(validateByType("kubernetes:mcpg.dev/v1alpha1:MCPGGateway", {}).length > 0);
|
|
88
|
+
assert.deepEqual(validateByType("kubernetes:core/v1:ConfigMap", {}), []);
|
|
89
|
+
});
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
// Admission-mirror validation helpers — PURE functions so they can be shared
|
|
2
|
+
// by the CrossGuard pack (preview-time) and the contract corpus, and unit
|
|
3
|
+
// tested without a Pulumi runtime. Mirror k8s/operator/src/admission/validators/.
|
|
4
|
+
|
|
5
|
+
export interface Finding {
|
|
6
|
+
rule: string;
|
|
7
|
+
message: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const blank = (s: any): boolean => typeof s !== "string" || s.trim() === "";
|
|
11
|
+
|
|
12
|
+
// pluginClass values the operator accepts (mcpg_plugin_protocol::abi::ALL_KINDS,
|
|
13
|
+
// the single source of truth in libs/plugin-protocol/src/abi.rs).
|
|
14
|
+
const KNOWN_PLUGIN_CLASSES = [
|
|
15
|
+
"tool_gate", "transform", "identity_provider", "backend", "watch_strategy",
|
|
16
|
+
"http_route", "audit_sink", "log_sink", "telemetry_sink", "metrics_sink",
|
|
17
|
+
"store", "cache", "secret_provider", "config_provider", "policy_engine",
|
|
18
|
+
"cluster", "transport", "catalog_provider", "credential_issuer",
|
|
19
|
+
"approval_notifier", "content_store",
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
// Constructs RE2 (the Go provider + the Rust `regex` crate the operator uses)
|
|
23
|
+
// cannot compile but JS RegExp can — lookahead/lookbehind + back-references.
|
|
24
|
+
// Rejecting them keeps the Pulumi verdict identical to the operator.
|
|
25
|
+
const RE2_INCOMPATIBLE = /\(\?<?[=!]|\\[1-9]|\\k</;
|
|
26
|
+
|
|
27
|
+
export function isSha256Hex(s: string): boolean {
|
|
28
|
+
// The operator accepts any 64 ascii-hexdigit string (case-insensitive) and
|
|
29
|
+
// lowercases for dedup — uppercase hashes are valid.
|
|
30
|
+
return /^[0-9a-fA-F]{64}$/.test(s);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** cosign certificateIdentityRegexp must be anchored with ^ and $, compile, AND
|
|
34
|
+
* be RE2-compatible (the operator compiles it with the Rust regex crate). */
|
|
35
|
+
export function isAnchoredRegexp(s: string): boolean {
|
|
36
|
+
if (typeof s !== "string" || !s.startsWith("^") || !s.endsWith("$")) return false;
|
|
37
|
+
if (RE2_INCOMPATIBLE.test(s)) return false;
|
|
38
|
+
try {
|
|
39
|
+
new RegExp(s);
|
|
40
|
+
return true;
|
|
41
|
+
} catch {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const WORKLOAD_IDENTITY_KEYS = ["aws", "gcp", "azure", "spiffe"];
|
|
47
|
+
export function countWorkloadIdentities(wi: any): number {
|
|
48
|
+
if (!wi || typeof wi !== "object") return 0;
|
|
49
|
+
return WORKLOAD_IDENTITY_KEYS.filter((k) => wi[k] != null).length;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function findDuplicates<T>(items: T[], key: (t: T) => string): string[] {
|
|
53
|
+
const seen = new Set<string>();
|
|
54
|
+
const dups = new Set<string>();
|
|
55
|
+
for (const it of items) {
|
|
56
|
+
const k = key(it);
|
|
57
|
+
if (seen.has(k)) dups.add(k);
|
|
58
|
+
seen.add(k);
|
|
59
|
+
}
|
|
60
|
+
return [...dups];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function validateGatewaySpec(spec: any): Finding[] {
|
|
64
|
+
const f: Finding[] = [];
|
|
65
|
+
if (!spec?.image) f.push({ rule: "gateway.image.required", message: "spec.image is required" });
|
|
66
|
+
// replicas, when set, must be ≥ 1 (gateway.rs:65). Absent ⇒ defaulted ⇒ ok.
|
|
67
|
+
if (typeof spec?.replicas === "number" && spec.replicas < 1) {
|
|
68
|
+
f.push({ rule: "gateway.replicas.min", message: "spec.replicas must be ≥ 1" });
|
|
69
|
+
}
|
|
70
|
+
if (spec?.workloadIdentity && countWorkloadIdentities(spec.workloadIdentity) > 1) {
|
|
71
|
+
f.push({ rule: "gateway.workloadIdentity.oneOf", message: "workloadIdentity must be exactly one of aws|gcp|azure|spiffe" });
|
|
72
|
+
}
|
|
73
|
+
// Ingress sub-shape (gateway.rs:114-130) when an ingress block is present.
|
|
74
|
+
const ing = spec?.ingress;
|
|
75
|
+
if (ing) {
|
|
76
|
+
if (blank(ing.ingressClassName)) f.push({ rule: "gateway.ingress.ingressClassName", message: "spec.ingress.ingressClassName must not be empty" });
|
|
77
|
+
const hosts: any[] = ing.hosts ?? [];
|
|
78
|
+
if (hosts.length === 0) f.push({ rule: "gateway.ingress.hosts", message: "spec.ingress.hosts must not be empty when ingress is set" });
|
|
79
|
+
hosts.forEach((h, i) => {
|
|
80
|
+
if (blank(h?.host)) f.push({ rule: "gateway.ingress.host", message: `spec.ingress.hosts[${i}].host is empty` });
|
|
81
|
+
if (!Array.isArray(h?.paths) || h.paths.length === 0) f.push({ rule: "gateway.ingress.paths", message: `spec.ingress.hosts[${i}].paths must not be empty` });
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
// NB: image.tag-non-empty is enforced by the operator AFTER the mutating
|
|
85
|
+
// webhook defaults an empty/absent tag, so it is not a plan-time reject
|
|
86
|
+
// (would false-reject the rely-on-defaulting path). The per-gateway replica
|
|
87
|
+
// cap (tenant_guard) is a cross-resource, client-backed check — both are
|
|
88
|
+
// intentionally NOT mirrored at plan time.
|
|
89
|
+
return f;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function validatePluginSpec(spec: any): Finding[] {
|
|
93
|
+
const f: Finding[] = [];
|
|
94
|
+
const oci = spec?.oci ?? {};
|
|
95
|
+
const trust = spec?.trust ?? {};
|
|
96
|
+
|
|
97
|
+
// Identity + class + version (plugin.rs:56-83).
|
|
98
|
+
if (blank(spec?.pluginId)) f.push({ rule: "plugin.pluginId.nonEmpty", message: "spec.pluginId must not be empty" });
|
|
99
|
+
else if (!spec.pluginId.includes(".")) f.push({ rule: "plugin.pluginId.reverseDns", message: "spec.pluginId is not reverse-DNS form (e.g. dev.mcpg.identity.workload)" });
|
|
100
|
+
if (blank(spec?.version)) f.push({ rule: "plugin.version.nonEmpty", message: "spec.version must not be empty" });
|
|
101
|
+
if (!KNOWN_PLUGIN_CLASSES.includes(spec?.pluginClass)) f.push({ rule: "plugin.pluginClass.known", message: `spec.pluginClass is not a known PluginClass` });
|
|
102
|
+
|
|
103
|
+
// OCI image reference shape (plugin.rs:84-99).
|
|
104
|
+
const img = typeof oci.image === "string" ? oci.image.trim() : "";
|
|
105
|
+
if (img === "") f.push({ rule: "plugin.oci.image.nonEmpty", message: "spec.oci.image must not be empty" });
|
|
106
|
+
else {
|
|
107
|
+
if (!img.includes("/")) f.push({ rule: "plugin.oci.image.registry", message: "spec.oci.image lacks a registry component (<registry>/<path>)" });
|
|
108
|
+
if (!img.includes(":") && !img.includes("@")) f.push({ rule: "plugin.oci.image.tagOrDigest", message: "spec.oci.image lacks a tag or digest pin (:tag or @sha256:...)" });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Trust anchor: digest pin OR cosign identity (plugin.rs:101-112).
|
|
112
|
+
const digestPinned = img.includes("@sha256:");
|
|
113
|
+
const hasCosign = trust.cosignIdentity != null;
|
|
114
|
+
if (!digestPinned && !hasCosign) {
|
|
115
|
+
f.push({ rule: "plugin.trust.anchor", message: "plugin must be digest-pinned OR carry a cosign identity" });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// signingKeyRef (Ed25519) is the MANDATORY baseline (plugin.rs:119-124).
|
|
119
|
+
const skr = trust.signingKeyRef;
|
|
120
|
+
if (blank(skr?.secretName)) {
|
|
121
|
+
f.push({ rule: "plugin.trust.signingKeyRef.required", message: "spec.trust.signingKeyRef.secretName is required (Ed25519 signing is the mandatory trust baseline)" });
|
|
122
|
+
}
|
|
123
|
+
// key defaults to release.pub; only an explicit empty key is invalid.
|
|
124
|
+
if (skr && skr.key !== undefined && blank(skr.key)) {
|
|
125
|
+
f.push({ rule: "plugin.trust.signingKeyRef.key", message: "spec.trust.signingKeyRef.key must not be empty when set" });
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Cosign sub-shape (plugin.rs:126-163).
|
|
129
|
+
if (hasCosign) {
|
|
130
|
+
if (blank(trust.cosignIdentity.certificateIdentityRegexp)) {
|
|
131
|
+
f.push({ rule: "plugin.cosign.regexpNonEmpty", message: "cosign certificateIdentityRegexp must not be empty" });
|
|
132
|
+
} else if (!isAnchoredRegexp(trust.cosignIdentity.certificateIdentityRegexp)) {
|
|
133
|
+
f.push({ rule: "plugin.cosign.anchoredRegexp", message: "cosign certificateIdentityRegexp must be anchored with ^ and $ and compile (RE2)" });
|
|
134
|
+
}
|
|
135
|
+
if (blank(trust.cosignIdentity.oidcIssuer)) {
|
|
136
|
+
f.push({ rule: "plugin.cosign.oidcIssuer", message: "cosign oidcIssuer is required when cosign is set" });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// SLSA provenance sub-shape (plugin.rs:166-178).
|
|
141
|
+
const slsa = trust.slsaProvenance;
|
|
142
|
+
if (slsa) {
|
|
143
|
+
if (blank(slsa.configMapName)) f.push({ rule: "plugin.slsa.configMapName", message: "spec.trust.slsaProvenance.configMapName must not be empty" });
|
|
144
|
+
if (blank(slsa.sourceUri)) f.push({ rule: "plugin.slsa.sourceUri", message: "spec.trust.slsaProvenance.sourceUri must not be empty" });
|
|
145
|
+
if (blank(slsa.sourceTag)) f.push({ rule: "plugin.slsa.sourceTag", message: "spec.trust.slsaProvenance.sourceTag must not be empty" });
|
|
146
|
+
}
|
|
147
|
+
return f;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function validatePluginSetSpec(spec: any): Finding[] {
|
|
151
|
+
const f: Finding[] = [];
|
|
152
|
+
const entries: any[] = spec?.entries ?? [];
|
|
153
|
+
if (entries.length === 0) f.push({ rule: "pluginSet.entries.nonEmpty", message: "entries must be non-empty" });
|
|
154
|
+
const ids = new Set<string>();
|
|
155
|
+
entries.forEach((e, i) => {
|
|
156
|
+
const id = e?.id;
|
|
157
|
+
if (blank(id)) f.push({ rule: "pluginSet.entries.id.nonEmpty", message: `spec.entries[${i}].id must not be empty` });
|
|
158
|
+
else if (!id.includes(".")) f.push({ rule: "pluginSet.entries.id.reverseDns", message: `spec.entries[${i}].id is not reverse-DNS form` });
|
|
159
|
+
if (blank(e?.pluginRef?.name)) f.push({ rule: "pluginSet.entries.pluginRef.name", message: `spec.entries[${i}].pluginRef.name must not be empty` });
|
|
160
|
+
if (typeof id === "string") ids.add(id);
|
|
161
|
+
});
|
|
162
|
+
const dups = findDuplicates(entries, (e) => String(e?.id));
|
|
163
|
+
if (dups.length) f.push({ rule: "pluginSet.entries.uniqueId", message: `duplicate entry ids: ${dups.join(",")}` });
|
|
164
|
+
// capabilityGrants is a MAP (id → [capabilities]); keys must name an entry
|
|
165
|
+
// id and each grant list must be non-empty (plugin_set.rs:85-100).
|
|
166
|
+
const grants = spec?.capabilityGrants;
|
|
167
|
+
if (grants && typeof grants === "object" && !Array.isArray(grants)) {
|
|
168
|
+
for (const id of Object.keys(grants)) {
|
|
169
|
+
if (!ids.has(id)) f.push({ rule: "pluginSet.capabilityGrants.unknownId", message: `capabilityGrants['${id}'] names an id not in entries` });
|
|
170
|
+
else if (!Array.isArray(grants[id]) || grants[id].length === 0) f.push({ rule: "pluginSet.capabilityGrants.empty", message: `capabilityGrants['${id}'] must not be empty` });
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return f;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function validateRevocationListSpec(spec: any): Finding[] {
|
|
177
|
+
const f: Finding[] = [];
|
|
178
|
+
if (spec?.version !== 1) f.push({ rule: "revocationList.version", message: "version must be 1" });
|
|
179
|
+
const revs: any[] = spec?.revocations ?? [];
|
|
180
|
+
for (const r of revs) {
|
|
181
|
+
if (!isSha256Hex(String(r?.artifactSha256 ?? ""))) {
|
|
182
|
+
f.push({ rule: "revocationList.sha256", message: "artifactSha256 must be 64 hex chars" });
|
|
183
|
+
}
|
|
184
|
+
// empty reason defeats the audit trail (revocation_list.rs:86).
|
|
185
|
+
if (blank(r?.reason)) {
|
|
186
|
+
f.push({ rule: "revocationList.reason", message: "revocation reason must not be empty" });
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
// dedup is case-insensitive in the operator (hashes lowercased), so ABCD…
|
|
190
|
+
// and abcd… collide.
|
|
191
|
+
const dups = findDuplicates(revs, (r) => String(r?.artifactSha256 ?? "").toLowerCase());
|
|
192
|
+
if (dups.length) f.push({ rule: "revocationList.noDuplicates", message: `duplicate hashes: ${dups.join(",")}` });
|
|
193
|
+
return f;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Mirrors the MCPGCluster admission rules (validators/cluster.rs). */
|
|
197
|
+
export function validateClusterSpec(spec: any): Finding[] {
|
|
198
|
+
const f: Finding[] = [];
|
|
199
|
+
const backend: string = spec?.backend ?? "single_node";
|
|
200
|
+
const singleNode = backend === "" || backend === "single_node";
|
|
201
|
+
const configEmpty = !spec?.config || Object.keys(spec.config).length === 0;
|
|
202
|
+
if (singleNode && !configEmpty) {
|
|
203
|
+
f.push({ rule: "cluster.singleNode.noConfig", message: "spec.config must be empty for the single_node backend (it takes no parameters)" });
|
|
204
|
+
}
|
|
205
|
+
if (!singleNode && configEmpty) {
|
|
206
|
+
f.push({ rule: "cluster.backend.configRequired", message: "spec.config must not be empty for an external backend — it needs at least a connection address" });
|
|
207
|
+
}
|
|
208
|
+
// Transport security: reject a plaintext coordinator unless opted out
|
|
209
|
+
// with `spec.config.allow_insecure_transport: true`. Mirrors the gateway
|
|
210
|
+
// boot guard + the operator admission webhook (validators/cluster.rs).
|
|
211
|
+
if (!singleNode && !configEmpty && spec?.config?.allow_insecure_transport !== true) {
|
|
212
|
+
const c = spec.config;
|
|
213
|
+
const lead = (s: any) => (typeof s === "string" ? s.replace(/^\s+/, "") : "");
|
|
214
|
+
let insecure: string | null = null;
|
|
215
|
+
if (backend === "redis" && lead(c?.url).startsWith("redis://")) {
|
|
216
|
+
insecure = "the redis `url` uses the plaintext `redis://` scheme (use `rediss://`)";
|
|
217
|
+
} else if (backend === "consul" && lead(c?.address).startsWith("http://")) {
|
|
218
|
+
insecure = "the consul `address` uses the plaintext `http://` scheme (use `https://`)";
|
|
219
|
+
} else if (backend === "etcd" && Array.isArray(c?.endpoints) &&
|
|
220
|
+
c.endpoints.some((e: any) => !lead(e).startsWith("https://"))) {
|
|
221
|
+
insecure = "an etcd `endpoint` is not an `https://` URL (use `https://`)";
|
|
222
|
+
} else if (backend === "nats" && c?.tls?.require_tls === false) {
|
|
223
|
+
insecure = "nats `tls.require_tls` is set to `false` (plaintext)";
|
|
224
|
+
}
|
|
225
|
+
if (insecure) {
|
|
226
|
+
f.push({ rule: "cluster.transport.insecure", message: `spec.config: ${insecure}. Set spec.config.allow_insecure_transport: true to accept plaintext (local/dev only).` });
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
const seen = new Set<string>();
|
|
230
|
+
const refs: any[] = spec?.credentialRefs ?? [];
|
|
231
|
+
refs.forEach((c, i) => {
|
|
232
|
+
if (blank(c?.name)) {
|
|
233
|
+
f.push({ rule: "cluster.credentialRefs.name", message: `spec.credentialRefs[${i}].name must not be empty` });
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
if (blank(c?.secretName)) {
|
|
237
|
+
f.push({ rule: "cluster.credentialRefs.secretName", message: `spec.credentialRefs[${i}].secretName must not be empty` });
|
|
238
|
+
}
|
|
239
|
+
if (seen.has(c.name)) {
|
|
240
|
+
f.push({ rule: "cluster.credentialRefs.uniqueName", message: `spec.credentialRefs name '${c.name}' is duplicated` });
|
|
241
|
+
}
|
|
242
|
+
seen.add(c.name);
|
|
243
|
+
});
|
|
244
|
+
return f;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Mirrors the MCPGRoute admission rules (validators/route.rs). Tenant-unset is
|
|
248
|
+
* an admit-with-warning in the webhook, so it is NOT a reject here. */
|
|
249
|
+
export function validateRouteSpec(spec: any): Finding[] {
|
|
250
|
+
const f: Finding[] = [];
|
|
251
|
+
if (blank(spec?.gatewayRef?.name)) f.push({ rule: "route.gatewayRef.name", message: "spec.gatewayRef.name must not be empty" });
|
|
252
|
+
const tools: any[] = spec?.match?.tools ?? [];
|
|
253
|
+
if (tools.length === 0) f.push({ rule: "route.match.tools.nonEmpty", message: "spec.match.tools must list at least one tool" });
|
|
254
|
+
const seen = new Set<string>();
|
|
255
|
+
tools.forEach((t, i) => {
|
|
256
|
+
const id = typeof t?.id === "string" ? t.id.trim() : "";
|
|
257
|
+
if (id === "") {
|
|
258
|
+
f.push({ rule: "route.match.tools.id", message: `spec.match.tools[${i}].id must not be empty` });
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
if (seen.has(id)) f.push({ rule: "route.match.tools.uniqueId", message: `spec.match.tools contains duplicate tool id '${id}'` });
|
|
262
|
+
seen.add(id);
|
|
263
|
+
});
|
|
264
|
+
for (const chain of ["identityChain", "policyChain", "auditChain"]) {
|
|
265
|
+
const ids: any[] = spec?.[chain] ?? [];
|
|
266
|
+
ids.forEach((id, i) => {
|
|
267
|
+
if (blank(id)) f.push({ rule: "route.chain.nonEmptyId", message: `spec.${chain}[${i}] must not be empty` });
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
return f;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Mirrors the MCPGTenant admission rules (validators/tenant.rs). */
|
|
274
|
+
export function validateTenantSpec(spec: any): Finding[] {
|
|
275
|
+
const f: Finding[] = [];
|
|
276
|
+
const namespaces: any[] = spec?.namespaces ?? [];
|
|
277
|
+
if (namespaces.length === 0) f.push({ rule: "tenant.namespaces.nonEmpty", message: "spec.namespaces must not be empty" });
|
|
278
|
+
const seen = new Set<string>();
|
|
279
|
+
for (const ns of namespaces) {
|
|
280
|
+
if (blank(ns)) {
|
|
281
|
+
f.push({ rule: "tenant.namespaces.nonEmptyEntry", message: "spec.namespaces[] entries must not be empty" });
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
if (seen.has(ns)) f.push({ rule: "tenant.namespaces.unique", message: `spec.namespaces lists '${ns}' more than once` });
|
|
285
|
+
seen.add(ns);
|
|
286
|
+
}
|
|
287
|
+
(spec?.allowedPlugins ?? []).forEach((a: any, i: number) => {
|
|
288
|
+
const nameSet = !blank(a?.name);
|
|
289
|
+
const prefixSet = !blank(a?.registryPrefix);
|
|
290
|
+
if (!nameSet && !prefixSet) f.push({ rule: "tenant.allowedPlugins.matcher", message: `spec.allowedPlugins[${i}] must set name or registryPrefix` });
|
|
291
|
+
});
|
|
292
|
+
if (spec?.quotas) {
|
|
293
|
+
for (const field of ["maxGateways", "maxPluginSets", "maxRoutes", "maxReplicasPerGateway"]) {
|
|
294
|
+
const v = spec.quotas[field];
|
|
295
|
+
if (typeof v === "number" && v < 0) f.push({ rule: "tenant.quotas.nonNegative", message: `spec.quotas.${field} must be ≥ 0` });
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
if (spec?.identityAttribute && blank(spec.identityAttribute.key)) {
|
|
299
|
+
f.push({ rule: "tenant.identityAttribute.key", message: "spec.identityAttribute.key must not be empty when set" });
|
|
300
|
+
}
|
|
301
|
+
return f;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** Mirrors the MCPGPluginMirror admission rules (validators/plugin_mirror.rs). */
|
|
305
|
+
export function validatePluginMirrorSpec(spec: any): Finding[] {
|
|
306
|
+
const f: Finding[] = [];
|
|
307
|
+
const svc = spec?.endpoint?.service ?? {};
|
|
308
|
+
if (blank(svc.namespace)) f.push({ rule: "pluginMirror.endpoint.service.namespace", message: "spec.endpoint.service.namespace must not be empty" });
|
|
309
|
+
if (blank(svc.name)) f.push({ rule: "pluginMirror.endpoint.service.name", message: "spec.endpoint.service.name must not be empty" });
|
|
310
|
+
if (!svc.port) f.push({ rule: "pluginMirror.endpoint.service.port", message: "spec.endpoint.service.port must be in 1..=65535" });
|
|
311
|
+
const up = spec?.upstream ?? {};
|
|
312
|
+
if (blank(up.registry)) {
|
|
313
|
+
f.push({ rule: "pluginMirror.upstream.registry", message: "spec.upstream.registry must not be empty" });
|
|
314
|
+
} else if (!up.registry.includes(".") && !up.registry.includes(":")) {
|
|
315
|
+
f.push({ rule: "pluginMirror.upstream.registryHost", message: `spec.upstream.registry '${up.registry}' does not look like a registry host` });
|
|
316
|
+
}
|
|
317
|
+
if (blank(up.namespace)) f.push({ rule: "pluginMirror.upstream.namespace", message: "spec.upstream.namespace must not be empty" });
|
|
318
|
+
if (spec?.auth && blank(spec.auth.secretRef?.secretName)) {
|
|
319
|
+
f.push({ rule: "pluginMirror.auth.secretName", message: "spec.auth.secretRef.secretName must not be empty when auth is set" });
|
|
320
|
+
}
|
|
321
|
+
return f;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** Dispatch by Pulumi resource type token (…:MCPGGateway etc.). */
|
|
325
|
+
export function validateByType(type: string, spec: any): Finding[] {
|
|
326
|
+
if (type.endsWith(":MCPGGateway")) return validateGatewaySpec(spec);
|
|
327
|
+
if (type.endsWith(":MCPGPlugin")) return validatePluginSpec(spec);
|
|
328
|
+
if (type.endsWith(":MCPGPluginSet")) return validatePluginSetSpec(spec);
|
|
329
|
+
if (type.endsWith(":MCPGRevocationList")) return validateRevocationListSpec(spec);
|
|
330
|
+
if (type.endsWith(":MCPGCluster")) return validateClusterSpec(spec);
|
|
331
|
+
if (type.endsWith(":MCPGRoute")) return validateRouteSpec(spec);
|
|
332
|
+
if (type.endsWith(":MCPGTenant")) return validateTenantSpec(spec);
|
|
333
|
+
if (type.endsWith(":MCPGPluginMirror")) return validatePluginMirrorSpec(spec);
|
|
334
|
+
return [];
|
|
335
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "es2020",
|
|
4
|
+
"module": "commonjs",
|
|
5
|
+
"moduleResolution": "node",
|
|
6
|
+
"declaration": true,
|
|
7
|
+
"outDir": "bin",
|
|
8
|
+
"rootDir": "src",
|
|
9
|
+
"strict": true,
|
|
10
|
+
"esModuleInterop": true,
|
|
11
|
+
"skipLibCheck": true,
|
|
12
|
+
"forceConsistentCasingInFileNames": true
|
|
13
|
+
},
|
|
14
|
+
"include": ["src/**/*.ts"]
|
|
15
|
+
}
|