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
65 changes: 65 additions & 0 deletions .github/workflows/check-known-host-source.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
name: check-known-host-source

on:
pull_request:
branches:
- main
workflow_dispatch:
inputs:
hero_host:
description: Approved SSH destination to match against the canonical source.
required: true
type: string

permissions:
contents: read

jobs:
synthetic-tests:
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97
with:
python-version: "3.14"
- name: Test source diagnostic with synthetic material
run: python tests/test-known-host-source.py

check-source:
if: github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main'
needs: synthetic-tests
runs-on: arc-tf
environment: production
timeout-minutes: 5
permissions:
contents: read
id-token: write
concurrency:
group: check-known-host-source
cancel-in-progress: false
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
persist-credentials: false
- name: Configure existing AWS SOPS KMS access
uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c
with:
role-to-assume: arn:aws:iam::332355796717:role/github-actions-sops-kms
aws-region: us-west-2
role-duration-seconds: 900
unset-current-credentials: true
- name: Match canonical known-host source without value output
timeout-minutes: 1
env:
HERO_HOST: ${{ inputs.hero_host }}
run: python3 scripts/check-known-host-source.py
- name: Remove diagnostic temporary material
if: always()
shell: bash
run: rm -rf -- "$RUNNER_TEMP/known-host-source-check-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
38 changes: 38 additions & 0 deletions docs/known-host-source-diagnostic.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Canonical known-host source diagnostic

`tfroot-github` maintains `scripts/check-known-host-source.py` and its sole consumer, the `check-known-host-source` workflow. This is an owner-approved diagnostic for the existing `ssh_known_hosts` distribution path, not a new secret distributor.

The existing `arc-tf` image supplies Python, SOPS and OpenSSH. The helper delegates extraction and matching to SOPS and `ssh-keygen`; no alternate parser, remote key scan, new package, or shared-workflow fork is used. Thin orchestration suppresses their output and removes the temporary file.

## Scope and gates

PR events run synthetic tests only in this new workflow. Source extraction runs only on a manually approved dispatch from `main`, after the tests, using the existing `production` environment and AWS SOPS KMS role. Supply the approved destination as `hero_host`; do not commit it to this repository. No Actions secret is read back, no GitHub secret is written, no host is contacted, and no OpenTofu/state operation runs in this diagnostic.

**The repository's existing OpenTofu workflow is unchanged.** Opening a PR still triggers its usual test/plan. Merging this diagnostic to `main` still triggers the usual main workflow, including its environment-scoped apply job. Review all outstanding infrastructure changes and actual environment protection before merge; installing this diagnostic must not be treated as approval to reapply unrelated infrastructure. A GitHub environment declaration alone does not prove that required-reviewer protection is configured.

After a separately approved merge, use a new manual `check-known-host-source` dispatch from `main`. Do not rerun an old OpenTofu apply as a diagnostic. IAM/KMS access in this new job is not proven until the authorized dispatch succeeds.

## Output contract

The helper extracts the canonical `ssh_known_hosts` field from `secrets/secrets.yaml` inside CI without buffering unbounded SOPS stdout. It reads stdout incrementally with a selector and `os.read`, allowing at most `MAX_BYTES + 1` bytes in memory so an oversize result is detected and terminated. A run-scoped temporary directory is held under `$RUNNER_TEMP/known-host-source-check-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}`, with directory mode 0700 and file mode 0600. OpenSSH matching output is discarded. Normal success/failure removes the temporary file and directory; an always-run workflow step provides best-effort cleanup after interruption, but cleanup is not guaranteed after a host crash. No artifacts or caches contain the extracted material.

Only `source_known_hosts: status=...` is emitted:

- `match-found`: OpenSSH found a matching host entry. This is not proof of key correctness, ordinary ED25519 validity, revocation status, remote identity, or equality with the consumer secret.
- `missing-host-entry`: OpenSSH found no matching destination in the extracted source.
- `source-extraction-failed`: SOPS could not extract the field. No underlying error content is printed.
- `source-unusable`: extraction was empty or exceeded 1 MiB.
- `invalid-input`: empty, dash-prefixed, whitespace/control-bearing target.
- `tool-error`: unavailable tool, timeout, temporary-file error or unexpected failure.

Extraction is bounded to 30 seconds; the match probe to five seconds; the whole step to one minute. On timeout or oversize, the child is killed and waited for with a bounded cleanup timeout. The workflow prints no key material, source contents, fingerprints, hashes, or tool exception details. GitHub may display the non-secret dispatch destination in step environment metadata.

## Interpretation

Compare the source result with `hero-host-config`'s runner-local preflight using the same destination:

1. Source missing: inspect/correct the canonical encrypted field through the trusted owner editing path. Do not rotate the remote key merely because the destination is absent.
2. Source match but consumer missing: investigate the particular Actions-secret resource update and non-sensitive metadata. Neither this check nor a successful apply proves byte-for-byte delivery equality.
3. Both match: keep strict host verification enabled and continue the separately approved Hero check workflow.

There are no changes to `secrets.tf`, `gh-secrets.tf`, encrypted sources, consumer mappings, or the Hero workflow. Rolling back removes the helper/test/workflow/docs through a reviewed PR; no secret or host rollback is needed. CI tests use generated synthetic keys and mocked extraction, never real SOPS/KMS access.
142 changes: 142 additions & 0 deletions scripts/check-known-host-source.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
#!/usr/bin/env python3

import os
import selectors
import subprocess
import sys
import tempfile
import time
from pathlib import Path


SOURCE_COMMAND = ["sops", "--decrypt", "--extract", '["ssh_known_hosts"]', "secrets/secrets.yaml"]
MAX_BYTES = 1024 * 1024
SOURCE_TIMEOUT = 30
SOURCE_CLEANUP_TIMEOUT = 1


def _stop_source(process):
if process is None:
return
try:
if process.poll() is None:
process.kill()
except OSError:
pass
try:
process.wait(timeout=SOURCE_CLEANUP_TIMEOUT)
except (OSError, subprocess.TimeoutExpired):
try:
process.kill()
except OSError:
pass
try:
process.wait(timeout=SOURCE_CLEANUP_TIMEOUT)
except (OSError, subprocess.TimeoutExpired):
pass
for stream in (getattr(process, "stdout", None), getattr(process, "stderr", None)):
if stream is not None:
try:
stream.close()
except OSError:
pass


def _extract_source():
process = None
selector = None
source = bytearray()
stream_closed = False
try:
process = subprocess.Popen(
SOURCE_COMMAND, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL
)
selector = selectors.DefaultSelector()
selector.register(process.stdout, selectors.EVENT_READ)
deadline = time.monotonic() + SOURCE_TIMEOUT
while not stream_closed:
remaining = deadline - time.monotonic()
if remaining <= 0:
return None, "tool-error"
if not selector.select(remaining):
return None, "tool-error"
remaining_bytes = MAX_BYTES + 1 - len(source)
chunk = os.read(process.stdout.fileno(), remaining_bytes)
if not chunk:
stream_closed = True
continue
source.extend(chunk)
if len(source) > MAX_BYTES:
return None, "source-unusable"
remaining = deadline - time.monotonic()
if remaining <= 0:
return None, "tool-error"
try:
return_code = process.wait(timeout=remaining)
except subprocess.TimeoutExpired:
return None, "tool-error"
if return_code != 0:
return None, "source-extraction-failed"
return bytes(source), None
except (OSError, subprocess.TimeoutExpired):
return None, "tool-error"
finally:
if selector is not None:
try:
selector.close()
except OSError:
pass
_stop_source(process)


def check(host, directory):
if not host or host.startswith("-") or any(
char.isspace() or ord(char) < 32 or ord(char) == 127 for char in host
):
return "invalid-input"
try:
os.mkdir(directory, mode=0o700)
except OSError:
return "tool-error"
try:
source, error = _extract_source()
if error:
return error
if not source:
return "source-unusable"
with tempfile.NamedTemporaryFile(dir=directory) as known_hosts:
known_hosts.write(source)
known_hosts.flush()
matched = subprocess.run(
["ssh-keygen", "-F", host, "-f", known_hosts.name],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=5,
)
if matched.returncode == 0:
return "match-found"
if matched.returncode == 1:
return "missing-host-entry"
return "tool-error"
except (OSError, subprocess.TimeoutExpired):
return "tool-error"
finally:
try:
os.rmdir(directory)
except OSError:
pass


def main():
try:
directory = Path(os.environ["RUNNER_TEMP"]) / (
f"known-host-source-check-{os.environ['GITHUB_RUN_ID']}-"
f"{os.environ['GITHUB_RUN_ATTEMPT']}"
)
status = check(os.environ.get("HERO_HOST", ""), directory)
except Exception:
status = "tool-error"
print(f"source_known_hosts: status={status}")
return 0 if status == "match-found" else 1


if __name__ == "__main__":
sys.exit(main())
Loading
Loading