GitHub Action Internals
This page is for engineers who need to understand what zenzic-action does under the hood — security reviewers, platform teams integrating Zenzic into shared infrastructure, and contributors to the action itself.
For day-to-day usage (copy-paste YAML, input reference), see the CI/CD Integration guide and the action README.
Architecture Overview
zenzic-action is a composite GitHub Action built on a strict two-layer architecture:
action.yml ← public contract (inputs, outputs, env injection)
│
└─▶ zenzic-action-wrapper.sh ← enforcement layer (security, exit codes, SARIF)
│
└─▶ uvx zenzic check all ← Zenzic Core (analysis engine)
action.yml injects caller-supplied values as environment variables. The wrapper validates, sanitises, and orchestrates the execution. It never trusts raw inputs — every path is guarded before it reaches the filesystem or the CLI.
Blood Sentinel Protocol
The wrapper enforces two independent Jailbreak Guards — one for the SARIF output path, one for the configuration file path. Both use the same case-based pattern, ensuring identical policy at every read/write boundary.
SARIF Jailbreak Guard
sarif-file is a write path. A malicious workflow could attempt to write outside the checkout directory:
# Rejected: absolute path
sarif-file: /tmp/evil.sarif
# Rejected: path traversal
sarif-file: ../../etc/evil.sarif
The wrapper rejects both patterns before any file I/O occurs:
case "${ZENZIC_SARIF_FILE}" in
/*)
echo "::error title=Zenzic — SARIF Jailbreak::..." >&2; exit 1 ;;
*../*|*/..|..)
echo "::error title=Zenzic — SARIF Jailbreak::..." >&2; exit 1 ;;
esac
Config Jailbreak Guard
config-file is a read path. An attacker attempting to read /etc/passwd or a file outside the workspace via path traversal is blocked by the same pattern:
case "${ZENZIC_CONFIG_FILE}" in
/*) exit 1 ;;
*../* | */..) exit 1 ;;
esac
The Config Jailbreak Guard applies only to explicit overrides — values supplied via the config-file input. Auto-discovered paths (zenzic.toml, .github/zenzic.toml) are hardcoded in the wrapper source and cannot be injected by an attacker. Guarding them would be security theatre, not security.
SARIF Integrity Check
A SIGKILL or Python runtime crash during Zenzic's execution can truncate the SARIF file mid-write. An incomplete SARIF produces a cryptic GitHub API error during upload rather than a meaningful message in the step log.
The wrapper validates the SARIF as JSON before handing it to codeql-action/upload-sarif:
import json, os
json.load(open(os.environ["ZENZIC_SARIF_FILE"]))
If the file is not valid JSON, a ::warning annotation is emitted — the upload proceeds so GitHub surfaces its own precise error — and findings-count is left at 0 to avoid false positives.
Exit Code Contract
Zenzic defines four exit codes. The wrapper propagates them without remapping:
| Code | Meaning | Suppressible? |
|---|---|---|
0 | Clean — all checks passed | — |
1 | Documentation findings (broken links, orphans, dead refs, etc.) | ✅ via fail-on-error: false |
2 | SECURITY — credential pattern detected (Shield / Z201) | ❌ Never |
3 | SECURITY — system path traversal (Blood Sentinel / Z202–Z203) | ❌ Never |
Exits 2 and 3 terminate the job unconditionally. Neither fail-on-error: "false" nor any other input can suppress them. This is enforced in the wrapper's exit logic, not in action.yml, so it cannot be circumvented by overriding action inputs.
Coherent findings-count for security exits
When a security breach is detected, Zenzic may abort before producing a complete SARIF file. In this case the SARIF contains zero results, even though a real incident occurred.
The wrapper handles this by forcing findings-count to 1 when EXIT_CODE is 2 or 3 and the parsed count is 0:
if [ "${EXIT_CODE}" -eq 2 ]; then
[ "${FINDINGS}" -eq 0 ] && FINDINGS=1
echo "findings-count=${FINDINGS}" >> "${GITHUB_OUTPUT}"
exit 2
fi
This ensures downstream steps that read findings-count never see "0 findings, exit 2" — an incoherent UX that would imply the build failed for no reason.
Root-First Sentinel — Configuration Discovery
The wrapper implements a hierarchical auto-discovery for the Zenzic configuration file. The search order reflects the conventional placement in real-world repositories:
Priority 1 → Explicit override (config-file input is set)
Priority 2 → zenzic.toml (repository root)
Priority 3 → .github/zenzic.toml (hidden config directory)
Priority — → (no file found) → Zenzic uses built-in defaults
This order guarantees parity between local runs and CI: a developer who runs zenzic check all locally picks up zenzic.toml from the root, and so does the action in CI.
The discovered path is passed to the CLI via --config using a Bash array — never a string — so paths containing spaces are handled correctly:
CONFIG_ARGS=(--config "${CANDIDATE_CONFIG}")
# ...
uvx "${PKG}" check all --format sarif "${CONFIG_ARGS[@]}" ...
Sovereign Intent Contract
When a caller explicitly sets config-file, they are expressing sovereign intent — a deliberate declaration that this specific file governs the run. If the file does not exist, the wrapper does not silently fall through to auto-discovery. Silent fallthrough would be operational deception: the developer believes they are testing with custom rules, but the system is secretly using a different configuration.
The response depends on strict mode:
strict | File specified | File exists | Outcome |
|---|---|---|---|
| any | no | — | Auto-discovery runs normally |
| any | yes | yes | --config <file> passed to CLI |
false | yes | no | ::warning emitted; Zenzic uses built-in defaults |
true | yes | no | ::error + exit 1 (fatal) |
When the warning path is taken, auto-discovery is suppressed — CONFIG_ARGS remains empty, and the run continues without any configuration file. This is intentionally more conservative than falling back to a discovered file, because the caller has declared a specific intent that cannot be honoured.
Glob-Safe Argument Passing
The ZENZIC_EXTRA_ARGS environment variable allows callers to pass additional flags (e.g. --exclude-url) to the Zenzic CLI at runtime. Because this variable is a plain string that must be word-split into argv tokens, unprotected expansion would trigger Bash glob expansion — a * or ? inside a URL could be expanded against the CI filesystem.
The wrapper disables globbing around the array construction:
set -f # disable glob expansion
EXTRA_ARGS=(${ZENZIC_EXTRA_ARGS:-}) # intentional IFS word-split
set +f # restore glob expansion
set -f / set +f is scoped to exactly this one assignment so nothing else in the wrapper is affected. The subsequent expansion uses "${EXTRA_ARGS[@]}" — quoted, so no further splitting or globbing occurs when the array is passed to uvx.
Related Resources
| Resource | Description |
|---|---|
| action README | Quick Start, inputs/outputs reference, Sovereign Override usage |
| CI/CD Integration | Workflow recipes, SARIF badge, score badge |
| Architecture | Zenzic Core two-pass pipeline, Shield middleware, adapter protocol |
| ADR Vault | Architectural decisions behind the exit code contract and Blood Sentinel |