Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/workflows/nddev-security-bundle.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down
9 changes: 9 additions & 0 deletions .github/workflows/private-security-bundle-free.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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"
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion catalog/capabilities.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
18 changes: 12 additions & 6 deletions catalog/product-facts.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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.'
Expand Down
21 changes: 14 additions & 7 deletions catalog/python-execution.yml
Original file line number Diff line number Diff line change
Expand Up @@ -524,7 +524,8 @@
],
"check_workflow_contracts.py": [
"_runners",
"_workflow_yaml"
"_workflow_yaml",
"check_python_execution_contract"
],
"compile_evidence_plan.py": [
"_strict_yaml",
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
25 changes: 25 additions & 0 deletions docs/06-security-scanning.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <sha256> <bytes>` and
`SECURITY_EVIDENCE_V1_END <sha256>`. 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).
4 changes: 2 additions & 2 deletions docs/generated/free-tier-matrix.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
2 changes: 2 additions & 0 deletions examples/private-free/security-selfhosted.yml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 2 additions & 0 deletions examples/private-paid-ghas/security-selfhosted.yml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
92 changes: 92 additions & 0 deletions scripts/check_workflow_contracts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 {}
Expand Down Expand Up @@ -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:
Expand Down
Loading
Loading