Secure CI/CD boundary and fail-closed gate lab
Exercises untrusted workflow boundaries and fail-closed scanner evidence against local positive and negative fixtures.
DevSecOps 5 min read
Implementation: Tested
Implementation
labs/secure-cicd/gate.js
1#!/usr/bin/env node
2"use strict";
3
4const fs = require("node:fs");
5
6function fail(message, status = 2) {
7 console.error(`GATE_ERROR: ${message}`);
8 process.exit(status);
9}
10
11function loadJson(file) {
12 try {
13 return JSON.parse(fs.readFileSync(file, "utf8"));
14 } catch (error) {
15 fail(`cannot read valid JSON from ${file}: ${error.message}`);
16 }
17}
18
19const [, , reportPath, policyPath] = process.argv;
20if (!reportPath || !policyPath) fail("usage: node gate.js <report.json> <policy.json>");
21const report = loadJson(reportPath);
22const policy = loadJson(policyPath);
23
24if (report.schemaVersion !== 1) fail("unsupported or missing report schemaVersion");
25if (report.scanStatus !== "completed") fail(`scanner did not complete successfully (status: ${String(report.scanStatus)})`);
26if (!report.findings || typeof report.findings !== "object") fail("missing findings object");
27
28for (const severity of ["critical", "high", "secret"]) {
29 const value = report.findings[severity];
30 if (!Number.isInteger(value) || value < 0) fail(`findings.${severity} must be a nonnegative integer`);
31}
32if (!Number.isInteger(policy.maxCritical) || policy.maxCritical < 0) fail("policy.maxCritical must be a nonnegative integer");
33if (!Number.isInteger(policy.maxHigh) || policy.maxHigh < 0) fail("policy.maxHigh must be a nonnegative integer");
34if (typeof policy.allowSecrets !== "boolean") fail("policy.allowSecrets must be boolean");
35
36const violations = [];
37if (report.findings.critical > policy.maxCritical) violations.push(`critical findings ${report.findings.critical} exceed ${policy.maxCritical}`);
38if (report.findings.high > policy.maxHigh) violations.push(`high findings ${report.findings.high} exceed ${policy.maxHigh}`);
39if (!policy.allowSecrets && report.findings.secret > 0) violations.push(`secret findings ${report.findings.secret} exceed 0`);
40
41if (violations.length) {
42 console.error("GATE_BLOCKED:");
43 for (const violation of violations) console.error(`- ${violation}`);
44 process.exit(3);
45}
46console.log("GATE_PASSED: scanner completed and all policy thresholds were satisfied.");
1"use strict";
2
3const assert = require("node:assert/strict");
4const fs = require("node:fs");
5const path = require("node:path");
6const { spawnSync } = require("node:child_process");
7const YAML = require("yaml");
8const lab = path.resolve(__dirname, "..");
9const gate = path.join(lab, "gate.js");
10const policy = path.join(lab, "fixtures", "policy.json");
11
12function expect(fixture, status, marker) {
13 const result = spawnSync(process.execPath, [gate, path.join(lab, "fixtures", fixture), policy], { encoding: "utf8" });
14 assert.equal(result.status, status, `${fixture}: ${result.stderr || result.stdout}`);
15 assert.match(`${result.stdout}\n${result.stderr}`, marker);
16}
17
18expect("report-pass.json", 0, /GATE_PASSED/);
19expect("report-critical.json", 3, /critical findings/);
20expect("report-secret.json", 3, /secret findings/);
21expect("report-scanner-failed.json", 2, /scanner did not complete/);
22expect("report-invalid.json", 2, /nonnegative integer/);
23
24const safeText = fs.readFileSync(path.join(lab, "fixtures", "safe-pr.yml"), "utf8");
25const safe = YAML.parse(safeText);
26assert.deepEqual(safe.permissions, { contents: "read" });
27assert.match(safeText, /actions\/checkout@[a-f0-9]{40}/);
28assert.match(safeText, /persist-credentials: false/);
29assert.doesNotMatch(safeText, /pull_request_target/);
30
31const unsafeText = fs.readFileSync(path.join(lab, "fixtures", "unsafe-pr-target.workflow.yaml.txt"), "utf8");
32assert.match(unsafeText, /pull_request_target/);
33assert.match(unsafeText, /permissions: write-all/);
34assert.match(unsafeText, /actions\/checkout@v4/);
35assert.match(unsafeText, /github\.event\.pull_request\.head\.sha/);
36
37console.log("PASS: CI/CD gate and safe/unsafe workflow fixture tests completed.");
1"use strict";
2
3const assert = require("node:assert/strict");
4const fs = require("node:fs");
5const path = require("node:path");
6
7const fixtures = path.resolve(__dirname, "..", "fixtures");
8
9function read(name) {
10 return fs.readFileSync(path.join(fixtures, name), "utf8");
11}
12
13function remoteActionReferences(text) {
14 return [...text.matchAll(/^\s*(?:-\s*)?uses:\s*([^\s#]+).*$/gm)]
15 .map((match) => match[1])
16 .filter((reference) => !reference.startsWith("./"));
17}
18
19function audit(text) {
20 const findings = new Set();
21 const actionReferences = remoteActionReferences(text);
22
23 if (actionReferences.some((reference) => !/@[0-9a-f]{40}$/.test(reference))) {
24 findings.add("mutable-action-reference");
25 }
26
27 if (/^\s*permissions:\s*write-all\s*$/m.test(text)) {
28 findings.add("broad-workflow-permissions");
29 }
30
31 if (
32 /\bpull_request_target\b/.test(text)
33 && /github\.event\.pull_request\.head\.sha/.test(text)
34 && /^\s*-\s*run:/m.test(text)
35 ) {
36 findings.add("privileged-pr-event-executes-untrusted-code");
37 }
38
39 if (
40 /\bpull_request:\s*$/m.test(text)
41 && !/\bworkflow_run:\s*$/m.test(text)
42 && (/id-token:\s*write/.test(text) || /secrets\./.test(text))
43 ) {
44 findings.add("untrusted-validation-has-credential-capability");
45 }
46
47 const cloudAuthentication = text.indexOf("uses: azure/login@");
48 const deployCommand = text.search(/^\s*(?:run:\s*)?(?:\.\/.*deploy|az\s+(?:deployment|webapp|containerapp)\b)/m);
49 const privilegedUse = cloudAuthentication >= 0 ? cloudAuthentication : deployCommand;
50
51 if (privilegedUse >= 0) {
52 const digestVerification = text.indexOf("sha256sum --check");
53 const provenanceVerification = text.indexOf("gh attestation verify");
54
55 if (digestVerification < 0 || digestVerification > privilegedUse) {
56 findings.add("digest-not-verified-before-privileged-use");
57 }
58 if (provenanceVerification < 0 || provenanceVerification > privilegedUse) {
59 findings.add("provenance-not-verified-before-privileged-use");
60 }
61 if (!/^\s*environment:\s*(?:$|[^\n]+)/m.test(text)) {
62 findings.add("protected-environment-not-bound");
63 }
64 }
65
66 if (cloudAuthentication >= 0 && !/id-token:\s*write/.test(text)) {
67 findings.add("oidc-permission-missing");
68 }
69
70 if (
71 /secrets\.(?:LONG_LIVED|AWS_ACCESS_KEY|AWS_SECRET|AZURE_CLIENT_SECRET|CLOUD_ACCESS|CLOUD_SECRET)/.test(text)
72 ) {
73 findings.add("long-lived-cloud-credential");
74 }
75
76 if (
77 /\bpull_request:\s*$/m.test(text)
78 && /\bworkflow_run:\s*$/m.test(text)
79 && /uses:\s*actions\/cache@/.test(text)
80 && /key:\s*shared-release-tools/.test(text)
81 ) {
82 findings.add("shared-cache-crosses-trust-boundary");
83 }
84
85 if (
86 /uses:\s*actions\/cache@/.test(text)
87 && /run:\s*\.\/\.pipeline-tools\/promote/.test(text)
88 ) {
89 findings.add("privileged-job-executes-cache-content");
90 }
91
92 return findings;
93}
94
95function expectNoFinding(findings, name, context) {
96 assert.equal(
97 findings.has(name),
98 false,
99 `${context}: unexpected ${name}; found ${[...findings].sort().join(", ")}`
100 );
101}
102
103function expectFinding(findings, name, context) {
104 assert.equal(
105 findings.has(name),
106 true,
107 `${context}: expected ${name}; found ${[...findings].sort().join(", ")}`
108 );
109}
110
111const safePullRequest = read("safe-pr.yml");
112const safePullRequestFindings = audit(safePullRequest);
113assert.match(safePullRequest, /^\s*pull_request:\s*$/m);
114assert.doesNotMatch(safePullRequest, /\bpull_request_target\b/);
115assert.match(safePullRequest, /^\s*permissions:\s*\n\s+contents:\s*read\s*$/m);
116assert.doesNotMatch(safePullRequest, /\b(?:environment|id-token):/);
117assert.doesNotMatch(safePullRequest, /secrets\./);
118expectNoFinding(safePullRequestFindings, "mutable-action-reference", "safe PR fixture");
119expectNoFinding(
120 safePullRequestFindings,
121 "untrusted-validation-has-credential-capability",
122 "safe PR fixture"
123);
124
125const trustedRelease = read("trusted-build-release.workflow.yml");
126const trustedReleaseFindings = audit(trustedRelease);
127assert.ok(remoteActionReferences(trustedRelease).length >= 5);
128assert.match(trustedRelease, /name:\s*release-\$\{\{\s*github\.sha\s*\}\}/);
129assert.match(trustedRelease, /if-no-files-found:\s*error/);
130assert.match(trustedRelease, /environment:\s*\n\s+name:\s*production/);
131assert.match(trustedRelease, /id-token:\s*write/);
132assert.match(trustedRelease, /uses:\s*azure\/login@[0-9a-f]{40}/);
133assert.match(
134 trustedRelease,
135 /--signer-workflow "\$GITHUB_REPOSITORY\/\.github\/workflows\/trusted-build-release\.yml"/,
136);
137assert.match(trustedRelease, /--source-ref "refs\/heads\/main"/);
138assert.match(trustedRelease, /--source-digest "\$GITHUB_SHA"/);
139assert.ok(
140 trustedRelease.indexOf("--signer-workflow") < trustedRelease.indexOf("uses: azure/login@"),
141 "trusted release fixture must constrain signer workflow before cloud authentication",
142);
143assert.doesNotMatch(trustedRelease, /secrets\./);
144assert.doesNotMatch(trustedRelease, /uses:\s*actions\/cache@/);
145for (const finding of [
146 "mutable-action-reference",
147 "broad-workflow-permissions",
148 "digest-not-verified-before-privileged-use",
149 "provenance-not-verified-before-privileged-use",
150 "protected-environment-not-bound",
151 "oidc-permission-missing",
152 "long-lived-cloud-credential",
153]) {
154 expectNoFinding(trustedReleaseFindings, finding, "trusted release fixture");
155}
156
157const unsafePullRequestTarget = read("unsafe-pr-target.workflow.yaml.txt");
158const unsafePullRequestTargetFindings = audit(unsafePullRequestTarget);
159expectFinding(
160 unsafePullRequestTargetFindings,
161 "privileged-pr-event-executes-untrusted-code",
162 "unsafe pull_request_target fixture"
163);
164expectFinding(
165 unsafePullRequestTargetFindings,
166 "mutable-action-reference",
167 "unsafe pull_request_target fixture"
168);
169expectFinding(
170 unsafePullRequestTargetFindings,
171 "broad-workflow-permissions",
172 "unsafe pull_request_target fixture"
173);
174
175const unsafeConsumer = read("unsafe-privileged-consumer.workflow.yaml.txt");
176const unsafeConsumerFindings = audit(unsafeConsumer);
177for (const finding of [
178 "mutable-action-reference",
179 "broad-workflow-permissions",
180 "digest-not-verified-before-privileged-use",
181 "provenance-not-verified-before-privileged-use",
182 "protected-environment-not-bound",
183 "long-lived-cloud-credential",
184]) {
185 expectFinding(unsafeConsumerFindings, finding, "unsafe artifact consumer fixture");
186}
187
188const unsafeCache = read("unsafe-shared-cache.workflow.yaml.txt");
189const unsafeCacheFindings = audit(unsafeCache);
190for (const finding of [
191 "mutable-action-reference",
192 "broad-workflow-permissions",
193 "shared-cache-crosses-trust-boundary",
194 "privileged-job-executes-cache-content",
195]) {
196 expectFinding(unsafeCacheFindings, finding, "unsafe shared-cache fixture");
197}
198
199console.log(
200 "PASS: 7 CI/CD boundaries accepted the hardened fixtures and rejected their unsafe counterparts."
201);
Run it
node labs/secure-cicd/tests/run-tests.jsnode --test labs/secure-cicd/tests/policy-tests.js
Evidence status: partially tested. The local tests evaluate fixture structure and fail-closed gate behavior; they do not execute a hosted workflow, authenticate to Azure, or inspect repository environment settings.
This lab makes CI/CD trust-boundary decisions reproducible. It keeps untrusted pull- request code away from privileged workflow context, rejects unsafe workflow patterns, and blocks when scanner evidence is missing, malformed, incomplete, or above policy.
Prerequisites and run commands
- Node.js 24.12.0 (the tests also support a compatible Node.js 22+ runtime).
- Locked repository dependencies installed with
npm ci --ignore-scriptsfor the existing YAML/gate suite.
The security-policy suite uses Node.js built-ins only:
node labs/secure-cicd/tests/policy-tests.js
Run the existing gate and YAML fixture suite after installing locked dependencies:
node labs/secure-cicd/tests/run-tests.js
Expected output ends with PASS. The gate test asserts exit code 0 only for a completed scanner report within policy. Critical findings and secret findings return 3; invalid or failed scanner input returns 2. A pipeline should treat every nonzero code as blocking.
Covered boundaries
| Boundary | Accepted evidence | Rejected evidence |
|---|---|---|
| Immutable dependencies | Every remote action in hardened fixtures uses a full 40-character commit SHA | Mutable major tags such as actions/cache@v4 |
| Least privilege | Read-only default token and narrowly scoped job permissions | permissions: write-all |
| Untrusted PR isolation | Ordinary pull_request, no secrets/OIDC/environment, credential-free checkout |
pull_request_target plus attacker-head checkout and shell execution |
| Artifact integrity | Revision-bound artifact name and sha256sum --check before cloud authentication; this detects corruption but is not an independent trust anchor |
Privileged execution of an artifact without digest verification |
| Provenance | GitHub attestation creation and verification constrained to repository, signer workflow, protected source ref, and source digest before cloud authentication | Privileged artifact use without provenance verification or with repository-only signer constraints |
| Release approval boundary | Release job binds to the production environment |
Privileged consumer with no environment binding |
| Cloud authentication | Azure workload federation via job-scoped id-token: write; identifiers come from non-secret variables |
Stored long-lived cloud credential references |
| Cache isolation | Privileged release consumes no dependency/tool cache | Untrusted producer and privileged consumer share and execute a predictable cache |
The tests deliberately exercise both sides. A negative fixture passing as hardened - or a hardened fixture matching a prohibited condition - fails the suite.
Checksum and attestation trust
release.txt.sha256 travels beside release.txt. It can detect corruption, but an attacker who can replace both files can generate a matching checksum. The external trust decision is the GitHub attestation: the fixture requires the expected repository, the exact .github/workflows/trusted-build-release.yml signer, protected refs/heads/main source ref, and the triggering GITHUB_SHA source digest.
GitHub CLI 2.96.0 accepts the fixture's signer syntax as owner/repository/path/to/workflow. The local suite asserts that all four constraints appear before Azure authentication; it does not fetch a live attestation or prove that a hosted workflow produced one. Predicate data remains partly under the originating workflow's control. A trusted reusable builder can provide stronger isolation when its inputs, permissions, build, and signing steps are themselves designed as a protected boundary.
Fixtures
safe-pr.ymluses the ordinarypull_requestevent, read-only content permission, credential-free checkout, full-SHA action pins, lockfile installation without lifecycle scripts, and an environment variable for untrusted PR text.trusted-build-release.workflow.ymlbuilds only a protectedmainrevision, records a digest, creates provenance, downloads the revision-bound artifact into a separate release job, verifies digest and provenance, binds the job to theproductionenvironment, and only then exchanges GitHub OIDC identity for an Azure session. Attestation verification constrains repository, signer workflow, source ref, and source digest. Its final command validates the session and does not deploy resources.unsafe-pr-target.workflow.yaml.txtcombinespull_request_target, write-all token, mutable action tag, untrusted head checkout, and expression injection into shell.unsafe-privileged-consumer.workflow.yaml.txtdownloads and executes an unverified artifact with stored cloud credential references and no protected environment.unsafe-shared-cache.workflow.yaml.txtlets untrusted code populate a predictable cache that a privileged job restores and executes.azure-pipelines.safe.ymlshows a credential-free PR validation stage. A separate protected release stage and workload-federated service connection are still required for deployment.
Files ending in .workflow.yaml.txt are intentionally non-runnable negative fixtures. The positive GitHub workflow files live outside .github/workflows, so repository validation cannot authenticate or deploy.
Required platform configuration
The environment: production declaration creates a binding, not an approval by itself. Configure required reviewers, branch/tag restrictions, and environment secrets/variables in GitHub repository settings. Configure the Azure federated credential to accept only the intended repository and protected-environment subject, then grant the resulting identity only the required Azure role. Those settings are outside this offline lab and require separate review and a non-production exchange test.
Do not move untrusted artifacts or caches into the release boundary merely because a later workflow is privileged. Bind an artifact to its producer, repository, revision, digest, and expected provenance identity; parse reports as data and never execute untrusted artifact or cache content.
Failure modes and limitations
The gate validates a small normalized report; production integrations must authenticate report provenance, bind it to the commit and tool configuration, handle waivers with expiry/ownership, and preserve evidence. Thresholds are an example, not an assertion of acceptable organizational risk.
The policy suite is intentionally dependency-free static analysis over controlled fixtures. It does not replace a general YAML parser or platform policy engine, prove the safety of pinned third-party actions, verify GitHub/Azure configuration, exercise environment approval, or validate a real cloud role. GitHub-hosted runner behavior, artifact service authorization, and attestation verification must be tested in the target repository before release is enabled.
Cleanup
The lab creates no persistent resources. It reads fixtures and starts child Node.js processes only.