Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
from darnit.core.logging import get_logger
from darnit.sieve.handler_registry import HandlerContext, HandlerResult, HandlerResultStatus

from .witness_attestation import WitnessCheckResult, check_witness_attestation

logger = get_logger("darnit_reproducibility.handlers")


Expand Down Expand Up @@ -329,19 +331,73 @@ def _iter_container_files(path: Path) -> list[Path]:
return results[:_FILE_SCAN_LIMIT]


def _detect_strong_hermeticity_signal(path: Path, ci_files: list[Path]) -> str | None:
"""Return a description of a build-system-enforced hermeticity signal, or None.

# Bazel network sandbox flags — current name, negated shorthand, and the
# deprecated pre-rename name (still honored by Bazel as an alias).
_BAZEL_NETWORK_BLOCK_FLAGS: tuple[str, ...] = (
"--sandbox_default_allow_network=false",
"--nosandbox_default_allow_network",
"--experimental_sandbox_default_allow_network=false",
)


def _maybe_check_witness_attestation(
ctx: HandlerContext,
config: dict[str, Any],
) -> WitnessCheckResult:
"""Call check_witness_attestation() unless disabled via config.

``verify_witness_attestations = false`` in the TOML pass config opts out
of the network round-trip entirely — for air-gapped audits or
environments without a usable `gh` login for the audited repo. There is
deliberately no automatic "skip if CI text doesn't mention witness"
heuristic: that would reintroduce exactly the kind of unreliable text-only
guess this real verification replaced (e.g. a reusable/composite workflow
can produce a valid attestation without the calling repo's own CI files
ever spelling out "witness").
"""
if not config.get("verify_witness_attestations", True):
return WitnessCheckResult(attempted=False, detail="witness attestation verification disabled via config")

return check_witness_attestation(ctx)


def _detect_strong_hermeticity_signal(
path: Path,
ci_files: list[Path],
dependency_results: dict[str, Any],
ctx: HandlerContext,
config: dict[str, Any],
) -> tuple[str | None, WitnessCheckResult]:
"""Return (signal description, witness check result). Signal is None if no
build-system-enforced hermeticity guarantee was found.

Checks in priority order:
1. Witness runtime attestation — records what the build actually did at runtime
2. Nix flake used in CI — fixed-output derivations run network-isolated by default
1. Witness runtime attestation — a Sigstore-verified DSSE envelope from the
repo's latest CI run, bound to its GitHub Actions OIDC identity, asserting
no network access occurred during the build (see witness_attestation.py).
Merely mentioning "witness run" in CI text is NOT sufficient — that only
proves the tool ran, not what it observed, so text mentions alone are no
longer treated as a strong signal.
2. Nix flake used in CI — fixed-output derivations run network-isolated by default.
Gated on RE-01.02 (BuildEnvDeclared) having PASSED: a bare flake.nix that isn't
the project's confirmed, declared build environment isn't a strong signal on its
own. If RE-01.02 hasn't run (e.g. this control invoked standalone), the signal is
withheld rather than assumed — conservative-by-default.
3. Bazel with explicit network sandbox — Bazel allows network by default, so the
blocking flag must be present to count as a strong signal
blocking flag must be present to count as a strong signal. Checked in both CI
files (where "bazel" must also appear, to avoid matching an unrelated tool that
happens to share a flag name) and .bazelrc (the canonical place to set it, where
the file itself is the bazel signal).

Comments are stripped before matching (same as ``_scan_line``) so a
commented-out reference (e.g. ``# TODO: add witness run``) can't be
mistaken for the real thing.
"""
witness_result = _maybe_check_witness_attestation(ctx, config)
if witness_result.verified and witness_result.network_clean is True:
return f"Witness attestation verified — {witness_result.detail}", witness_result

ci_content: dict[str, str] = {}
for f in ci_files:
try:
Expand All @@ -350,35 +406,37 @@ def _detect_strong_hermeticity_signal(path: Path, ci_files: list[Path]) -> str |
continue
ci_content[f.name] = "\n".join(_strip_comment(line) for line in raw.splitlines())

witness_hits = sorted(
name
for name, content in ci_content.items()
if "witness run" in content or "testifysec/witness" in content or "in-toto/witness" in content
)
if witness_hits:
return f"Witness runtime attestation in CI ({', '.join(witness_hits)})"

if (path / "flake.nix").exists():
if (path / "flake.nix").exists() and dependency_results.get("RE-01.02") == "PASS":
nix_hits = sorted(
name
for name, content in ci_content.items()
if any(cmd in content for cmd in ("nix build", "nix develop", "nix run", "nix flake"))
)
if nix_hits:
return f"Nix flake build in CI ({', '.join(nix_hits)})"
return f"Nix flake build in CI ({', '.join(nix_hits)})", witness_result

has_bazel = any((path / f).exists() for f in ("WORKSPACE", "WORKSPACE.bazel", "MODULE.bazel", "BUILD.bazel"))
if has_bazel:
bazel_hits = sorted(
bazel_hits: list[str] = [
name
for name, content in ci_content.items()
if "bazel" in content.lower()
and ("--sandbox_default_allow_network=false" in content or "--sandbox_network=block" in content)
)
if "bazel" in content.lower() and any(flag in content for flag in _BAZEL_NETWORK_BLOCK_FLAGS)
]

bazelrc = path / ".bazelrc"
if bazelrc.exists():
try:
raw = bazelrc.read_text(encoding="utf-8")
except Exception:
raw = ""
bazelrc_content = "\n".join(_strip_comment(line) for line in raw.splitlines())
if any(flag in bazelrc_content for flag in _BAZEL_NETWORK_BLOCK_FLAGS):
bazel_hits.append(".bazelrc")

if bazel_hits:
return f"Bazel with network sandbox in CI ({', '.join(bazel_hits)})"
return f"Bazel with network sandbox ({', '.join(sorted(bazel_hits))})", witness_result

return None
return None, witness_result


def repro_hermetic_build_handler(
Expand All @@ -394,15 +452,30 @@ def repro_hermetic_build_handler(
inside Dockerfiles are DEFERRED — building the image environment is fine;
fetching application dependencies at build time is not.

v0.3: Adds a Sigstore-verified Witness/runtime-trace attestation check
(see witness_attestation.py) — fetches attestation artifacts from the
repo's latest successful CI run via the `gh` CLI, cryptographically
verifies them against the repo's GitHub Actions OIDC identity, and only
treats an empty, verified network log as a strong PASS signal. A verified
attestation that *does* record network activity is fed into the violation
list — stronger evidence than the CI-text grep below it. Requires the
`gh` CLI and `darnit-core[attestation]`; any missing prerequisite (no gh,
not authenticated, no matching CI run/artifact, sigstore not installed,
verification failure) degrades to a specific "no attestation evidence"
reason in ``evidence["strong_signal"]``/the witness result's ``detail``,
never to failing the audit. Set ``verify_witness_attestations = false`` in
the TOML pass config to skip the network round-trip entirely (air-gapped
audits, or repos with no usable `gh` login).

Result semantics (conservative-by-default):
- PASS: strong hermeticity signal (Witness, Nix flake CI, Bazel sandbox)
- FAIL: suspicious live network-fetch pattern in any scanned file
- PASS: strong hermeticity signal (verified Witness attestation,
Nix flake CI, Bazel sandbox)
- FAIL: suspicious live network-fetch pattern in any scanned file,
or a verified Witness attestation that recorded network activity
- INCONCLUSIVE: files scanned, no violations, no strong signal
(grep absence ≠ proof of hermeticity)
- INCONCLUSIVE
(confidence 0): no CI or build files found at all

Roadmap: https://github.com/kusari-oss/darnit/issues/227
"""
path = Path(ctx.local_path)

Expand All @@ -428,7 +501,9 @@ def repro_hermetic_build_handler(
},
)

strong_signal = _detect_strong_hermeticity_signal(path, all_ci_files)
strong_signal, witness_result = _detect_strong_hermeticity_signal(
path, all_ci_files, ctx.dependency_results, ctx, config
)
if strong_signal:
return HandlerResult(
status=HandlerResultStatus.PASS,
Expand All @@ -447,6 +522,12 @@ def repro_hermetic_build_handler(
deferred: list[str] = []
files_scanned: list[str] = []

# A verified Witness attestation that positively recorded network activity
# is stronger evidence than the grep heuristic below — surface it as a
# violation on its own rather than waiting for a matching CI-text pattern.
if witness_result.verified and witness_result.network_clean is False:
violations.append(f"witness attestation ({witness_result.evidence.get('artifact', '?')}): {witness_result.detail}")

for f in all_files:
is_dockerfile = f in container_file_set
try:
Expand Down
Loading
Loading