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
66 changes: 62 additions & 4 deletions .github/workflows/labs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,13 @@ jobs:
- 'scripts/test_openlabs_lab_setup.py'
- 'scripts/test_openlabs_lifecycle.py'
- 'scripts/test_openlabs_reset_safety.py'
- 'scripts/test_openlabs_verify.py'
- 'scripts/test_m2.py'
- 'scripts/test_run_m2_tier1_lifecycle.py'
- 'scripts/evidence_artifact.py'
- 'scripts/run_m2_tier1_lifecycle.py'
- 'scripts/diagnostic_registry.py'
- 'openlabs'
- 'contracts/cli-v1.md'
- 'contracts/**'
- 'scripts/test_validate*.py'
Expand All @@ -97,8 +104,12 @@ jobs:
- 'scripts/check.py'
- '.gitleaks.toml'
reference:
- 'labs/web/duck-cross/**'
- 'openlabs'
- 'scripts/openlabs_cli/**'
- 'scripts/run_m2_tier1_lifecycle.py'
- 'scripts/prove_reference_lab.py'
- 'scripts/diagnostic_registry.py'
- 'labs/web/duck-cross/**'
content:
- 'content/**'
- 'contentbit.config.ts'
Expand Down Expand Up @@ -227,22 +238,69 @@ jobs:
needs: detect-changes
if: needs.detect-changes.outputs.run_reference == 'true'
runs-on: ubuntu-latest
timeout-minutes: 12
timeout-minutes: 25
steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v7
with:
python-version: "3.12"

- name: Prove reference lab lifecycle
run: python3 scripts/prove_reference_lab.py --json reference-lab-evidence.json
- name: Docker Compose sanity
run: docker compose version

- name: Prove reference lab lifecycle (legacy)
id: prove_legacy
continue-on-error: true
run: python3 scripts/prove_reference_lab.py --json reference-lab-evidence-raw.json

- name: Bound legacy reference evidence
if: always()
run: |
if [ ! -f reference-lab-evidence-raw.json ]; then
echo '{"lab":"duck-cross","steps":[],"detail":"legacy prove produced no raw evidence"}' > reference-lab-evidence-raw.json
fi
python3 scripts/evidence_artifact.py bound reference-lab-evidence-raw.json reference-lab-evidence.json

- name: Tier 1 openlabs lifecycle
id: tier1_lifecycle
if: always()
run: python3 scripts/run_m2_tier1_lifecycle.py --json m2-tier1-lifecycle-evidence.json

- name: Scan lifecycle artifacts for plaintext flags
if: always()
run: |
python3 scripts/evidence_artifact.py scan-secrets reference-lab-evidence.json
python3 scripts/evidence_artifact.py scan-secrets m2-tier1-lifecycle-evidence.json

- name: Aggregate Tier 1 lifecycle results
if: always()
run: |
set -euo pipefail
fail=0
if [ "${{ steps.prove_legacy.outcome }}" != "success" ]; then
echo "legacy prove_reference_lab failed" >&2
fail=1
fi
if [ "${{ steps.tier1_lifecycle.outcome }}" != "success" ]; then
echo "openlabs tier1 lifecycle failed" >&2
fail=1
fi
exit "$fail"

- uses: actions/upload-artifact@v4
if: always()
with:
name: reference-lab-evidence
path: reference-lab-evidence.json
if-no-files-found: error

- uses: actions/upload-artifact@v4
if: always()
with:
name: m2-tier1-lifecycle-evidence
path: m2-tier1-lifecycle-evidence.json
if-no-files-found: error

security:
name: Security scan
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,5 @@ tsconfig.tsbuildinfo
# GitHub API cache for the lab sync. Regenerates automatically.
.cache/
.openlabs/
.tmp-test-artifacts/
/.codegraph/
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ verifies a player's flag against the stored SHA-256 hash.

```bash
python3 scripts/validate.py # structure + metadata
python3 scripts/test_m2.py # non-Docker M2 CLI and lifecycle gates
python3 scripts/test_contract.py # contract conformance gate (M1-08)
python3 scripts/test_openlabs_cli.py # openlabs CLI core (M2-02)
python3 scripts/test_openlabs_issue.py # issue explain and bundle (M2-03)
Expand Down
5 changes: 4 additions & 1 deletion scripts/baseline_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@ def local_ci_commands() -> list[dict]:
"ci_job": "Validate labs",
"commands": [
"python3 scripts/validate.py",
"python3 scripts/test_m2.py",
"python3 scripts/test_contract.py",
"python3 scripts/test_validate_status.py",
"python3 scripts/test_status_aware_inventory.py",
Expand All @@ -179,7 +180,9 @@ def local_ci_commands() -> list[dict]:
{
"ci_job": "Prove duck-cross L0-L6",
"commands": [
"python3 scripts/prove_reference_lab.py --json reference-lab-evidence.json"
"python3 scripts/prove_reference_lab.py --json reference-lab-evidence-raw.json",
"python3 scripts/evidence_artifact.py bound reference-lab-evidence-raw.json reference-lab-evidence.json",
"python3 scripts/run_m2_tier1_lifecycle.py --json m2-tier1-lifecycle-evidence.json",
],
},
{
Expand Down
130 changes: 130 additions & 0 deletions scripts/evidence_artifact.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
#!/usr/bin/env python3
"""Bounded, redacted JSON evidence for CI lifecycle artifacts."""

from __future__ import annotations

import argparse
import json
import re
import sys
from pathlib import Path
from typing import Any

from diagnostic_registry import redact_mapping

DEFAULT_MAX_BYTES = 256 * 1024
FLAG_PLAINTEXT_RE = re.compile(r"duck\{[a-z0-9_]{16,40}\}")
EVIDENCE_VERSION = "openlabs.evidence.v1"


def prepare_evidence_document(data: dict[str, Any]) -> dict[str, Any]:
prepared = redact_mapping(dict(data))
if isinstance(prepared, dict):
meta = prepared.get("meta")
if not isinstance(meta, dict):
meta = {}
prepared["meta"] = meta
meta["redacted"] = True
return prepared # type: ignore[return-value]


def _shrink_for_limit(data: dict[str, Any], *, max_bytes: int) -> tuple[dict[str, Any], int, bool]:
current = prepare_evidence_document(data)
encoded = (json.dumps(current, indent=2) + "\n").encode("utf-8")
original_bytes = len(encoded)
if original_bytes <= max_bytes:
return current, original_bytes, False

working = dict(current)
steps = working.get("steps")
if isinstance(steps, list):
while len(steps) > 1:
steps.pop()
working["truncated"] = True
working["original_bytes"] = original_bytes
encoded = (json.dumps(working, indent=2) + "\n").encode("utf-8")
if len(encoded) <= max_bytes:
working["written_bytes"] = len(encoded)
return working, original_bytes, True
working["steps"] = steps[:1] if steps else []

working.pop("raw_stdout", None)
working.pop("raw_stderr", None)
working["truncated"] = True
working["original_bytes"] = original_bytes
encoded = (json.dumps(working, indent=2) + "\n").encode("utf-8")
if len(encoded) > max_bytes:
minimal = {
"version": working.get("version", EVIDENCE_VERSION),
"truncated": True,
"original_bytes": original_bytes,
"written_bytes": max_bytes,
"detail": "evidence exceeded size limit after truncation",
"meta": {"redacted": True},
}
encoded = (json.dumps(minimal, indent=2) + "\n").encode("utf-8")
if len(encoded) > max_bytes:
encoded = encoded[: max_bytes - 1]
if not encoded.endswith(b"\n"):
encoded += b"\n"
working = json.loads(encoded.decode("utf-8", errors="replace"))
if not isinstance(working, dict):
working = minimal
else:
working["written_bytes"] = len(encoded)
return working, original_bytes, True


def write_bounded_json(path: Path, data: dict[str, Any], *, max_bytes: int = DEFAULT_MAX_BYTES) -> None:
bounded, _original, _truncated = _shrink_for_limit(data, max_bytes=max_bytes)
text = json.dumps(bounded, indent=2) + "\n"
path.write_text(text, encoding="utf-8")


def assert_no_plaintext_secrets(path: Path) -> None:
text = path.read_text(encoding="utf-8")
matches = FLAG_PLAINTEXT_RE.findall(text)
if matches:
print(
f"evidence artifact {path.name}: found {len(matches)} plaintext flag pattern(s)",
file=sys.stderr,
)
raise SystemExit(1)


def bound_file(source: Path, dest: Path, *, max_bytes: int = DEFAULT_MAX_BYTES) -> None:
data = json.loads(source.read_text(encoding="utf-8"))
if not isinstance(data, dict):
raise ValueError("evidence source must be a JSON object")
wrapped = {
"version": EVIDENCE_VERSION,
"source": source.name,
**data,
}
write_bounded_json(dest, wrapped, max_bytes=max_bytes)


def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
sub = parser.add_subparsers(dest="command", required=True)

bound = sub.add_parser("bound", help="read raw JSON, write bounded redacted copy")
bound.add_argument("source", type=Path)
bound.add_argument("dest", type=Path)
bound.add_argument("--max-bytes", type=int, default=DEFAULT_MAX_BYTES)

scan = sub.add_parser("scan-secrets", help="fail if plaintext flags appear in file")
scan.add_argument("path", type=Path)

args = parser.parse_args()
if args.command == "bound":
bound_file(args.source, args.dest, max_bytes=args.max_bytes)
return 0
if args.command == "scan-secrets":
assert_no_plaintext_secrets(args.path)
return 0
return 2


if __name__ == "__main__":
sys.exit(main())
45 changes: 45 additions & 0 deletions scripts/fixtures/ci_routing_matrix.json
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,51 @@
"run_pdf": false,
"run_reference": false
},
{
"change": "openlabs wrapper only",
"changed_files": ["openlabs"],
"run_validate": true,
"run_security": false,
"run_content": false,
"run_pdf": false,
"run_reference": true
},
{
"change": "scripts/openlabs_cli/lab_verify.py only",
"changed_files": ["scripts/openlabs_cli/lab_verify.py"],
"run_validate": true,
"run_security": false,
"run_content": false,
"run_pdf": false,
"run_reference": true
},
{
"change": "scripts/diagnostic_registry.py only",
"changed_files": ["scripts/diagnostic_registry.py"],
"run_validate": true,
"run_security": false,
"run_content": false,
"run_pdf": false,
"run_reference": true
},
{
"change": "scripts/test_m2.py only",
"changed_files": ["scripts/test_m2.py"],
"run_validate": true,
"run_security": false,
"run_content": false,
"run_pdf": false,
"run_reference": false
},
{
"change": "scripts/run_m2_tier1_lifecycle.py only",
"changed_files": ["scripts/run_m2_tier1_lifecycle.py"],
"run_validate": true,
"run_security": false,
"run_content": false,
"run_pdf": false,
"run_reference": true
},
{
"change": "wiki/Home.md only",
"changed_files": ["wiki/Home.md"],
Expand Down
Loading
Loading