CI Security Gates¶
Status: ACTIVE — the security job in .github/workflows/ci.yml runs five
gate categories on every push and every pull_request. Every gate fails the
build on a finding: none of them is advisory, none is continue-on-error, and
there is no || true anywhere in the job. The only suppressions are the
reviewed, value-pinned allowlists in §6; §7 lists exactly what is not
covered, including the checks that cannot fail the build.
The pre-existing test job (pytest -q, ruff check ., mypy src/jdlib) is
unchanged by design — the security gates are additive and run in parallel.
1. Overview¶
| # | Gate | Tool and pinned version | Fails the build? | Verified locally |
|---|---|---|---|---|
| 1 | Dependency vulnerabilities | pip-audit 2.10.1 |
Yes, on any advisory for any installed dependency | Yes (incl. negative test) |
| 2 | Secrets (full git history) | gitleaks v8.30.1, official container zricethezav/gitleaks |
Yes, on any leak not covered by a reviewed allowlist entry | Yes (incl. negative test) |
| 3 | SAST | bandit 1.9.4, medium+ severity |
Yes, on any MEDIUM or HIGH finding | Yes (incl. negative test) |
| 4 | SBOM | cyclonedx-bom 7.4.0 (CycloneDX JSON 1.6) |
Only if generation fails; the BOM content is not evaluated (see §7) | Yes |
| 5 | Build + install smoke test | build 1.6.1 |
Yes, on build failure, install failure or import failure | Yes |
Job-level hardening (.github/workflows/ci.yml):
permissions: contents: readat workflow level — the job never needs a write-scopedGITHUB_TOKEN.timeout-minutes: 30on thesecurityjob.- Scanner tooling is installed into a throwaway venv under
$RUNNER_TEMP(Python tools) or run as a pinned container (gitleaks). No scanner is added topyproject.toml: the project's runtime and dev dependencies are untouched by this workstream. - No paid or hosted service is used: Docker Hub (public image), PyPI (public packages), and the GitHub Actions runner image only.
- Evidence (redacted gitleaks report, CycloneDX SBOM, sdist + wheel) is
uploaded as the
security-artifactsbuild artifact withif: always(), so a failing job still leaves the scan output behind. Retention: 14 days.
1.1 Run recorded on the phase 10 branch (re-verified end to end)¶
Every gate was executed locally before this was committed, with the tools at the pinned versions above:
pip-audit 2.10.1 No known vulnerabilities found (57 packages, --no-deps, frozen set)
gitleaks v8.30.1 no leaks found (full history; 157 commits at that run)
bandit 1.9.4 exit 0, 0 MEDIUM / 0 HIGH (19 LOW, 1 reviewed #nosec B608)
cyclonedx-bom 7.4.0 CycloneDX 1.6, root jdlib 0.1.0 (58 components, no credential-shaped keys)
build 1.6.1 jdlib-0.1.0.tar.gz + jdlib-0.1.0-py3-none-any.whl
smoke fresh venv + wheel only: import jdlib, jdlib.security,
jdlib.security.compliance; 19 controls registered
Two findings of the scanner against this repository were synthetic test
fixtures written in phase 9 (a fake GitHub token and a secret = "…"
assignment) and are recorded as historical allowlist entries in §6. One bandit
finding is a reviewed exception: the interpolated schema identifier in
MigrationRunner.tenant_version is validated by an allowlist regex before the
query is built, which is why the file carries the repository's only # nosec
annotation — with the reasoning inline and the reviewer's note here.
2. Gate 1 — Dependency vulnerabilities (pip-audit)¶
Covers. Every package installed for the project — runtime dependencies and
the dev extra (pytest, testcontainers, ruff, mypy, typer, …) including
transitive dependencies — matched against the GitHub Advisory / PyPI / OSV
advisory data for the exact pinned versions.
Tool and version. pip-audit==2.10.1 (installed into the scanner venv).
The audited set is produced by the job itself:
# in the job, after: python -m pip install -e ".[dev]"
python -m pip freeze --exclude-editable > "$RUNNER_TEMP/requirements-lock.txt" # 57 pins today
Command (gate, verbatim from the workflow).
"$RUNNER_TEMP/scanners/bin/python" -m pip_audit \
--requirement "$RUNNER_TEMP/requirements-lock.txt" \
--no-deps \
--desc on \
--progress-spinner off
--no-deps audits exactly the frozen set instead of re-resolving it, so the
gate cannot drift from what the tests actually run against. pip-audit exits
1 when any advisory is found; the step therefore fails the job.
Local verification.
$ .venv/Scripts/python.exe -m pip freeze --exclude-editable > requirements-lock.txt # 57 entries
$ python -m pip_audit -r requirements-lock.txt --no-deps --desc on --progress-spinner off
No known vulnerabilities found # exit 0
Negative test (proves the gate is not a no-op) — a deliberately vulnerable pin set in the scratch dir:
$ printf 'requests==2.19.0\npyyaml==5.3.1\n' > vuln-probe.txt
$ python -m pip_audit -r vuln-probe.txt --no-deps --desc on --progress-spinner off
... PYSEC-2026-1994 ... PYSEC-2026-141 ... (advisories listed) # exit 1
Documented limitation. The repository has no lock file (no uv.lock,
requirements*.txt, poetry.lock), and the lock built in CI is a version
freeze without hashes — pip-audit itself warns about this. Version pins
still make the audit deterministic, but a hash-pinned lock (e.g. pip-compile
with --generate-hashes) is the stronger follow-up. This limitation does not
weaken the gate; it is recorded so it is a known, measured gap rather than an
assumption.
3. Gate 2 — Secret scanning (gitleaks)¶
Covers. Every commit in the repository's history (not just the working
tree): all 244 commits / ~4.14 MB at the phase-17 MCP work (155 commits / ~2.97 MB when this section was first written). History scanning is deliberate — a
secret that was committed and later deleted is still exposed and still fails
the gate. The checkout uses fetch-depth: 0 so the full history is present.
Tool and version. zricethezav/gitleaks:v8.30.1 (official image, pinned
tag; sha256:c00b6bd0aeb3071cbcb79009cb16a60dd9e0a7c60e2be9ab65d25e6bc8abbb7f
at the time of writing). Running the container sidesteps binary installs and
version skew on the runner.
Command (gate, verbatim from the workflow).
docker run --rm \
-e GIT_CONFIG_COUNT=1 -e GIT_CONFIG_KEY_0=safe.directory -e GIT_CONFIG_VALUE_0=/repo \
-v "${{ github.workspace }}:/repo" \
zricethezav/gitleaks:v8.30.1 detect \
--source=/repo --config=/repo/.gitleaks.toml --no-banner --redact --exit-code 1 \
--report-format json --report-path=/repo/security-artifacts/gitleaks-report.json
--exit-code 1 fails the job on any leak. --redact means a finding never
prints secret material into the job log or the uploaded artifact — the
report records rule, file, line and commit only.
Local verification.
$ docker run ... zricethezav/gitleaks:v8.30.1 detect --source=/repo --config=/repo/.gitleaks.toml --no-banner --redact --exit-code 1
INF 155 commits scanned.
INF scanned ~2965511 bytes (2.97 MB)
INF no leaks found # exit 0
Re-measured at the phase-17 MCP work, after the entry below was added:
Negative test in both directions, using a scratch probe repository that
contains (a) the reviewed fixture from tests/unit/security/test_telemetry.py
and (b) a new, unreviewed GitHub-PAT-shaped value:
Finding: token = "REDACTED # (a) suppressed by the allowlist
Secret: REDACTED # (b) reported, redacted
RuleID: github-pat
File: tests/unit/security/test_probe_unreviewed.py
INF leaks found: 1 # exit 1
This proves the allowlist entries are value- and path-pinned: they do not disable a rule, a directory or a file, and a new secret anywhere — including inside an allowlisted file — still fails the gate.
Documented exceptions: 13 allowlist entries, counted from .gitleaks.toml
at the phase-17 MCP work (the section previously said 10, which was already
stale — two phase-9 historical fixtures had been added without updating it), each
pinned by exact path and exact value with condition = "AND". The newest
(phase 17) is a false positive rather than a credential-shaped fixture — a two-argument fixture call
(authorization="Bearer …", x_correlation_id="…") whose captured secret was the
tail of the call, x_correlation_id=, which generic-api-key read as a value.
The fixture itself was rewritten in the working tree to remove the
credential-adjacent word; the entry pins the historical value, so the finding is
allowlisted rather than the rule relaxed — a new value in that file, or that
value anywhere else, is still reported.
Rejected alternative: scanning the working tree (gitleaks dir). Measured
locally: 35 findings, of which 25 come from .venv/, __pycache__/ and
tests/infra/out/ (local, gitignored artefacts, including real local infra
credentials that must never be printed). History scanning of the checkout is
both quieter and stricter, since CI only ever contains committed content.
4. Gate 3 — Static analysis (bandit)¶
Covers. Security anti-patterns in the shipped source tree, src/jdlib
(12,249 lines of code): hardcoded SQL, subprocess misuse, weak crypto,
eval/exec, unsafe deserialisation, hardcoded credential strings, assert
usage, and the rest of bandit's plugin set.
Tool and version. bandit==1.9.4, medium-or-higher threshold:
Bandit exits 1 when any reported issue is at or above the threshold, so the
step fails the job.
Local verification.
$ python -m bandit -r src/jdlib -ll -f txt
Total potential issues skipped due to specifically being disabled (e.g., #nosec BXXX): 1
Total issues (by severity): Low: 19, Medium: 0, High: 0 # exit 0
Negative test — a scratch file with string-formatted SQL:
>> Issue: [B608:hardcoded_sql_expressions] Possible SQL injection vector through string-based query construction.
Severity: Medium Confidence: Low
Location: probe.py:2:11 # exit 1
Documented exception. One inline # nosec B608 in
src/jdlib/migrations/runner.py:121, with a comment naming this document. The
interpolated value is a schema identifier that is allowlist-validated by
_validate_tenant_schema (t_[a-z0-9][a-z0-9_]{0,50}, fullmatch) or
_validate_deployment_schema ([a-z_][a-z0-9_]{0,62}, fullmatch) on both
call paths that reach _read_version, so no attacker-controlled text can reach
the f-string; the adjacent to_regclass probe on the same values is already
parameter-bound. The exception is narrow (that one line, that one check, still
counted as "skipped" in the bandit metrics above) and re-reviewed whenever
runner.py changes; B608 stays enabled everywhere else.
Explicitly not gated: 19 LOW findings. -ll reports only MEDIUM and
HIGH, so the existing LOW backlog neither fails the build nor appears in the
default output. It is:
- 10 × B105
hardcoded_password_string— matched constant/enum values that contain the word "secret"/"password" but are not credentials:ClientAuthMethod.CLIENT_SECRET_POST/CLIENT_SECRET_BASIC(security/authn/provider.py:56-57), audit/authn error-code constants (security/audit/events.py:44-46,security/errors.py:30-31,security/gateway/config.py:29,security/gateway/adapters.py:196,security/compliance/posture.py:84). - 9 × B101
assert_used— assertions used as internal invariants, including in the deliberately-importable testing helpers (src/jdlib/testing/assertions.py). Addressable by policy (asserts are stripped underpython -O) but not by a scanner; it needs a code-owner decision, so it is recorded here rather than silenced.
To see the backlog locally: bandit -r src/jdlib -l -f txt. If a LOW-severity
finding is ever judged worth gating, either widen the threshold to -l or add
targeted skips — both would be recorded here first.
5. Gate 4 — SBOM (CycloneDX) and Gate 5 — build + install smoke test¶
5.1 SBOM (CycloneDX JSON, cyclonedx-bom 7.4.0)¶
Covers. The full installed dependency set of the project interpreter — runtime and dev, including transitive packages — as a CycloneDX 1.6 JSON BOM, with the project itself as the root component.
Command (gate, verbatim from the workflow).
"$RUNNER_TEMP/scanners/bin/python" -m cyclonedx_py environment \
"$(python -c 'import sys; print(sys.executable)')" \
--pyproject pyproject.toml --mc-type library \
--output-format JSON --output-file security-artifacts/sbom.cdx.json
The interpreter argument is what makes this correct: the BOM describes the project's environment, not the scanner venv. The file is uploaded as a build artifact.
Fails the build? Only for generation failures (non-zero exit). The BOM is evidence; nothing in this job evaluates its contents. Stated plainly rather than implied: an SBOM does not itself fail the build for anything. Policy evaluation against the BOM (banned licences, components without versions) is not implemented — see §7.
No secrets. Verified: the BOM contains package metadata only (names, versions, PURLs, licences) and a scan of the file for credential-like patterns matched nothing but PyJWT's description text ("JSON Web Token implementation").
Local verification.
$ python -m cyclonedx_py environment /path/to/project/python --pyproject pyproject.toml --mc-type library --output-format JSON --output-file sbom.cdx.json
# -> CycloneDX 1.6 | root component: jdlib 0.1.0 (library) | 58 components | 77 KB
5.2 Build + install smoke test (build 1.6.1)¶
Covers. That the project still builds both distributions from source and
that the wheel is installable and importable in a clean environment with only
the declared runtime dependencies — the fastest way to catch packaging
regressions (missing package data, a module that imports a dev-only dependency
at import time, broken pyproject.toml metadata).
Commands (gate, verbatim from the workflow).
"$RUNNER_TEMP/scanners/bin/python" -m build --outdir security-artifacts/dist
python -m venv "$RUNNER_TEMP/smoke"
"$RUNNER_TEMP/smoke/bin/python" -m pip install security-artifacts/dist/*.whl
"$RUNNER_TEMP/smoke/bin/python" -c "import jdlib, jdlib.security; print('smoke test ok:', jdlib.__file__)"
Any failure in build, install or import fails the job.
Local verification.
$ python -m build --outdir dist
Successfully built jdlib-0.1.0.tar.gz and jdlib-0.1.0-py3-none-any.whl # exit 0
# sdist: 294 entries (includes pyproject.toml) | wheel: 108 files
# (includes jdlib/__init__.py and jdlib/security/__init__.py)
$ python -m venv smoke && smoke/bin/python -m pip install dist/jdlib-0.1.0-py3-none-any.whl
$ smoke/bin/python -c "import jdlib, jdlib.security; ..."
SMOKE_IMPORT_OK: jdlib / jdlib.security # exit 0
# import resolved from ...\smoke\Lib\site-packages\jdlib\..., i.e. the installed copy
Not covered, stated explicitly: only the wheel is installed; the sdist is built and uploaded but not installed in the smoke venv. Adding an sdist install is a one-line change if it is ever wanted.
6. Reviewed exceptions (complete list)¶
6.1 Secret-scanning allowlist — .gitleaks.toml (10 entries). Every entry
carries condition = "AND" with an anchored paths regex and an anchored
regexTarget = "secret" regex, so it can only suppress one exact value in one
exact file. All ten were reviewed and are synthetic, not credentials:
| File (finding location) | Rule | What the matched text actually is |
|---|---|---|
tests/unit/security/test_telemetry.py:33 |
jwt |
Elided dummy JWT placeholder (the fixture value is literally abbreviated with an ellipsis) used to test log/telemetry redaction |
tests/unit/security/test_telemetry.py:93 |
generic-api-key |
A fake API-key assignment built from obviously repeating digits — redaction fixture |
tests/unit/security/test_error_responses.py:194 |
generic-api-key |
One of a dict of fake credentials (a sandbox-style API-key placeholder) asserting error responses are redacted |
tests/unit/security/test_error_responses.py:202 |
jwt |
Multi-line synthetic JWT built from placeholder parts and the public jwt.io demo signature |
tests/unit/security/test_gateway_adapters.py:131 |
generic-api-key |
False positive: the matched text is the keyword subject_header= in subject_header="X-JDLIB-Subject" — no credential at all |
tests/unit/security/test_jwt_validator.py:3 |
generic-api-key |
False positive: the matched text is docstring text listing JWT claims (iss/aud/sub/exp/...) |
tests/unit/security/test_security_context.py:163 |
generic-api-key |
Dummy JWT in a test asserting TokenMetadata rejects raw credentials |
tests/unit/security/test_security_interfaces.py:84 |
generic-api-key |
Dummy outbound-token placeholder |
tests/unit/security/test_audit_events.py:239 |
generic-api-key |
A placeholder value of repeating hex digits — metadata-safety fixture |
tests/integration/test_security_audit_persistence.py:220 |
generic-api-key |
The same placeholder value in a DB-backed test |
tests/unit/security/test_compliance_posture.py (phase 9 commit) |
github-pat |
Historical: a synthetic token in a test asserting a posture report carries no secret-shaped text. The value was replaced in the working tree by a DSN-shaped fixture (which the redaction path also covers and which the scanner does not flag); the entry remains because the commit is in history and history is scanned. Pinned by path and exact value |
tests/unit/test_cli_security.py (phase 9 commit) |
generic-api-key |
Historical: secret = "…" in a CLI test proving a policy body never reaches the output. Now a marker variable, same reason as above |
The exact matched values are, by construction, recorded only in .gitleaks.toml
(that is what a value-pinned allowlist is); this table describes them so a
reviewer can check the pin without re-deriving it from the scanner.
Procedure for a new entry: confirm the value is synthetic, add an entry pinned
to the exact path and exact value (never a rule skip, never a directory
allowlist), re-run gitleaks detect locally, and extend the table above with
the justification. A finding that is not a synthetic fixture must never be
allowlisted: it is rotated and, if it is in history, the history is dealt with
by the code owners.
6.2 SAST exception — one inline # nosec B608. See §4. It is visible in
bandit's own metrics (#nosec skipped: 1) rather than hidden in a config skip
list.
6.3 Gates that cannot fail the build. Only one, and it is a category statement rather than a suppressed failure: the SBOM step fails only if generation fails — the BOM's contents are not evaluated. The 19 LOW-severity bandit findings are reported but not gated (§4). Everything else in the job fails on findings.
7. What is deliberately not covered¶
- LOW-severity bandit findings — reported, not gated (§4).
- SBOM policy evaluation — the BOM is produced and uploaded, but nothing fails on its contents; there is no licence-policy or component-completeness check, and no BOM signing/attestation.
- Non-Python static analysis — no linter/SAST for YAML, Dockerfiles, SQL or
shell scripts; bandit covers
src/jdlibonly (nottests/, which is exercised by the test job and scanned for secrets). - Container/IaC scanning — no image or compose-stack scan; the project ships no images.
- Untracked/gitignored local files — e.g.
tests/infra/out/kong-credentials.envis never scanned, because CI never has it (it is gitignored and must stay that way). Local secret hygiene for those files is out of scope for this job. - Hosted services — no SARIF upload to GitHub code scanning and no third-party dashboard: code scanning requires GitHub Advanced Security for private repositories (paid) and the task forbids paid/hosted dependencies. The gitleaks report and SBOM are run artifacts instead.
8. Tools evaluated and rejected¶
| Tool | Evidence | Why it was rejected |
|---|---|---|
semgrep (registry ruleset p/python) |
Ran locally: Ran 151 rules on 101 files: 12 findings — all 12 from the single rule avoid-sqlalchemy-text (impact LOW, confidence MEDIUM), 11 of them in src/jdlib/persistence/** where parameter-bound sqlalchemy.text() is the documented design |
Noisy for this codebase: it flags every use of text(), including correctly bound parameters, so the gate would either fail on false positives or need a broad exclusion. It also fetches rules from the semgrep registry at run time (unpinned ruleset, extra network dependency) and exits 0 unless --error is passed, which makes a naive wiring a silent no-op. Bandit covers the Python scope with a pinned version and no registry. |
gitleaks/gitleaks-action |
Upstream README: GITLEAKS_LICENSE: ... # Only required for Organizations, not personal accounts; the action's licence changed from MIT at v2 |
Requires a paid licence for organisations, which the constraints rule out. The pinned official container gives the identical engine with no licence and no extra moving parts. |
| GitHub code scanning / CodeQL (SARIF upload) | Requires GitHub Advanced Security for private repositories (paid) | Paid hosted dependency; also unnecessary — the same findings live in the uploaded artifacts. |
detect-secrets (alternative secret scanner) |
Not run | gitleaks already covers full-history scanning with an official pinned container and a config format that supports value-pinned allowlists. Adding a second secret scanner would duplicate the gate without adding coverage. Recorded as considered, not as tested. |
gitleaks dir (working-tree mode) |
Ran locally: 35 findings, 25 of them from .venv/, __pycache__/ and tests/infra/out/ |
Noisy and unsafe locally (it matches local infra credential files); the committed checkout contains none of that, and history scanning is the stricter gate anyway. |
9. Verification status (what is proven where)¶
Verified locally on the development host (Windows, Docker 29.8.0, Python 3.12.10) by running each gate's command against this working tree:
| Check | Result |
|---|---|
yaml.safe_load of .github/workflows/ci.yml |
OK |
| Workflow audit (job names, test job unchanged, no soft-fail constructs, every invoked module present) | PASS |
| pip-audit (57 pinned requirements) | No known vulnerabilities found, exit 0 |
pip-audit negative test (requests==2.19.0, pyyaml==5.3.1) |
advisories listed, exit 1 |
gitleaks v8.30.1 history scan with .gitleaks.toml |
no leaks found, exit 0 (155 commits at that run; the count grows with every commit, and a later run of the same gate scanned 157) |
| gitleaks probe (reviewed fixture + unreviewed PAT) | 1 finding, exit 1; allowlisted fixture suppressed |
Post-commit simulation (.gitleaks.toml, this document and the workflow committed in a scratch repo, plus one planted unreviewed secret) |
exactly 1 finding — the planted secret, exit 1; none in the new/changed files |
bandit 1.9.4 -ll |
exit 0; Low 19 / Medium 0 / High 0; #nosec skipped 1 |
| bandit negative test (string-formatted SQL) | B608 MEDIUM reported, exit 1 |
| CycloneDX SBOM (exact CI command) | CycloneDX 1.6, jdlib 0.1.0, 58 components, no secrets |
python -m build |
Successfully built jdlib-0.1.0.tar.gz and jdlib-0.1.0-py3-none-any.whl |
Fresh venv wheel install + import jdlib, jdlib.security |
OK, imported from the smoke venv's site-packages |
Pinned gitleaks tag v8.30.1 pull + scan |
identical result to latest |
Not verifiable locally (stated so it is not mistaken for tested): GitHub
Actions runner behaviour — actions/checkout@v4/actions/setup-python@v5/
actions/upload-artifact@v4 execution, $RUNNER_TEMP resolution, and the
Ubuntu equivalent of the docker run invocation above (the same flags were
executed through Docker Desktop on Windows, not on the runner image). The first
run of the security job on a real runner is the remaining verification step.
10. Maintenance¶
- Bump the pinned versions (four Python tools, one image tag) deliberately and re-run each command above locally before pushing; record the new versions in §1.
- Any change to
.gitleaks.tomlmust come with a table row in §6.1. - Keep the existing
testjob untouched; the security job adds gates, it does not replace or relax anything. - Commands never print credentials: gitleaks runs with
--redact, and the SBOM, the gitleaks report and the distributions are the only uploaded artifacts.tests/infra/out/kong-credentials.envand any.envfile are gitignored and must never be committed, uploaded or echoed.