{"meta":{"tool":"kevgate","tag":"v0.1.1","commit":"973c8c1","commit_full":"973c8c143e8e7d571e52af3f290f4c835954599a","repo":"https://github.com/samiali38183/kevgate","captured":"2026-11-11","tests_total":277,"tests_cited":34,"test_cases_cited":84,"test_cases_passed":84,"python":"3.12.2","pytest":"9.2.0","os":"Windows-11-10.0.26300-SP0","command":"pytest --collect-only, then pytest on each cited test; gh api actions/runs/ID/jobs; GHCR manifests"},"layers":[{"id":"application","label":"Application"},{"id":"container","label":"Container"},{"id":"cluster","label":"Cluster"},{"id":"cloud","label":"Cloud"},{"id":"supply","label":"Supply chain"}],"runs":{"ci":{"id":42960808156,"workflow":"ci","sha":"973c8c1","event":"push","branch":"main","date":"2026-11-11","conclusion":"success"},"security":{"id":42960808037,"workflow":"security","sha":"973c8c1","event":"push","branch":"main","date":"2026-11-11","conclusion":"success"},"deploy-kind":{"id":42960807983,"workflow":"deploy-kind","sha":"973c8c1","event":"push","branch":"main","date":"2026-11-11","conclusion":"success"},"terraform":{"id":42956666367,"workflow":"terraform","sha":"b08a920","event":"push","branch":"main","date":"2026-11-11","conclusion":"success"},"release":{"id":42960813836,"workflow":"release","sha":"973c8c1","event":"push","branch":"v0.1.1","date":"2026-11-11","conclusion":"success"}},"controls":[{"id":"keys","layer":"application","title":"API keys stored as hashes and compared in constant time","threat":{"text":"Timing side channel reveals key bytes or which key matched","stride":"Spoofing","src":"docs/THREAT_MODEL.md","line":46,"sec":"2"},"limits":[{"text":"A leaked plaintext key stays valid until removed. There is no expiry, no last-used tracking, and revocation needs a restart","src":"docs/THREAT_MODEL.md","line":47,"sec":"2"}],"code":[{"path":"src/kevgate/auth.py","start":70,"end":71},{"path":"src/kevgate/auth.py","start":151,"end":162}],"tests":[{"id":"tests/test_auth.py::test_authenticate_checks_every_key","path":"tests/test_auth.py","name":"test_authenticate_checks_every_key","cases":1,"start":104,"end":111},{"id":"tests/test_mutation_kills.py::test_authenticate_compares_every_key_with_compare_digest","path":"tests/test_mutation_kills.py","name":"test_authenticate_compares_every_key_with_compare_digest","cases":1,"start":67,"end":85},{"id":"tests/test_auth.py::test_generated_keys_are_unique_and_high_entropy","path":"tests/test_auth.py","name":"test_generated_keys_are_unique_and_high_entropy","cases":1,"start":21,"end":24}],"ci":[{"wf":"ci","job":"lint, types, tests (py3.12)","job_id":130326619900,"step":"pytest with coverage","n":8,"conclusion":"success","src":{"path":".github/workflows/ci.yml","start":53,"end":54},"asserts":[]}]},{"id":"scopes","layer":"application","title":"401 for no or unknown key, 403 for a key without the scope","threat":{"text":"A read or write key performs admin actions","stride":"Elevation of privilege","src":"docs/THREAT_MODEL.md","line":39,"sec":"1"},"limits":[{"text":"Scopes are global; there is no per-asset or per-team authorization","src":"docs/THREAT_MODEL.md","line":39,"sec":"1"},{"text":"Ops endpoints are unauthenticated by design; rely on NetworkPolicy for `/metrics`","src":"docs/THREAT_MODEL.md","line":29,"sec":"1"}],"code":[{"path":"src/kevgate/app.py","start":121,"end":180},{"path":"src/kevgate/auth.py","start":52,"end":53}],"tests":[{"id":"tests/test_api.py::test_no_key_is_401_with_challenge","path":"tests/test_api.py","name":"test_no_key_is_401_with_challenge","cases":6,"start":37,"end":42},{"id":"tests/test_api.py::test_bad_key_is_401","path":"tests/test_api.py","name":"test_bad_key_is_401","cases":30,"start":56,"end":59},{"id":"tests/test_api.py::test_scope_matrix","path":"tests/test_api.py","name":"test_scope_matrix","cases":6,"start":63,"end":71},{"id":"tests/test_api.py::test_admin_scope_implies_everything","path":"tests/test_api.py","name":"test_admin_scope_implies_everything","cases":1,"start":74,"end":78}],"ci":[{"wf":"ci","job":"lint, types, tests (py3.12)","job_id":130326619900,"step":"pytest with coverage","n":8,"conclusion":"success","src":{"path":".github/workflows/ci.yml","start":53,"end":54},"asserts":[]},{"wf":"deploy-kind","job":"kind deploy, smoke, NetworkPolicy, ZAP, kubescape","job_id":130326618870,"step":"Smoke test through port-forward","n":13,"conclusion":"success","src":{"path":".github/workflows/deploy-kind.yml","start":88,"end":93},"asserts":[{"path":"deploy/smoke.sh","start":36,"end":43}]}]},{"id":"failures","layer":"application","title":"Failed authentications throttled per source address before any key is checked","threat":{"text":"Brute-forcing a key or key id","stride":"Spoofing","src":"docs/THREAT_MODEL.md","line":37,"sec":"1"},"limits":[{"text":"Per replica and in memory. Keyed by source address: clients behind one NAT or proxy share a budget, so an attacker there can lock legitimate clients out until the bucket refills (3 seconds per token at the default). A distributed attacker with many addresses is not slowed. The 256-bit key space is what actually defeats guessing; the limiter protects CPU, logs and alert noise","src":"docs/THREAT_MODEL.md","line":37,"sec":"1"}],"code":[{"path":"src/kevgate/app.py","start":131,"end":140},{"path":"src/kevgate/ratelimit.py","start":57,"end":65},{"path":"src/kevgate/ratelimit.py","start":67,"end":75},{"path":"src/kevgate/clientip.py","start":30,"end":45}],"tests":[{"id":"tests/test_auth_failures.py::test_repeated_bad_keys_get_throttled_before_auth","path":"tests/test_auth_failures.py","name":"test_repeated_bad_keys_get_throttled_before_auth","cases":1,"start":171,"end":186},{"id":"tests/test_auth_failures.py::test_valid_requests_never_consume_failure_budget","path":"tests/test_auth_failures.py","name":"test_valid_requests_never_consume_failure_budget","cases":1,"start":189,"end":192},{"id":"tests/test_auth_failures.py::test_peek_does_not_spend_tokens","path":"tests/test_auth_failures.py","name":"test_peek_does_not_spend_tokens","cases":1,"start":48,"end":52},{"id":"tests/test_auth_failures.py::test_trusted_proxy_header_separates_sources","path":"tests/test_auth_failures.py","name":"test_trusted_proxy_header_separates_sources","cases":1,"start":208,"end":219},{"id":"tests/test_auth_failures.py::test_forwarded_for_ignored_when_peer_not_trusted","path":"tests/test_auth_failures.py","name":"test_forwarded_for_ignored_when_peer_not_trusted","cases":1,"start":201,"end":205},{"id":"tests/test_auth_failures.py::test_resolve_client_address","path":"tests/test_auth_failures.py","name":"test_resolve_client_address","cases":10,"start":98,"end":99}],"ci":[{"wf":"ci","job":"lint, types, tests (py3.12)","job_id":130326619900,"step":"pytest with coverage","n":8,"conclusion":"success","src":{"path":".github/workflows/ci.yml","start":53,"end":54},"asserts":[]}]},{"id":"bucket","layer":"application","title":"Per-key token-bucket rate limit with Retry-After","threat":{"text":"One client floods the API","stride":"Denial of service","src":"docs/THREAT_MODEL.md","line":36,"sec":"1"},"limits":[{"text":"Per replica and in memory. Queue and stats score every row on each call, which is fine at tens of thousands of findings but not millions","src":"docs/THREAT_MODEL.md","line":36,"sec":"1"}],"code":[{"path":"src/kevgate/ratelimit.py","start":23,"end":93},{"path":"src/kevgate/app.py","start":167,"end":172}],"tests":[{"id":"tests/test_ratelimit.py::test_burst_then_block_then_refill","path":"tests/test_ratelimit.py","name":"test_burst_then_block_then_refill","cases":1,"start":16,"end":24},{"id":"tests/test_ratelimit.py::test_retry_after_reflects_refill_rate","path":"tests/test_ratelimit.py","name":"test_retry_after_reflects_refill_rate","cases":1,"start":27,"end":34},{"id":"tests/test_api.py::test_rate_limit_returns_429_with_retry_after","path":"tests/test_api.py","name":"test_rate_limit_returns_429_with_retry_after","cases":1,"start":127,"end":136},{"id":"tests/test_api.py::test_ops_endpoints_not_rate_limited","path":"tests/test_api.py","name":"test_ops_endpoints_not_rate_limited","cases":1,"start":145,"end":147}],"ci":[{"wf":"ci","job":"lint, types, tests (py3.12)","job_id":130326619900,"step":"pytest with coverage","n":8,"conclusion":"success","src":{"path":".github/workflows/ci.yml","start":53,"end":54},"asserts":[]}]},{"id":"body","layer":"application","title":"Request body size limit on declared length and on streamed chunks","threat":{"text":"Large or slow bodies exhaust memory","stride":"Denial of service","src":"docs/THREAT_MODEL.md","line":35,"sec":"1"},"limits":[{"text":"Slowloris-style slow uploads are left to the ingress and uvicorn timeouts","src":"docs/THREAT_MODEL.md","line":35,"sec":"1"}],"code":[{"path":"src/kevgate/middleware.py","start":127,"end":168},{"path":"src/kevgate/app.py","start":269,"end":269}],"tests":[{"id":"tests/test_api.py::test_body_over_limit_by_content_length_is_413","path":"tests/test_api.py","name":"test_body_over_limit_by_content_length_is_413","cases":1,"start":153,"end":160},{"id":"tests/test_api.py::test_chunked_body_over_limit_is_413","path":"tests/test_api.py","name":"test_chunked_body_over_limit_is_413","cases":1,"start":163,"end":167},{"id":"tests/test_mutation_kills.py::test_many_small_chunks_over_limit_is_413","path":"tests/test_mutation_kills.py","name":"test_many_small_chunks_over_limit_is_413","cases":1,"start":121,"end":122},{"id":"tests/test_mutation_kills.py::test_chunks_under_limit_pass_through","path":"tests/test_mutation_kills.py","name":"test_chunks_under_limit_pass_through","cases":1,"start":125,"end":126}],"ci":[{"wf":"ci","job":"lint, types, tests (py3.12)","job_id":130326619900,"step":"pytest with coverage","n":8,"conclusion":"success","src":{"path":".github/workflows/ci.yml","start":53,"end":54},"asserts":[]}]},{"id":"headers","layer":"application","title":"Security response headers on every response","threat":{"text":"Browser or proxy caches findings","stride":"Information disclosure","src":"docs/THREAT_MODEL.md","line":34,"sec":"1"},"limits":[{"text":"uvicorn's `server` header remains when launched with plain `uvicorn` and no `--no-server-header`","src":"docs/THREAT_MODEL.md","line":34,"sec":"1"}],"code":[{"path":"src/kevgate/middleware.py","start":29,"end":36},{"path":"src/kevgate/middleware.py","start":85,"end":88}],"tests":[{"id":"tests/test_api.py::test_security_headers_on_api_responses","path":"tests/test_api.py","name":"test_security_headers_on_api_responses","cases":1,"start":411,"end":417}],"ci":[{"wf":"ci","job":"lint, types, tests (py3.12)","job_id":130326619900,"step":"pytest with coverage","n":8,"conclusion":"success","src":{"path":".github/workflows/ci.yml","start":53,"end":54},"asserts":[]}]},{"id":"logs","layer":"application","title":"JSON logs that carry a key id and never the key","threat":{"text":"Keys end up in logs","stride":"Information disclosure","src":"docs/THREAT_MODEL.md","line":48,"sec":"2"},"limits":[{"text":"Upstream proxies may log the `Authorization` header if misconfigured","src":"docs/THREAT_MODEL.md","line":48,"sec":"2"}],"code":[{"path":"src/kevgate/middleware.py","start":106,"end":119},{"path":"src/kevgate/app.py","start":101,"end":118},{"path":"src/kevgate/logs.py","start":19,"end":33}],"tests":[{"id":"tests/test_api.py::test_access_log_has_fields_and_no_secrets","path":"tests/test_api.py","name":"test_access_log_has_fields_and_no_secrets","cases":1,"start":429,"end":443},{"id":"tests/test_auth_failures.py::test_failures_log_client_address_and_never_the_key","path":"tests/test_auth_failures.py","name":"test_failures_log_client_address_and_never_the_key","cases":1,"start":149,"end":168},{"id":"tests/test_config_cli_logs.py::test_json_formatter_shape","path":"tests/test_config_cli_logs.py","name":"test_json_formatter_shape","cases":1,"start":82,"end":88}],"ci":[{"wf":"ci","job":"lint, types, tests (py3.12)","job_id":130326619900,"step":"pytest with coverage","n":8,"conclusion":"success","src":{"path":".github/workflows/ci.yml","start":53,"end":54},"asserts":[]}]},{"id":"ready","layer":"application","title":"Readiness checks storage headroom, a rolled-back write and database integrity","threat":{"text":"Disk fills or file is locked","stride":"Denial of service","src":"docs/THREAT_MODEL.md","line":59,"sec":"3"},"limits":[{"text":"One writer at a time, and one SQLite file cannot be shared by several replicas. Horizontal scaling needs Postgres. A full disk is caught only when free space drops under the threshold, and there is still no volume-usage alert","src":"docs/THREAT_MODEL.md","line":59,"sec":"3"}],"code":[{"path":"src/kevgate/app.py","start":281,"end":291},{"path":"src/kevgate/db.py","start":174,"end":185},{"path":"src/kevgate/db.py","start":187,"end":202},{"path":"src/kevgate/db.py","start":204,"end":232}],"tests":[{"id":"tests/test_readiness.py::test_write_probe_fails_when_another_connection_holds_the_write_lock","path":"tests/test_readiness.py","name":"test_write_probe_fails_when_another_connection_holds_the_write_lock","cases":1,"start":106,"end":120},{"id":"tests/test_readiness.py::test_storage_fails_when_disk_nearly_full","path":"tests/test_readiness.py","name":"test_storage_fails_when_disk_nearly_full","cases":1,"start":74,"end":81},{"id":"tests/test_readiness.py::test_integrity_detects_corruption","path":"tests/test_readiness.py","name":"test_integrity_detects_corruption","cases":1,"start":136,"end":150},{"id":"tests/test_readiness.py::test_integrity_over_budget_is_inconclusive_not_failed_and_not_cached","path":"tests/test_readiness.py","name":"test_integrity_over_budget_is_inconclusive_not_failed_and_not_cached","cases":1,"start":168,"end":173},{"id":"tests/test_readiness.py::test_readyz_503_when_a_probe_fails","path":"tests/test_readiness.py","name":"test_readyz_503_when_a_probe_fails","cases":3,"start":197,"end":205},{"id":"tests/test_readiness.py::test_readyz_ok_with_real_database_file","path":"tests/test_readiness.py","name":"test_readyz_ok_with_real_database_file","cases":1,"start":186,"end":190}],"ci":[{"wf":"ci","job":"lint, types, tests (py3.12)","job_id":130326619900,"step":"pytest with coverage","n":8,"conclusion":"success","src":{"path":".github/workflows/ci.yml","start":53,"end":54},"asserts":[]},{"wf":"deploy-kind","job":"kind deploy, smoke, NetworkPolicy, ZAP, kubescape","job_id":130326618870,"step":"Smoke test through port-forward","n":13,"conclusion":"success","src":{"path":".github/workflows/deploy-kind.yml","start":88,"end":93},"asserts":[{"path":"deploy/smoke.sh","start":32,"end":32}]}]},{"id":"container","layer":"container","title":"Non-root user, read-only root filesystem, all capabilities dropped","threat":{"text":"Container breakout from a root process","stride":"Elevation of privilege","src":"docs/THREAT_MODEL.md","line":78,"sec":"5"},"limits":[{"text":"There is a shell in the image (Debian slim ships `sh`).","src":"docs/DEPLOYMENT.md","line":110}],"code":[{"path":"Dockerfile","start":67,"end":77},{"path":"Dockerfile","start":82,"end":82},{"path":"deploy/helm/kevgate/values.yaml","start":53,"end":60},{"path":"deploy/helm/kevgate/values.yaml","start":62,"end":73}],"tests":[],"ci":[{"wf":"security","job":"Image build, Trivy scan, SBOM","job_id":130326619089,"step":"Container runs read-only as UID 10001 and passes the smoke test","n":11,"conclusion":"success","src":{"path":".github/workflows/security.yml","start":255,"end":271},"asserts":[]},{"wf":"deploy-kind","job":"kind deploy, smoke, NetworkPolicy, ZAP, kubescape","job_id":130326618870,"step":"Prove restricted pod security context on the live pod","n":16,"conclusion":"success","src":{"path":".github/workflows/deploy-kind.yml","start":101,"end":111},"asserts":[]}]},{"id":"netpol","layer":"cluster","title":"NetworkPolicy: ingress from labelled pods only, egress to DNS only","threat":{"text":"Other pods scrape metrics or call the API","stride":"Spoofing / disclosure","src":"docs/THREAT_MODEL.md","line":81,"sec":"5"},"limits":[{"text":"Ops endpoints are unauthenticated by design; rely on NetworkPolicy for `/metrics`","src":"docs/THREAT_MODEL.md","line":29,"sec":"1"},{"text":"No WAF, ingress controller or TLS termination.","src":"docs/DEPLOYMENT.md","line":239}],"code":[{"path":"deploy/helm/kevgate/templates/networkpolicy.yaml","start":13,"end":74},{"path":"deploy/helm/kevgate/values.yaml","start":127,"end":160}],"tests":[],"ci":[{"wf":"deploy-kind","job":"kind deploy, smoke, NetworkPolicy, ZAP, kubescape","job_id":130326618870,"step":"NetworkPolicy, egress deny and Pod Security admission","n":14,"conclusion":"success","src":{"path":".github/workflows/deploy-kind.yml","start":95,"end":96},"asserts":[{"path":"deploy/scripts/netpol-test.sh","start":57,"end":65},{"path":"deploy/scripts/netpol-test.sh","start":69,"end":79}]}]},{"id":"psa","layer":"cluster","title":"Pod Security Admission enforces the restricted profile on the namespace","threat":{"text":"Container breakout from a root process","stride":"Elevation of privilege","src":"docs/THREAT_MODEL.md","line":78,"sec":"5"},"limits":[{"text":"No admission policy engine (Kyverno or Gatekeeper) beyond Pod Security Admission.","src":"docs/DEPLOYMENT.md","line":241}],"code":[{"path":"deploy/kind/namespace.yaml","start":8,"end":14}],"tests":[],"ci":[{"wf":"deploy-kind","job":"kind deploy, smoke, NetworkPolicy, ZAP, kubescape","job_id":130326618870,"step":"NetworkPolicy, egress deny and Pod Security admission","n":14,"conclusion":"success","src":{"path":".github/workflows/deploy-kind.yml","start":95,"end":96},"asserts":[{"path":"deploy/scripts/netpol-test.sh","start":81,"end":88}]}]},{"id":"alerts","layer":"cluster","title":"Prometheus alert rules for auth failures, forbidden use and rate limiting","threat":{"text":"invalid suggests key guessing or a revoked key still in use; forbidden suggests a valid key used outside its scopes.","src":"deploy/monitoring/kevgate-rules.yaml","line":97},"limits":[{"text":"Alert rules and the dashboard never ran against a live Prometheus. They are checked by `promtool` unit tests and schema validation only.","src":"docs/DEPLOYMENT.md","line":53}],"code":[{"path":"deploy/monitoring/kevgate-rules.yaml","start":88,"end":98},{"path":"deploy/monitoring/kevgate-rules.yaml","start":100,"end":109},{"path":"deploy/monitoring/kevgate-rules.yaml","start":111,"end":121},{"path":"src/kevgate/metrics.py","start":62,"end":65}],"tests":[{"id":"tests/test_auth_failures.py::test_labelled_counters_exist_at_zero_on_startup","path":"tests/test_auth_failures.py","name":"test_labelled_counters_exist_at_zero_on_startup","cases":1,"start":30,"end":35},{"id":"tests/test_auth_failures.py::test_first_forbidden_event_moves_series_from_zero","path":"tests/test_auth_failures.py","name":"test_first_forbidden_event_moves_series_from_zero","cases":1,"start":38,"end":42},{"id":"tests/test_api.py::test_auth_failures_counted","path":"tests/test_api.py","name":"test_auth_failures_counted","cases":1,"start":92,"end":99}],"ci":[{"wf":"security","job":"IaC and config scan (checkov, trivy, lint)","job_id":130326618963,"step":"promtool (rules, unit tests, CRD in sync)","n":10,"conclusion":"success","src":{"path":".github/workflows/security.yml","start":194,"end":199},"asserts":[]}]},{"id":"sigma","layer":"cluster","title":"Sigma rules for Kubernetes audit events","threat":{"text":"Detects kubectl exec or attach into a kevgate pod. The kevgate image needs no interactive access in normal operation (no shell tooling, read-only root filesystem), so a session in the container is either break-glass debugging or an attacker with cluster credentials looking for the API key file or the SQLite database on /data.","src":"deploy/detections/k8s_exec_into_kevgate_pod.yml","line":5},"limits":[{"text":"The Sigma rules never ran in a SIEM. `audit-check.sh` is a hand translation to `jq` that checks field names against a real kind audit log.","src":"docs/DEPLOYMENT.md","line":55}],"code":[{"path":"deploy/detections/k8s_exec_into_kevgate_pod.yml","start":20,"end":30},{"path":"deploy/detections/k8s_privileged_pod_in_kevgate_namespace.yml","start":24,"end":37},{"path":"deploy/detections/k8s_kevgate_secret_read_by_other_sa.yml","start":21,"end":34},{"path":"deploy/detections/k8s_ephemeral_container_in_kevgate_pod.yml","start":19,"end":27}],"tests":[],"ci":[{"wf":"security","job":"IaC and config scan (checkov, trivy, lint)","job_id":130326618963,"step":"Sigma rules","n":11,"conclusion":"success","src":{"path":".github/workflows/security.yml","start":201,"end":202},"asserts":[]},{"wf":"deploy-kind","job":"kind deploy, smoke, NetworkPolicy, ZAP, kubescape","job_id":130326618870,"step":"Audit log contains the events the Sigma rules target","n":15,"conclusion":"success","src":{"path":".github/workflows/deploy-kind.yml","start":98,"end":99},"asserts":[{"path":"deploy/scripts/audit-check.sh","start":49,"end":51},{"path":"deploy/scripts/audit-check.sh","start":53,"end":57},{"path":"deploy/scripts/audit-check.sh","start":59,"end":61}]}],"note":"audit-check.sh checks 3 of the 4 rules against a real kind audit log; `sigma check` covers all 4."},{"id":"lock","layer":"supply","title":"Dependencies installed from a committed, hash-locked file","threat":{"text":"Vulnerable dependency","stride":"Tampering","src":"docs/THREAT_MODEL.md","line":82,"sec":"5"},"limits":[{"text":"Dependabot does not recognise `requirements.lock` as an input, so the workflow is the check that covers it.","src":"docs/PIPELINE.md","line":80}],"code":[{"path":"Dockerfile","start":28,"end":29},{"path":".github/dependabot.yml","start":9,"end":14}],"tests":[],"ci":[{"wf":"ci","job":"lint, types, tests (py3.12)","job_id":130326619900,"step":"Install runtime dependencies from the hashed lock","n":4,"conclusion":"success","src":{"path":".github/workflows/ci.yml","start":38,"end":39},"asserts":[]},{"wf":"security","job":"SCA (pip-audit on the hashed lock)","job_id":130326619075,"step":"pip-audit","n":5,"conclusion":"success","src":{"path":".github/workflows/security.yml","start":85,"end":90},"asserts":[]},{"wf":"security","job":"Image build, Trivy scan, SBOM","job_id":130326619089,"step":"Build image","n":7,"conclusion":"success","src":{"path":".github/workflows/security.yml","start":231,"end":240},"asserts":[]}]},{"id":"release","layer":"supply","title":"Release image signed, with SBOM and provenance attestations","threat":{"text":"The aim is that a pull request cannot reach `main` with a known-vulnerable dependency, a leaked secret, a weakened pod spec or an unscanned image, and that a release carries proof of where it came from.","src":"docs/PIPELINE.md","line":3},"limits":[{"text":"No image signature verification at admission. Signing exists; enforcing it in the cluster does not.","src":"docs/DEPLOYMENT.md","line":242},{"text":"Verify the cosign signature at admission (Kyverno or a similar controller), deploy by image digest, and turn on branch protection with required checks for `main`.","src":"docs/DEPLOYMENT.md","line":274}],"code":[{"path":".github/workflows/release.yml","start":100,"end":105},{"path":".github/workflows/release.yml","start":107,"end":112},{"path":".github/workflows/release.yml","start":114,"end":123}],"tests":[],"ci":[{"wf":"release","job":"build, scan, push, sign, attest","job_id":130326638562,"step":"Sign image (keyless) and attach SBOM attestation","n":15,"conclusion":"success","src":{"path":".github/workflows/release.yml","start":100,"end":105},"asserts":[]},{"wf":"release","job":"build, scan, push, sign, attest","job_id":130326638562,"step":"Build provenance attestation","n":16,"conclusion":"success","src":{"path":".github/workflows/release.yml","start":107,"end":112},"asserts":[]},{"wf":"release","job":"build, scan, push, sign, attest","job_id":130326638562,"step":"Verify what was just published","n":17,"conclusion":"success","src":{"path":".github/workflows/release.yml","start":114,"end":123},"asserts":[]}],"registry":{"image":"ghcr.io/samiali38183/kevgate","tag":"0.1.1","digest":"sha256:526cae185004d9b7f2121d24cbe8e3783b8c4b814331dff96f11cea17c687d27","anonymous_pull":true,"attached":["https://cyclonedx.org/bom","https://sigstore.dev/cosign/sign/v1","https://slsa.dev/provenance/v1"],"sbom_components":104}},{"id":"oidc","layer":"cloud","title":"CI role trust policy pinned to one repository and one branch","threat":{"text":"The CI role's trust policy is the one place where a small edit (`StringEquals` to `StringLike`, or a `*` in the `sub` value) would let any workflow in the repository, including pull request runs, assume the role.","src":"docs/DEPLOYMENT.md","line":197},"limits":[{"text":"The CI role's trust policy is pinned by a `terraform test` (see docs/DEPLOYMENT.md), but `main` has no branch protection, so whoever can push to `main` controls what may assume that role. The role has no consumer today.","src":"docs/THREAT_MODEL.md","line":91},{"text":"It checks the policy as written in Terraform, not what AWS stores after an apply, and it does not cover branch protection on `main`: anyone who can push to `main` still controls what runs with that `sub`. Branch protection is not configured for this repository.","src":"docs/DEPLOYMENT.md","line":212}],"code":[{"path":"infra/terraform/modules/kevgate/iam.tf","start":14,"end":41},{"path":"infra/terraform/modules/kevgate/variables.tf","start":23,"end":32},{"path":"infra/terraform/modules/kevgate/tests/oidc_trust.tftest.hcl","start":40,"end":100}],"tests":[],"ci":[{"wf":"terraform","job":"OIDC trust policy regression (terraform test)","job_id":130313282767,"step":"Trust policy must be StringEquals on aud and one exact sub, no wildcard","n":4,"conclusion":"success","src":{"path":".github/workflows/terraform.yml","start":81,"end":86},"asserts":[{"path":"infra/terraform/modules/kevgate/tests/oidc_trust.tftest.hcl","start":120,"end":129}]}]}]}
