diff --git a/.github/workflows/nddev-security-bundle.yml b/.github/workflows/nddev-security-bundle.yml index 0aef92e..fb1b71d 100644 --- a/.github/workflows/nddev-security-bundle.yml +++ b/.github/workflows/nddev-security-bundle.yml @@ -137,6 +137,8 @@ jobs: run: "$NDDEV_SECURITY_BUNDLE_SCRIPT" - name: Upload redacted security evidence + id: evidence_artifact + continue-on-error: true # a failed upload must complete the checked log fallback # Only when the gates actually ran. These uploads were `always()`, so a # job that died in its own bootstrap -- before any scanner existed -- # ended by reporting `Path does not exist: @@ -158,6 +160,13 @@ jobs: retention-days: 1 compression-level: 9 + - name: Preserve security evidence in the run log + if: ${{ !cancelled() && steps.evidence_artifact.outcome == 'failure' }} + shell: bash + run: | + set -euo pipefail + bash "$(dirname "$NDDEV_SECURITY_BUNDLE_SCRIPT")/security_evidence_to_log.sh" + - name: Upload zizmor SARIF if: ${{ !cancelled() && steps.scan.conclusion != 'skipped' }} uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 diff --git a/.github/workflows/private-security-bundle-free.yml b/.github/workflows/private-security-bundle-free.yml index 98bdb42..4db5884 100644 --- a/.github/workflows/private-security-bundle-free.yml +++ b/.github/workflows/private-security-bundle-free.yml @@ -215,6 +215,8 @@ jobs: run: "$PRIVATE_SECURITY_BUNDLE_SCRIPT" - name: Upload redacted security evidence + id: evidence_artifact + continue-on-error: true # a failed upload must complete the checked log fallback # Only when the gates actually ran; see the note in # nddev-security-bundle.yml. `always()` here turned a bootstrap failure # into a missing-SARIF message that named the wrong step. @@ -230,3 +232,10 @@ jobs: if-no-files-found: error retention-days: 1 compression-level: 9 + + - name: Preserve security evidence in the run log + if: ${{ !cancelled() && steps.evidence_artifact.outcome == 'failure' }} + shell: bash + run: | + set -euo pipefail + bash "$(dirname "$PRIVATE_SECURITY_BUNDLE_SCRIPT")/security_evidence_to_log.sh" diff --git a/CHANGELOG.md b/CHANGELOG.md index 7fae6fb..04f7f2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ The project follows Semantic Versioning. ## [Unreleased] +- Re-verify GitLab Free and Open Source allowances against current primary + sources, with separate review deadlines and explicit eligibility conditions. + +- Preserve complete redacted private security evidence in a bounded, checksummed + run-log ZIP when artifact upload fails. Scanner enforcement and failed fallback + remain blocking; no new token permission or external storage is required. + - Add optional `check_name` to the private-free security bundle so callers can retain an existing required check identity when migrating away from SARIF publication, with all four scanners and evidence artifacts preserved. diff --git a/catalog/capabilities.yml b/catalog/capabilities.yml index 9031b8e..e566b6f 100644 --- a/catalog/capabilities.yml +++ b/catalog/capabilities.yml @@ -271,6 +271,7 @@ capabilities: - "Private repositories only; a public fork must never reach a self-hosted runner" - "Requires GitHub Code Security because zizmor SARIF upload is preserved; private-free callers keep the no-SARIF workflows" - "Four gates share one failure domain, but the script executes and summarizes every gate before returning failure" + - "Artifact upload failure requires a complete bounded redacted ZIP with SHA-256 and run identity in the run log; failure of both transports fails the job" - "The called workflow fetches its implementation from job.workflow_repository at exact job.workflow_sha outside the caller workspace" - "Downloaded actionlint, OSV-Scanner, and gitleaks artifacts are checksum-pinned; zizmor is uvx version-pinned" deprecations: null @@ -301,7 +302,7 @@ capabilities: - "Four gates share one failure domain, but every gate executes before aggregate failure" - "Optional check_name preserves the inner job display name during required-check migration; caller prefix and scanner enforcement remain separate" - "Called-workflow implementation is fetched at exact job.workflow_sha outside the caller workspace" - - "Gitleaks evidence is fully redacted before the always-uploaded one-day artifact is created" + - "Gitleaks evidence is fully redacted before the one-day artifact upload; a failed upload requires a complete bounded ZIP with SHA-256 and run identity in the run log" deprecations: null last_verified: "2026-08-22" sources: diff --git a/catalog/product-facts.yml b/catalog/product-facts.yml index bb365e8..41136de 100644 --- a/catalog/product-facts.yml +++ b/catalog/product-facts.yml @@ -617,16 +617,18 @@ facts: conditions: - Shared-runner use may require identity/payment verification - Own runners do not consume hosted compute quota - - 10 GiB project storage + - 10 GiB adjustable storage + - Five users per top-level group on Free payment_method: may-be-required-for-shared-runners overage: purchase additional compute or use own runners status: official source_authority: primary - verified_at: '2026-08-10' - expires_after: '2026-09-11' + verified_at: '2026-09-13' + expires_after: '2026-10-12' source_urls: - https://about.gitlab.com/pricing/ - notes: [] + notes: + - "Re-verified 2026-09-13: the official pricing page still lists 400 compute minutes per month, 10 GiB adjustable storage, and five users per top-level group; creating a Free account needs no card, while shared-runner verification remains separate." - id: gitlab-open-source-program provider: GitLab product: GitLab for Open Source @@ -643,6 +645,8 @@ facts: multipliers: {} conditions: - Qualification and acceptance required + - Compute minutes use the program-specific cost factor + - Program membership does not include support - Project must remain eligible under program rules - Membership must be renewed annually or the account is downgraded - The program page states the compute-minute grant alongside an annual renewal cycle without naming @@ -651,11 +655,13 @@ facts: overage: program/plan dependent status: official source_authority: primary - verified_at: '2026-08-10' - expires_after: '2026-09-11' + verified_at: '2026-09-13' + expires_after: '2026-10-15' source_urls: - https://about.gitlab.com/solutions/open-source/join/ + - https://about.gitlab.com/pricing/ notes: + - "Re-verified 2026-09-13: the official programme page retains the Ultimate grant and 50,000 compute minutes, public OSI-licensed non-profit eligibility and annual renewal; the pricing page states the Ultimate monthly allowance. Community subscriptions currently cannot purchase GitLab Credits." - 'Re-verified 2026-08-10: grant is GitLab Ultimate (self-managed or SaaS) including 50,000 compute minutes, gated on OSI-approved licence, public visibility and non-profit status, applied for through the Customers Portal and renewed annually.' diff --git a/catalog/python-execution.yml b/catalog/python-execution.yml index 843700e..da49238 100644 --- a/catalog/python-execution.yml +++ b/catalog/python-execution.yml @@ -524,7 +524,8 @@ ], "check_workflow_contracts.py": [ "_runners", - "_workflow_yaml" + "_workflow_yaml", + "check_python_execution_contract" ], "compile_evidence_plan.py": [ "_strict_yaml", @@ -664,18 +665,18 @@ "profile": "isolated-python-fixture" } }, - "check_gate_contract.py": { - "_run": { - "count": 1, - "profile": "isolated-python-fixture" - } - }, "check_consumer_skill_contract.py": { "_git": { "count": 1, "profile": "external-tool" } }, + "check_gate_contract.py": { + "_run": { + "count": 1, + "profile": "isolated-python-fixture" + } + }, "check_maintenance_report_contract.py": { "_run_sweep": { "count": 1, @@ -782,6 +783,12 @@ "profile": "shell-fixture" } }, + "check_workflow_contracts.py": { + "run": { + "count": 1, + "profile": "shell-fixture" + } + }, "generate_android_fixture_provenance.py": { "_java_properties": { "count": 1, diff --git a/docs/06-security-scanning.md b/docs/06-security-scanning.md index ea64a33..7742d01 100644 --- a/docs/06-security-scanning.md +++ b/docs/06-security-scanning.md @@ -99,3 +99,28 @@ All three can emit SARIF for upload on public/GHAS repos. See --- Last verified: 2026-07-10 + +## Private security evidence when artifact storage is unavailable + +Both consolidated private security workflows first upload their redacted reports +as a one-day artifact. If that transport fails, the exact called-workflow source +captures all four reports into a deterministic ZIP and writes its base64 bytes +to the fallback step's run log. The job summary records the ZIP digest and its +location. This uses the run's existing visibility and log retention, without +new credentials, permissions, or an external storage service. + +The archive is delimited by `SECURITY_EVIDENCE_V1_BEGIN ` and +`SECURITY_EVIDENCE_V1_END `. To recover it, obtain the fallback step's log, +decode the base64 lines between those markers, verify the ZIP's SHA-256 and byte +count, then verify its `SHA256SUMS`. `identity.json` binds repository, source +commit, workflow run and attempt. It contains the actionlint log and all three +SARIF files, including fully redacted Gitleaks evidence. + +The fallback refuses missing, symlinked, non-regular, changing, malformed, or +oversized reports (4 MiB per report). An incomplete archive is never emitted. +Failure of both delivery paths fails the job. A scanner failure also remains +blocking after successful evidence delivery; an upload warning is not a clean +security finding. + +GitHub artifact storage and run logs are distinct retention surfaces. Storage +quota updates can take time; see the official [Actions billing documentation](https://docs.github.com/en/billing/concepts/product-billing/github-actions). diff --git a/docs/generated/free-tier-matrix.md b/docs/generated/free-tier-matrix.md index cac08ef..55428e3 100644 --- a/docs/generated/free-tier-matrix.md +++ b/docs/generated/free-tier-matrix.md @@ -38,8 +38,8 @@ past its expiry, so a stale tier claim cannot silently mislead adopters. | `github-secret-scanning-private-org` | GitHub | Secret scanning / Secret Protection | private, internal | Team, Enterprise Cloud | add-on-gated | repository-feature | official | 2026-08-10 | 2026-11-06 | | `github-secret-scanning-public` | GitHub | Secret scanning | public | All current plans | included-feature | repository-feature | official | 2026-08-10 | 2026-11-06 | | `github-slsa-build-level-reusable` | GitHub | SLSA build level for artifact attestations | public, private, internal | Free, Pro, Team, Enterprise Cloud | included-feature | 3 slsa-v1-build-level/feature | official | 2026-08-14 | 2026-11-06 | -| `gitlab-free-hosted` | GitLab | GitLab.com Free compute | public, private | Free | recurring-quota | 400 compute-minutes/month | official | 2026-08-10 | 2026-09-11 | -| `gitlab-open-source-program` | GitLab | GitLab for Open Source | public | Ultimate for qualifying OSS | application-grant | 50000 compute-minutes/month | official | 2026-08-10 | 2026-09-11 | +| `gitlab-free-hosted` | GitLab | GitLab.com Free compute | public, private | Free | recurring-quota | 400 compute-minutes/month | official | 2026-09-13 | 2026-10-12 | +| `gitlab-open-source-program` | GitLab | GitLab for Open Source | public | Ultimate for qualifying OSS | application-grant | 50000 compute-minutes/month | official | 2026-09-13 | 2026-10-15 | | `google-cloud-build-free` | Google Cloud | Cloud Build free tier | public, private | Google Cloud Free Tier | recurring-quota | 2500 e2-standard-2-build-minutes/month | official | 2026-08-10 | 2026-10-16 | | `harness-free` | Harness | Harness Cloud CI Free | public, private | Free | credit-quota | 2000 credits/month | conditional | 2026-09-07 | 2026-09-17 | | `teamcity-professional` | JetBrains | TeamCity Professional | public, private, internal | Professional | zero-license-self-hosted | unmetered (build-minutes) | official | 2026-08-10 | 2026-10-23 | diff --git a/examples/private-free/security-selfhosted.yml b/examples/private-free/security-selfhosted.yml index 3bfff0a..e7bef84 100644 --- a/examples/private-free/security-selfhosted.yml +++ b/examples/private-free/security-selfhosted.yml @@ -1,3 +1,5 @@ +# If artifact storage is unavailable, complete redacted reports are preserved +# as a checksummed ZIP in the security evidence fallback step log. # Private security suite with zero GitHub Actions compute meter. # Replace `private-ci` with an isolated ephemeral runner label registered only # to private repositories. Destroy the worker after one job; cost control must not diff --git a/examples/private-paid-ghas/security-selfhosted.yml b/examples/private-paid-ghas/security-selfhosted.yml index b08559c..c2fd808 100644 --- a/examples/private-paid-ghas/security-selfhosted.yml +++ b/examples/private-paid-ghas/security-selfhosted.yml @@ -1,3 +1,5 @@ +# If artifact storage is unavailable, complete redacted reports are preserved +# as a checksummed ZIP in the security evidence fallback step log. # Private repository WITH GitHub Code Security, on a caller-owned self-hosted # label. This is an explicit paid opt-in: zizmor keeps SARIF upload. Callers # without Code Security must use examples/private-free/security-selfhosted.yml diff --git a/scripts/check_workflow_contracts.py b/scripts/check_workflow_contracts.py index fceec66..c0a8849 100644 --- a/scripts/check_workflow_contracts.py +++ b/scripts/check_workflow_contracts.py @@ -12,11 +12,20 @@ """ from __future__ import annotations +import base64 +import hashlib +import io +import json +import os import re +import subprocess +import tempfile +import zipfile import shlex import sys from pathlib import Path +from ci_workflows_tools.check_python_execution_contract import clean_environment from ci_workflows_tools._runners import is_standard_hosted, resolve_runner_labels from ci_workflows_tools._workflow_yaml import SELF_WORKFLOWS, get_on, is_reusable, load_yaml, workflow_files @@ -170,6 +179,30 @@ def check() -> list[str]: "nddev-security-bundle.yml: redacted evidence must upload exactly once with one-day retention" ) + # Transport failure is recoverable only when complete evidence survives. + for filename, variable in ( + ("nddev-security-bundle.yml", "NDDEV_SECURITY_BUNDLE_SCRIPT"), + ("private-security-bundle-free.yml", "PRIVATE_SECURITY_BUNDLE_SCRIPT"), + ): + workflow = load_yaml(workflow_root / filename) + job = workflow["jobs"]["security-bundle"] + steps = job["steps"] + upload = next((step for step in steps if step.get("id") == "evidence_artifact"), {}) + fallback = next((step for step in steps if step.get("name") == "Preserve security evidence in the run log"), {}) + if (upload.get("continue-on-error") is not True + or fallback.get("if") != "${{ !cancelled() && steps.evidence_artifact.outcome == 'failure' }}" + or fallback.get("continue-on-error") + or f'$(dirname "${variable}")/security_evidence_to_log.sh' not in fallback.get("run", "")): + problems.append(f"{filename}: upload failure must require the exact checked log fallback") + scan = next(step for step in steps if step.get("id") == "scan") + if filename == "private-security-bundle-free.yml" and scan.get("continue-on-error"): + problems.append(f"{filename}: scanner failure must remain blocking") + if filename == "nddev-security-bundle.yml" and not any( + step.get("if") == "${{ steps.scan.outcome == 'failure' }}" + and step.get("run") == "exit 1" for step in steps): + problems.append(f"{filename}: deferred scanner failure must still be enforced") + problems.extend(_probe_security_evidence(workflow_root.parents[1])) + for filename in sorted(isolated_checkout_workflows): workflow = load_yaml(workflow_root / filename) jobs = workflow.get("jobs", {}) or {} @@ -585,6 +618,65 @@ def _job_defaults_pin_the_shell() -> list[str]: return problems + +def _probe_security_evidence(root: Path) -> list[str]: + """Round-trip actual fallback bytes, and reject incomplete or unsafe inputs.""" + script = root / "scripts" / "security_evidence_to_log.sh" + names = ("private-security-actionlint.log", "private-security-zizmor.sarif", + "private-security-osv.sarif", "private-security-gitleaks.sarif") + problems: list[str] = [] + with tempfile.TemporaryDirectory(prefix="security-evidence-probe-") as directory: + target = Path(directory) + summary = target / "summary.md" + summary.touch() + expected = {name: (b"::error::literal diagnostic\n" if name.endswith(".log") + else b'{"version":"2.1.0","runs":[]}\n') for name in names} + env = clean_environment({"RUNNER_TEMP": str(target), "GITHUB_REPOSITORY": "example-org/example", + "GITHUB_SHA": "a" * 40, "GITHUB_RUN_ID": "123", "GITHUB_RUN_ATTEMPT": "1", + "GITHUB_STEP_SUMMARY": str(summary)}) + def run(): + return subprocess.run(["bash", str(script)], env=env, capture_output=True, + text=True, timeout=10, check=False) + for name, raw in expected.items(): + (target / name).write_bytes(raw) + try: + first, second = run(), run() + if first.returncode or second.returncode or first.stdout != second.stdout: + return ["security evidence: valid inputs must produce one deterministic archive"] + lines = first.stdout.splitlines() + digest = lines[0].split()[1] + end = lines.index("SECURITY_EVIDENCE_V1_END " + digest) + raw = base64.b64decode("".join(lines[1:end]), validate=True) + if hashlib.sha256(raw).hexdigest() != digest or len(raw) != int(lines[0].split()[2]): + problems.append("security evidence: archive digest or length mismatch") + with zipfile.ZipFile(io.BytesIO(raw)) as archive: + if set(archive.namelist()) != set(names) | {"SHA256SUMS", "identity.json"}: + problems.append("security evidence: archive lost or added a report") + for name, content in expected.items(): + if archive.read(name) != content: + problems.append("security evidence: report bytes changed") + for line in archive.read("SHA256SUMS").decode().splitlines(): + sha, name = line.split(" ") + if hashlib.sha256(archive.read(name)).hexdigest() != sha: + problems.append("security evidence: member digest mismatch") + if json.loads(archive.read("identity.json"))["run_id"] != "123": + problems.append("security evidence: run identity missing") + victim = target / names[1] + for case in ("missing", "symlink", "fifo", "oversized", "invalid-sarif"): + victim.unlink(missing_ok=True) + if case == "symlink": victim.symlink_to(target / names[2]) + elif case == "fifo": os.mkfifo(victim) + elif case == "oversized": + with victim.open("wb") as file: file.truncate(4 * 1024 * 1024 + 1) + elif case == "invalid-sarif": victim.write_text("{}") + failed = run() + if failed.returncode == 0 or "SECURITY_EVIDENCE_V1_BEGIN" in failed.stdout: + problems.append(f"security evidence: accepted {case} report") + except (OSError, ValueError, KeyError, subprocess.TimeoutExpired, zipfile.BadZipFile) as error: + problems.append(f"security evidence probe failed: {error}") + return problems + + def main() -> int: problems = check() if problems: diff --git a/scripts/security_evidence_to_log.sh b/scripts/security_evidence_to_log.sh new file mode 100755 index 0000000..a552bb2 --- /dev/null +++ b/scripts/security_evidence_to_log.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +# Preserve complete redacted scanner outputs when GitHub artifact storage is unavailable. +set -euo pipefail +exec python3 -I - "$@" <<'PY' +import base64 +import hashlib +import io +import json +import os +from pathlib import Path +import re +import stat +import sys +import zipfile + +LIMIT = 4 * 1024 * 1024 +NAMES = ( + "private-security-actionlint.log", "private-security-zizmor.sarif", + "private-security-osv.sarif", "private-security-gitleaks.sarif", +) + +def main(): + identity = { + "schema_version": 1, + "repository": os.environ["GITHUB_REPOSITORY"], + "source_commit": os.environ["GITHUB_SHA"], + "run_id": os.environ["GITHUB_RUN_ID"], + "run_attempt": os.environ["GITHUB_RUN_ATTEMPT"], + } + if not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", identity["repository"]): + raise ValueError("invalid repository identity") + if not re.fullmatch(r"[0-9a-f]{40}", identity["source_commit"]): + raise ValueError("invalid source commit") + if any(not re.fullmatch(r"[1-9][0-9]*", identity[key]) for key in ("run_id", "run_attempt")): + raise ValueError("invalid run identity") + root = Path(os.environ["RUNNER_TEMP"]) + if not root.is_absolute(): + raise ValueError("RUNNER_TEMP must be absolute") + files = {} + for name in NAMES: + fd = os.open(root / name, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK) + with os.fdopen(fd, "rb") as source: + before = os.fstat(source.fileno()) + if not stat.S_ISREG(before.st_mode) or before.st_size > LIMIT: + raise ValueError("evidence must be a bounded regular file: " + name) + raw = source.read(LIMIT + 1) + after = os.fstat(source.fileno()) + key = lambda s: (s.st_dev, s.st_ino, s.st_size, s.st_mtime_ns, s.st_ctime_ns) + if len(raw) > LIMIT or len(raw) != before.st_size or key(before) != key(after): + raise ValueError("evidence changed while captured: " + name) + if name.endswith(".sarif"): + document = json.loads(raw) + if document.get("version") != "2.1.0" or not isinstance(document.get("runs"), list): + raise ValueError("invalid SARIF: " + name) + files[name] = raw + files["identity.json"] = (json.dumps(identity, sort_keys=True, separators=(",", ":")) + "\n").encode() + files["SHA256SUMS"] = "".join( + hashlib.sha256(raw).hexdigest() + " " + name + "\n" + for name, raw in sorted(files.items()) + ).encode() + output = io.BytesIO() + with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=9) as archive: + for name, raw in sorted(files.items()): + info = zipfile.ZipInfo(name, date_time=(1980, 1, 1, 0, 0, 0)) + info.external_attr = (stat.S_IFREG | 0o600) << 16 + info.compress_type = zipfile.ZIP_DEFLATED + archive.writestr(info, raw) + raw = output.getvalue() + digest = hashlib.sha256(raw).hexdigest() + # Base64 cannot form a workflow command. Emit only after every input was + # captured and validated; no partial archive can be mistaken for evidence. + print("SECURITY_EVIDENCE_V1_BEGIN " + digest + " " + str(len(raw))) + print(base64.encodebytes(raw).decode(), end="") + print("SECURITY_EVIDENCE_V1_END " + digest) + print("::warning::Artifact upload failed; complete redacted security evidence is retained in this run log (SHA-256 " + digest + ").") + summary = os.environ.get("GITHUB_STEP_SUMMARY") + if summary: + fd = os.open(summary, os.O_WRONLY | os.O_APPEND | os.O_NOFOLLOW) + with os.fdopen(fd, "a") as target: + target.write("\n### Security evidence fallback\n\nArtifact storage was unavailable. The complete redacted ZIP is base64-encoded between `SECURITY_EVIDENCE_V1_BEGIN/END` in this step's log. SHA-256: `" + digest + "`. The ZIP includes all four reports, run identity, and `SHA256SUMS`. Scanner failures remain failures.\n") + +try: + main() +except (OSError, ValueError, KeyError) as error: + print("security evidence fallback failed: " + str(error), file=sys.stderr) + sys.exit(1) +PY