diff --git a/.kaizen/agents/custodian-codex/supervision.json b/.kaizen/agents/custodian-codex/supervision.json index 5b028336..b2168842 100644 --- a/.kaizen/agents/custodian-codex/supervision.json +++ b/.kaizen/agents/custodian-codex/supervision.json @@ -679,6 +679,34 @@ "promotion_evidence": false, "refinement_or_rescue": true, "prepared_at": "2026-10-13T16:26:09.780197+00:00" + }, + { + "proposal_id": "FORGE6-RUNNER-LOOPBACK-001", + "disposition": "unscored", + "outcome": "succeeded", + "original_revision": 1, + "original_digest_sha256": "a2d29132c639f76bed0465a30119148d207f9242c29a2229a2e90cfc5a6aecac", + "original_proposal_ref": "docs/changes/CUST-WP-0080/forge-runner-loopback-proposal-revision-1.json", + "prepared_revision": 2, + "prepared_digest_sha256": "9bd1e9f98a99298c810a47e9dffa0316fe235eff53bcd6cfa0dd6391e094f853", + "proposal_ref": "docs/changes/CUST-WP-0080/forge-runner-loopback-proposal.json", + "authorization_ref": "canon/standards/build-mode-change-authorization_v0.1.md", + "reason": "Standing founder blue-lane rule: reversible reduction of exposed Docker access, reviewed source commit and server dry-run, no credential/policy-scope expansion, exact rollback and named abort owner. No exact-revision human approval or autopilot claim.", + "target": "forgejo/Deployment forgejo-runner only; existing Docker and runner binaries pinned by digest", + "expected_result": "Runner remains Ready with same image bytes and loopback Docker ping; actual Docker listener is 127.0.0.1 only and a separate Forgejo pod cannot reach it. No running or queued tasks at apply. No replica, resource-request, credential or policy expansion.", + "verification_ref": "docs/evidence/2026-10-13-netkingdom-security/forge-runner-loopback-activation.json", + "rollback": "Run same executor --rollback with this proposal, ignored original-template.json baseline and a new receipt, only against exact candidate template/UID. Restores original listener and image references; records restored exposure. No automatic rollback on a security-probe failure; abort owner reviews live state.", + "abort_owner": "railiance-forge, source owner railiance-apps; custodian-codex executes", + "cost_estimate_eur": 0, + "risk_estimate": "Brief runner unavailability during existing Recreate rollout and cold ephemeral Docker cache. Abort if running/queued work appears before apply. An external Docker consumer using the pod IP loses access; the declared runner uses loopback. A concurrent newly scheduled job remains a race; preserve any nonterminal outcome. Original private template retained for exact guarded rollback. No new resources/spend or data deletion requested.", + "refinement_or_rescue": true, + "refinement_note": "Read-only preflight showed Docker wildcard binding in /proc/net/tcp6, so rollback verification binds the observed original listener instead of assuming an IPv4-only socket. No live mutation in revision 1.", + "promotion_evidence": false, + "prepared_at": "2026-10-13T16:42:18.349596+00:00", + "executed_revision": 2, + "executed_digest_sha256": "9bd1e9f98a99298c810a47e9dffa0316fe235eff53bcd6cfa0dd6391e094f853", + "verified_at": "2026-10-13T16:46:23.659255+00:00", + "execution_note": "Process exited 0. Generation 4 Ready, exact prior image bytes retained, local Docker ping succeeds, other-pod ping refuses, actual listener is IPv4 loopback only. Zero queued/running jobs before and after. Revision 1 was preparation only; unscored standing grant, no promotion credit." } ] } diff --git a/docs/changes/CUST-WP-0080/close_runner_docker_listener.py b/docs/changes/CUST-WP-0080/close_runner_docker_listener.py new file mode 100644 index 00000000..ecd9925c --- /dev/null +++ b/docs/changes/CUST-WP-0080/close_runner_docker_listener.py @@ -0,0 +1,214 @@ +#!/usr/bin/env python3 +"""Conditional, receipt-producing reduction of the runner Docker listener scope.""" +import argparse +import copy +import datetime +import hashlib +import importlib.util +import ipaddress +import json +from pathlib import Path +import re +import time + +HERE = Path(__file__).parent +s = importlib.util.spec_from_file_location('native', HERE / 'verify_receive_native.py') +n = importlib.util.module_from_spec(s); s.loader.exec_module(n) +NAME = 'forgejo-runner' +SELECTOR = 'app.kubernetes.io/name=forgejo-runner' +PROBE = ['sh', '-c', 'wget -q -O- -T 3 http://127.0.0.1:2375/_ping | grep -qx OK'] + + +def digest(value): + return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(',', ':')).encode()).hexdigest() + + +def deployment(): + return json.loads(n.command(['-n', 'forgejo', 'get', 'deployment', NAME, '-o', 'json'])) + + +def ready_pod(d): + pods = json.loads(n.command(['-n', 'forgejo', 'get', 'pods', '-l', SELECTOR, '-o', 'json']))['items'] + pods = [p for p in pods if not p['metadata'].get('deletionTimestamp') and + p.get('status', {}).get('phase') == 'Running' and + any(c['type'] == 'Ready' and c['status'] == 'True' for c in p.get('status', {}).get('conditions', []))] + if len(pods) != 1 or d['status'].get('observedGeneration', 0) < d['metadata']['generation'] or d['status'].get('availableReplicas') != 1: + raise ValueError('runner-not-uniquely-ready') + return pods[0] + + +def execute(pod, container, *args, timeout=30): + return n.command(['-n', 'forgejo', 'exec', pod['metadata']['name'], '-c', container, '--', *args], timeout=timeout) + + +def job_counts(): + query = "SELECT json_build_object('tasks_running',(SELECT count(*) FROM action_task WHERE status=6),'tasks_waiting',(SELECT count(*) FROM action_task WHERE status=5),'jobs_running',(SELECT count(*) FROM action_run_job WHERE status=6),'jobs_waiting',(SELECT count(*) FROM action_run_job WHERE status=5))" + return json.loads(n.command(['-n', 'databases', 'exec', 'forgejo-db-1', '--', 'psql', + '-U', 'postgres', '-d', 'forgejo', '-At', '-c', query])) + + +def boundary(pod): + # Emit only fixed booleans and listener addresses, never daemon inventories. + address = str(ipaddress.ip_address(pod['status']['podIP'])) + local = execute(pod, 'runner', 'wget', '-qO-', '-T', '3', 'http://127.0.0.1:2375/_ping').strip() == b'OK' + peers = json.loads(n.command(['-n', 'forgejo', 'get', 'pods', '-l', 'app.kubernetes.io/instance=forgejo', '-o', 'json']))['items'] + peers = [p for p in peers if not p['metadata'].get('deletionTimestamp') and + p.get('status', {}).get('phase') == 'Running' and + any(c['name'] == 'gitea' for c in p['spec']['containers'])] + if len(peers) != 1: + raise ValueError('peer-not-unique') + other = execute(peers[0], 'gitea', 'sh', '-c', + 'command -v wget >/dev/null || exit 21; if output=$(wget -qO- -T 3 "$1" 2>/dev/null); then test "$output" = OK || exit 22; printf reachable; else printf refused; fi', + 'sh', 'http://' + address + ':2375/_ping').decode() + listeners = execute(pod, 'dind', 'sh', '-c', + "awk '$2 ~ /:0947$/ && $4 == \"0A\" {print $2}' /proc/net/tcp /proc/net/tcp6").decode().splitlines() + if other not in ('reachable', 'refused') or any(not re.fullmatch(r'[0-9A-F]{8,32}:0947', a) for a in listeners): + raise ValueError('boundary-output-unexpected') + return {'runner_loopback_ping': local, 'other_pod_ping': other, + 'docker_listen_addresses_hex': listeners, 'pod_uid': pod['metadata']['uid']} + + +def candidate(template, images): + result = copy.deepcopy(template) + spec = result['spec'] + containers = {c['name']: c for c in spec['containers']} + if set(containers) != {'dind', 'runner'}: + raise ValueError('unexpected-containers') + dind = containers['dind'] + if dind['args'] != ['dockerd', '-H', 'tcp://0.0.0.0:2375', '--tls=false']: + raise ValueError('original-listener-differs') + readiness = dind['readinessProbe'] + if readiness.get('tcpSocket') != {'port': 2375} or any(k in readiness for k in ('exec', 'httpGet', 'grpc')): + raise ValueError('original-probe-differs') + hosts = [x for x in containers['runner']['env'] if x['name'] == 'DOCKER_HOST'] + if hosts != [{'name': 'DOCKER_HOST', 'value': 'tcp://127.0.0.1:2375'}]: + raise ValueError('runner-docker-host-differs') + dind['args'][2] = 'tcp://127.0.0.1:2375' + del readiness['tcpSocket'] + readiness['exec'] = {'command': PROBE} + # Pin the exact currently running images, including registration init. + for c in spec['containers']: + c['image'] = images[c['name']] + for c in spec.get('initContainers', []): + if c['name'] != 'register': + raise ValueError('unexpected-init-container') + c['image'] = images['runner'] + return result + + +def patch(d, target): + return [{'op': 'test', 'path': '/metadata/uid', 'value': d['metadata']['uid']}, + {'op': 'test', 'path': '/metadata/resourceVersion', 'value': d['metadata']['resourceVersion']}, + {'op': 'test', 'path': '/spec/template', 'value': d['spec']['template']}, + {'op': 'replace', 'path': '/spec/template', 'value': target}] + + +def send_patch(operations, dry_run=False): + args = ['-n', 'forgejo', 'patch', 'deployment', NAME, '--type=json', '--patch-file=/dev/stdin', '-o', 'json'] + if dry_run: + args.append('--dry-run=server') + return json.loads(n.command(args, payload=json.dumps(operations).encode(), timeout=45)) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--proposal', type=Path, required=True) + parser.add_argument('--receipt', type=Path, required=True) + parser.add_argument('--baseline', type=Path, required=True, help='Private exact original template for guarded rollback') + modes = parser.add_mutually_exclusive_group() + modes.add_argument('--apply', action='store_true') + modes.add_argument('--rollback', action='store_true') + args = parser.parse_args() + if args.receipt.exists(): + raise SystemExit('Retain prior receipt; never replay an ambiguous change.') + p = json.loads(args.proposal.read_text()) + record = {'proposal_id': p['proposal_id'], 'proposal_sha256': hashlib.sha256(args.proposal.read_bytes()).hexdigest(), + 'at': datetime.datetime.now(datetime.timezone.utc).isoformat(), 'result': 'unverified', + 'mode': 'rollback' if args.rollback else 'apply' if args.apply else 'dry-run'} + def save(): + args.receipt.write_text(json.dumps(record, indent=2) + '\n') + try: + for name, expected in p['source_sha256'].items(): + if Path(name).name != name or hashlib.sha256((HERE / name).read_bytes()).hexdigest() != expected: + raise ValueError('executor-changed') + original = json.loads(args.baseline.read_text()) + if digest(original) != p['original_template_digest']: + raise ValueError('baseline-changed') + target = candidate(original, p['images']) + if digest(target) != p['candidate_template_digest']: + raise ValueError('candidate-changed') + d = deployment() + expected = p['candidate_template_digest'] if args.rollback else p['original_template_digest'] + if d['metadata']['uid'] != p['deployment_uid'] or digest(d['spec']['template']) != expected: + raise ValueError('deployment-changed') + pod = ready_pod(d) + before_images = {c['name']: c['imageID'] for c in pod['status']['containerStatuses']} + if before_images != p['images'] or any(c['imageID'] != p['images']['runner'] for c in pod['status'].get('initContainerStatuses', [])): + raise ValueError('running-images-changed') + record['before'] = boundary(pod) + jobs = job_counts(); record['jobs_before'] = jobs + if any(jobs.values()): + raise ValueError('runner-not-idle') + if d['spec']['strategy'] != {'type': 'Recreate'}: + raise ValueError('rollout-strategy-changed') + # Read-only headroom check; Recreate and unchanged requests avoid surge. + nodes = json.loads(n.command(['get', 'nodes', '-o', 'json']))['items'] + if len(nodes) != 1 or not any(a['address'] == '92.205.62.239' for a in nodes[0]['status']['addresses']): + raise ValueError('wrong-node') + record['node_cpu_allocatable'] = nodes[0]['status']['allocatable']['cpu'] + description = n.command(['describe', 'node', nodes[0]['metadata']['name']]).decode() + section = description.split('Allocated resources:', 1)[1] + cpu = re.search(r'^\s*cpu\s+(\S+)\s+\((\d+)%\)', section, re.MULTILINE) + if not cpu: + raise ValueError('cpu-headroom-unverified') + record['node_cpu_requested'] = cpu.group(1) + record['node_cpu_requested_percent'] = int(cpu.group(2)) + record['no_surge_or_request_increase'] = True + target = original if args.rollback else target + operations = patch(d, target) + dry = send_patch(operations, True) + if digest(dry['spec']['template']) != digest(target): + raise ValueError('server-defaulting-differs') + record['server_dry_run_verified'] = True + record['result'] = 'dry-run-verified'; save() + if not (args.apply or args.rollback): + print(json.dumps(record)); return + # Recheck immediately before mutation; a concurrent new job is an abort. + record['jobs_at_apply'] = job_counts() + if any(record['jobs_at_apply'].values()): + raise ValueError('runner-became-busy') + record['result'] = 'apply-outcome-unverified'; save() + changed = send_patch(operations) + record.update(result='rollout-started', generation=changed['metadata']['generation']); save() + deadline = time.monotonic() + 360 + while time.monotonic() < deadline: + after = deployment() + try: + new = ready_pod(after) + if new['metadata']['uid'] != pod['metadata']['uid'] and digest(after['spec']['template']) == digest(target): + break + except ValueError: + pass + time.sleep(3) + else: + raise ValueError('rollout-unverified') + record['after'] = boundary(new) + record['jobs_after'] = job_counts() + images = {c['name']: c['imageID'] for c in new['status']['containerStatuses']} + record['images_unchanged'] = images == p['images'] and all(c['imageID'] == p['images']['runner'] for c in new['status'].get('initContainerStatuses', [])) + expected_other = 'reachable' if args.rollback else 'refused' + expected_listeners = p['original_listener_addresses_hex'] if args.rollback else ['0100007F:0947'] + if (not record['after']['runner_loopback_ping'] or record['after']['other_pod_ping'] != expected_other or + record['after']['docker_listen_addresses_hex'] != expected_listeners or not record['images_unchanged']): + raise ValueError('native-boundary-unverified') + record['result'] = 'rollback-verified' if args.rollback else 'verified'; save() + print(json.dumps(record)) + except Exception: + record['result'] = 'unverified' + record['raw_diagnostics_retained'] = False + save() + raise SystemExit('Runner change incomplete; inspect sanitized receipt and live state before retry.') + + +if __name__ == '__main__': + main() diff --git a/docs/changes/CUST-WP-0080/forge-runner-loopback-operations.md b/docs/changes/CUST-WP-0080/forge-runner-loopback-operations.md new file mode 100644 index 00000000..c531ee08 --- /dev/null +++ b/docs/changes/CUST-WP-0080/forge-runner-loopback-operations.md @@ -0,0 +1,52 @@ +# Runner Docker listener reduction + +FORGE6-RUNNER-LOOPBACK-001, under FORGE-WP-0006-T03 and CUST-WP-0080 G12. +The October 13 inspection reproduced unauthenticated Docker ping from another +pod. The live DinD listener covered all pod interfaces and the namespace had no +NetworkPolicy. The declared runner consumer already used `127.0.0.1:2375`. +[Docker's daemon guidance](https://docs.docker.com/engine/daemon/remote-access/) +describes the security implications of network access to this control interface. + +The source change is railiance-apps `706461b`, in +`manifests/forgejo-runner.yaml`. It binds Docker to loopback, changes DinD readiness +to a local exec ping, and pins all container/init image references to the exact +bytes already running. It changes no resources, registration, credential path, +replica count, PVC or workflow. Existing `Recreate` strategy avoids a surge pod. + +Revision 1 only ran a preflight. That observed a dual-stack wildcard socket, so +revision 2 binds rollback verification to the observed original addresses rather +than assuming an IPv4 wildcard. Both proposals and preflight receipts remain. +Revision 2's conditional server dry-run matched exactly; three patch-boundary +tests passed. CPU requests were 3460m of 4 cores (86%); this change adds no requests. + +The executor rechecks zero running/queued tasks immediately before the conditional +patch. This reduces but cannot atomically eliminate a concurrently arriving job. +Actual activation found no running/queued tasks before or after. Generation 4 +became Ready with the original image digests. The runner's loopback ping passed; +another Forgejo pod's ping was refused, and `/proc/net/tcp` showed only +`0100007F:0947`. The executor exited 0. Receipt: +`docs/evidence/2026-10-13-netkingdom-security/forge-runner-loopback-activation.json`. + +The private original template is retained, mode 0600, in ignored +`.private-backups/forge-runner-listener-20261013/original-template.json` and bound +by digest. Rollback requires the exact candidate template and Deployment UID: + +```sh +python3 docs/changes/CUST-WP-0080/close_runner_docker_listener.py \ + --proposal docs/changes/CUST-WP-0080/forge-runner-loopback-proposal.json \ + --baseline .private-backups/forge-runner-listener-20261013/original-template.json \ + --receipt docs/evidence/2026-10-13-netkingdom-security/forge-runner-loopback-rollback.json \ + --rollback +``` + +Rollback restores the known prior exposure and is not automatic on a security +verification failure. Abort owner: railiance-forge, with railiance-apps owning +the source declaration. Never rerun apply because an observation timed out; +inspect the exact running session and live template first. An unexpected template +change refuses rollback instead of overwriting concurrent work. Reconcile source +if rollback is actually used. No rollback was performed in acceptance. + +This closes the reproduced cross-pod Docker listener defect. Host-mode workflows +still share a privileged runner boundary. Job identity, source/workflow authority, +required checks, independent detection and credential isolation retain their +existing FORGE-WP-0006 / RMGR-WP-0021 / CUST-WP-0080 gates. diff --git a/docs/changes/CUST-WP-0080/forge-runner-loopback-proposal-revision-1.json b/docs/changes/CUST-WP-0080/forge-runner-loopback-proposal-revision-1.json new file mode 100644 index 00000000..012088ca --- /dev/null +++ b/docs/changes/CUST-WP-0080/forge-runner-loopback-proposal-revision-1.json @@ -0,0 +1,25 @@ +{ + "proposal_id": "FORGE6-RUNNER-LOOPBACK-001", + "revision": 1, + "deployment_uid": "89c8a056-bcae-4940-acdd-2eb2768e108a", + "original_template_digest": "e9dbbea8192bbf805f4f5838db844d40ac9d3c331739952fd3df412cce09bcca", + "candidate_template_digest": "1ded8e6fc18d5911bf95d518e19a43cf855802e77582ae04e624fd589937e130", + "images": { + "dind": "docker.io/library/docker@sha256:aa3df78ecf320f5fafdce71c659f1629e96e9de0968305fe1de670e0ca9176ce", + "runner": "code.forgejo.org/forgejo/runner@sha256:5071e6832313bafe71577e05631bece88caff08fcfb372193e4a21941f4ed54b" + }, + "source_sha256": { + "close_runner_docker_listener.py": "2ad56666680125f3e13dba4c47046618bb0ff0222d05dcae91699a204eae61e8", + "verify_receive_native.py": "c9120122c6f6f0b7c41fe4fa975e5a2a340e776885d852b861b8c13b7664adcd" + }, + "authorization_ref": "canon/standards/build-mode-change-authorization_v0.1.md", + "target": "forgejo/Deployment forgejo-runner only; existing Docker and runner binaries pinned by digest", + "expected_result": "Runner remains Ready with same image bytes and loopback Docker ping; actual Docker listener is 127.0.0.1 only and a separate Forgejo pod cannot reach it. No running or queued tasks at apply. No replica, resource-request, credential or policy expansion.", + "diff_ref": "railiance-apps/manifests/forgejo-runner.yaml; exact conditional template patch and server dry-run", + "rollback": "Run same executor --rollback with this proposal, ignored original-template.json baseline and a new receipt, only against exact candidate template/UID. Restores original listener and image references; records restored exposure. No automatic rollback on a security-probe failure; abort owner reviews live state.", + "abort_owner": "railiance-forge, source owner railiance-apps; custodian-codex executes", + "cost_estimate_eur": 0, + "risk_estimate": "Brief runner unavailability during existing Recreate rollout and cold ephemeral Docker cache. Abort if running/queued work appears before apply. An external Docker consumer using the pod IP loses access; the declared runner uses loopback. A concurrent newly scheduled job remains a race; preserve any nonterminal outcome. Original private template retained for exact guarded rollback. No new resources/spend or data deletion requested.", + "scope_limit": "Reduces unauthenticated cross-pod Docker authority only. Host-mode jobs still share the privileged runner boundary; no full CI isolation, workflow authority, required-check or secret detection conformance inferred.", + "baseline_path": ".private-backups/forge-runner-listener-20261013/original-template.json" +} diff --git a/docs/changes/CUST-WP-0080/forge-runner-loopback-proposal.json b/docs/changes/CUST-WP-0080/forge-runner-loopback-proposal.json new file mode 100644 index 00000000..0ec49ef5 --- /dev/null +++ b/docs/changes/CUST-WP-0080/forge-runner-loopback-proposal.json @@ -0,0 +1,31 @@ +{ + "proposal_id": "FORGE6-RUNNER-LOOPBACK-001", + "revision": 2, + "deployment_uid": "89c8a056-bcae-4940-acdd-2eb2768e108a", + "original_template_digest": "e9dbbea8192bbf805f4f5838db844d40ac9d3c331739952fd3df412cce09bcca", + "candidate_template_digest": "1ded8e6fc18d5911bf95d518e19a43cf855802e77582ae04e624fd589937e130", + "images": { + "dind": "docker.io/library/docker@sha256:aa3df78ecf320f5fafdce71c659f1629e96e9de0968305fe1de670e0ca9176ce", + "runner": "code.forgejo.org/forgejo/runner@sha256:5071e6832313bafe71577e05631bece88caff08fcfb372193e4a21941f4ed54b" + }, + "source_sha256": { + "close_runner_docker_listener.py": "3763a4f90ed964aad1bf5fc580318d891501dc6d908de8554a2cd7d35854e3e4", + "verify_receive_native.py": "c9120122c6f6f0b7c41fe4fa975e5a2a340e776885d852b861b8c13b7664adcd" + }, + "authorization_ref": "canon/standards/build-mode-change-authorization_v0.1.md", + "target": "forgejo/Deployment forgejo-runner only; existing Docker and runner binaries pinned by digest", + "expected_result": "Runner remains Ready with same image bytes and loopback Docker ping; actual Docker listener is 127.0.0.1 only and a separate Forgejo pod cannot reach it. No running or queued tasks at apply. No replica, resource-request, credential or policy expansion.", + "diff_ref": "railiance-apps/manifests/forgejo-runner.yaml; exact conditional template patch and server dry-run", + "rollback": "Run same executor --rollback with this proposal, ignored original-template.json baseline and a new receipt, only against exact candidate template/UID. Restores original listener and image references; records restored exposure. No automatic rollback on a security-probe failure; abort owner reviews live state.", + "abort_owner": "railiance-forge, source owner railiance-apps; custodian-codex executes", + "cost_estimate_eur": 0, + "risk_estimate": "Brief runner unavailability during existing Recreate rollout and cold ephemeral Docker cache. Abort if running/queued work appears before apply. An external Docker consumer using the pod IP loses access; the declared runner uses loopback. A concurrent newly scheduled job remains a race; preserve any nonterminal outcome. Original private template retained for exact guarded rollback. No new resources/spend or data deletion requested.", + "scope_limit": "Reduces unauthenticated cross-pod Docker authority only. Host-mode jobs still share the privileged runner boundary; no full CI isolation, workflow authority, required-check or secret detection conformance inferred.", + "baseline_path": ".private-backups/forge-runner-listener-20261013/original-template.json", + "original_revision_sha256": "a2d29132c639f76bed0465a30119148d207f9242c29a2229a2e90cfc5a6aecac", + "original_listener_addresses_hex": [ + "00000000000000000000000000000000:0947" + ], + "preparation_refinement": "Read-only preflight showed Docker wildcard binding in /proc/net/tcp6, so rollback verification binds the observed original listener instead of assuming an IPv4-only socket. No live mutation in revision 1.", + "source_commit": "railiance-apps@706461b" +} diff --git a/docs/changes/CUST-WP-0080/test_runner_listener.py b/docs/changes/CUST-WP-0080/test_runner_listener.py new file mode 100644 index 00000000..029adbdc --- /dev/null +++ b/docs/changes/CUST-WP-0080/test_runner_listener.py @@ -0,0 +1,55 @@ +import copy +import importlib.util +import unittest +from pathlib import Path + +s = importlib.util.spec_from_file_location('listener', Path(__file__).with_name('close_runner_docker_listener.py')) +m = importlib.util.module_from_spec(s); s.loader.exec_module(m) + + +class Tests(unittest.TestCase): + def fixture(self): + return {'metadata': {'labels': {'existing': 'preserved'}}, 'spec': { + 'containers': [ + {'name': 'dind', 'image': 'docker:27-dind', 'args': ['dockerd', '-H', 'tcp://0.0.0.0:2375', '--tls=false'], + 'readinessProbe': {'tcpSocket': {'port': 2375}, 'periodSeconds': 10}, + 'resources': {}, 'securityContext': {'privileged': True}}, + {'name': 'runner', 'image': 'runner:6.3.1', 'env': [{'name': 'DOCKER_HOST', 'value': 'tcp://127.0.0.1:2375'}]}], + 'initContainers': [{'name': 'register', 'image': 'runner:6.3.1', 'env': [{'name': 'REGISTRATION_TOKEN', 'valueFrom': {'secretKeyRef': {'name': 'existing', 'key': 'token'}}}]}], + 'volumes': [{'name': 'data', 'persistentVolumeClaim': {'claimName': 'existing'}}]}} + + def test_changes_only_listener_probe_and_exact_images(self): + old = self.fixture(); frozen = copy.deepcopy(old) + new = m.candidate(old, {'dind': 'docker@sha256:existing', 'runner': 'runner@sha256:existing'}) + self.assertEqual(old, frozen) + containers = new['spec']['containers'] + self.assertEqual(containers[0]['args'][2], 'tcp://127.0.0.1:2375') + self.assertEqual(containers[0]['readinessProbe']['exec']['command'], m.PROBE) + self.assertNotIn('tcpSocket', containers[0]['readinessProbe']) + containers[0]['args'][2] = old['spec']['containers'][0]['args'][2] + containers[0]['readinessProbe'] = old['spec']['containers'][0]['readinessProbe'] + for new_c, old_c in zip(containers, old['spec']['containers']): + new_c['image'] = old_c['image'] + new['spec']['initContainers'][0]['image'] = old['spec']['initContainers'][0]['image'] + self.assertEqual(new, old) + + def test_refuses_unexpected_listener_probe_and_consumer(self): + for kind in ('listener', 'probe', 'consumer', 'container'): + with self.subTest(kind=kind): + value = self.fixture() + if kind == 'listener': value['spec']['containers'][0]['args'][2] = 'tcp://different:1234' + if kind == 'probe': value['spec']['containers'][0]['readinessProbe']['httpGet'] = {} + if kind == 'consumer': value['spec']['containers'][1]['env'][0]['value'] = 'tcp://other-host:2375' + if kind == 'container': value['spec']['containers'].append({'name': 'unknown'}) + with self.assertRaises(ValueError): m.candidate(value, {'dind': 'pinned', 'runner': 'pinned'}) + + def test_patch_guards_exact_identity_revision_and_template(self): + old = {'metadata': {'uid': 'owned', 'resourceVersion': '12'}, 'spec': {'template': self.fixture()}} + patch = m.patch(old, {'desired': 'template'}) + self.assertEqual([p['path'] for p in patch[:3]], ['/metadata/uid', '/metadata/resourceVersion', '/spec/template']) + self.assertTrue(all(p['op'] == 'test' for p in patch[:3])) + self.assertEqual(patch[2]['value'], old['spec']['template']) + self.assertEqual(patch[3], {'op': 'replace', 'path': '/spec/template', 'value': {'desired': 'template'}}) + + +if __name__ == '__main__': unittest.main() diff --git a/docs/evidence/2026-10-12-netkingdom-security/sec-dsm-requirement-ledger.json b/docs/evidence/2026-10-12-netkingdom-security/sec-dsm-requirement-ledger.json index dc86d467..3bc868d8 100644 --- a/docs/evidence/2026-10-12-netkingdom-security/sec-dsm-requirement-ledger.json +++ b/docs/evidence/2026-10-12-netkingdom-security/sec-dsm-requirement-ledger.json @@ -965,7 +965,18 @@ "G07", "G12" ], - "closure_evidence": [] + "closure_evidence": [], + "assessment": { + "scope": "Existing Forgejo runner Docker network listener only; full production-grade CI/CD posture remains unverified", + "observed": "Unauthenticated Docker ping from a separate pod succeeded before repair. Exact same-image generation 4 now binds only 127.0.0.1:2375; local readiness/ping and runner heartbeat pass; peer-pod ping is refused. Resources and Recreate strategy preserved; zero queued/running jobs at change.", + "remaining": "Host-mode job privilege sharing, workflow authority, required checks, short-lived job identity, detection and response remain open. No complete DSM-CI-001 conformance inferred.", + "verification_evidence": [ + "../2026-10-13-netkingdom-security/forge-runner-loopback-activation.json", + "../2026-10-13-netkingdom-security/forge-runner-heartbeat.json" + ], + "owner_task": "FORGE-WP-0006-T03; RMGR-WP-0021-T03/T04; CUST-WP-0080-T03", + "assessed_at": "2026-10-13" + } }, { "id": "DSM-CI-002", diff --git a/docs/evidence/2026-10-13-netkingdom-security/forge-gitlink-reconciliation.json b/docs/evidence/2026-10-13-netkingdom-security/forge-gitlink-reconciliation.json new file mode 100644 index 00000000..9a397252 --- /dev/null +++ b/docs/evidence/2026-10-13-netkingdom-security/forge-gitlink-reconciliation.json @@ -0,0 +1,31 @@ +{ + "repository_id": 55, + "scope": "Read-only legacy-address reconciliation against current Forgejo directories and pinned commit reachability; no history scanning or URL edits.", + "observed": [ + { + "submodule": "_issue-tracking/issue-facade", + "canonical_repository": "issue-facade", + "pinned_commit": "70d7ec0cdcbdff1a41cc420e2c939f855fe7787b", + "observation": "repository-missing" + }, + { + "submodule": "capabilities/kaizen-agentic", + "canonical_repository": "kaizen-agentic", + "pinned_commit": "afc038d98b1efc5098f473e55fdddd2999f798ba", + "observation": "present commit-present 2" + }, + { + "submodule": "capabilities/testdrive-jsui", + "canonical_repository": "testdrive-jsui", + "pinned_commit": "71a2a1e52c02d2fa6df2a4d5f5e808e17fe07221", + "observation": "present commit-present 1" + }, + { + "submodule": "wiki", + "canonical_repository": "markitect-main.wiki", + "pinned_commit": "8818df03d3d7be241c144fd839d90c4d7c0ba20a", + "observation": "repository-missing" + } + ], + "interpretation": "All four URLs use the retired forge NodePort. Two pinned commits are reachable in current managed repositories; issue-facade and markitect-main.wiki directories are absent. URL rewriting alone cannot establish complete target coverage. No parent hook exception or repository retirement inferred." +} diff --git a/docs/evidence/2026-10-13-netkingdom-security/forge-runner-heartbeat.json b/docs/evidence/2026-10-13-netkingdom-security/forge-runner-heartbeat.json new file mode 100644 index 00000000..a5b7aa55 --- /dev/null +++ b/docs/evidence/2026-10-13-netkingdom-security/forge-runner-heartbeat.json @@ -0,0 +1,10 @@ +{ + "result": "verified", + "observed_at": "2026-10-13T16:46:44.001648+00:00", + "runner": { + "runner_id": 2, + "last_online": 1791910005, + "online_age_seconds": 1 + }, + "scope": "Fresh Forgejo runner heartbeat after listener restriction; not workflow execution proof." +} diff --git a/docs/evidence/2026-10-13-netkingdom-security/forge-runner-loopback-activation.json b/docs/evidence/2026-10-13-netkingdom-security/forge-runner-loopback-activation.json new file mode 100644 index 00000000..963abf50 --- /dev/null +++ b/docs/evidence/2026-10-13-netkingdom-security/forge-runner-loopback-activation.json @@ -0,0 +1,48 @@ +{ + "proposal_id": "FORGE6-RUNNER-LOOPBACK-001", + "proposal_sha256": "9bd1e9f98a99298c810a47e9dffa0316fe235eff53bcd6cfa0dd6391e094f853", + "at": "2026-10-13T16:42:40.576763+00:00", + "result": "verified", + "mode": "apply", + "before": { + "runner_loopback_ping": true, + "other_pod_ping": "reachable", + "docker_listen_addresses_hex": [ + "00000000000000000000000000000000:0947" + ], + "pod_uid": "37cf553a-c8de-4ef1-ad53-ff5a96c49904" + }, + "jobs_before": { + "tasks_running": 0, + "tasks_waiting": 0, + "jobs_running": 0, + "jobs_waiting": 0 + }, + "node_cpu_allocatable": "4", + "node_cpu_requested": "3460m", + "node_cpu_requested_percent": 86, + "no_surge_or_request_increase": true, + "server_dry_run_verified": true, + "jobs_at_apply": { + "tasks_running": 0, + "tasks_waiting": 0, + "jobs_running": 0, + "jobs_waiting": 0 + }, + "generation": 4, + "after": { + "runner_loopback_ping": true, + "other_pod_ping": "refused", + "docker_listen_addresses_hex": [ + "0100007F:0947" + ], + "pod_uid": "5a0cc0e2-ba02-4f8c-852f-1e96543a174a" + }, + "jobs_after": { + "tasks_running": 0, + "tasks_waiting": 0, + "jobs_running": 0, + "jobs_waiting": 0 + }, + "images_unchanged": true +} diff --git a/docs/evidence/2026-10-13-netkingdom-security/forge-runner-loopback-preflight-r2.json b/docs/evidence/2026-10-13-netkingdom-security/forge-runner-loopback-preflight-r2.json new file mode 100644 index 00000000..3525931f --- /dev/null +++ b/docs/evidence/2026-10-13-netkingdom-security/forge-runner-loopback-preflight-r2.json @@ -0,0 +1,26 @@ +{ + "proposal_id": "FORGE6-RUNNER-LOOPBACK-001", + "proposal_sha256": "9bd1e9f98a99298c810a47e9dffa0316fe235eff53bcd6cfa0dd6391e094f853", + "at": "2026-10-13T16:41:48.617164+00:00", + "result": "dry-run-verified", + "mode": "dry-run", + "before": { + "runner_loopback_ping": true, + "other_pod_ping": "reachable", + "docker_listen_addresses_hex": [ + "00000000000000000000000000000000:0947" + ], + "pod_uid": "37cf553a-c8de-4ef1-ad53-ff5a96c49904" + }, + "jobs_before": { + "tasks_running": 0, + "tasks_waiting": 0, + "jobs_running": 0, + "jobs_waiting": 0 + }, + "node_cpu_allocatable": "4", + "node_cpu_requested": "3460m", + "node_cpu_requested_percent": 86, + "no_surge_or_request_increase": true, + "server_dry_run_verified": true +} diff --git a/docs/evidence/2026-10-13-netkingdom-security/forge-runner-loopback-preflight.json b/docs/evidence/2026-10-13-netkingdom-security/forge-runner-loopback-preflight.json new file mode 100644 index 00000000..9e2cb293 --- /dev/null +++ b/docs/evidence/2026-10-13-netkingdom-security/forge-runner-loopback-preflight.json @@ -0,0 +1,26 @@ +{ + "proposal_id": "FORGE6-RUNNER-LOOPBACK-001", + "proposal_sha256": "a2d29132c639f76bed0465a30119148d207f9242c29a2229a2e90cfc5a6aecac", + "at": "2026-10-13T16:40:44.200829+00:00", + "result": "dry-run-verified", + "mode": "dry-run", + "before": { + "runner_loopback_ping": true, + "other_pod_ping": "reachable", + "docker_listen_addresses_hex": [ + "00000000000000000000000000000000:0947" + ], + "pod_uid": "37cf553a-c8de-4ef1-ad53-ff5a96c49904" + }, + "jobs_before": { + "tasks_running": 0, + "tasks_waiting": 0, + "jobs_running": 0, + "jobs_waiting": 0 + }, + "node_cpu_allocatable": "4", + "node_cpu_requested": "3460m", + "node_cpu_requested_percent": 86, + "no_surge_or_request_increase": true, + "server_dry_run_verified": true +} diff --git a/docs/evidence/2026-10-13-netkingdom-security/forge-runner-post-change-ci.json b/docs/evidence/2026-10-13-netkingdom-security/forge-runner-post-change-ci.json new file mode 100644 index 00000000..091acc69 --- /dev/null +++ b/docs/evidence/2026-10-13-netkingdom-security/forge-runner-post-change-ci.json @@ -0,0 +1,22 @@ +{ + "run_id": 7251, + "status": 1, + "commit_sha": "706461b845086ee2df8ce9acae3dae7feebfffda", + "jobs": [ + { + "id": 12657, + "job_id": "host-smoke", + "status": 1, + "task_id": 11945 + }, + { + "id": 12658, + "job_id": "container-smoke", + "status": 1, + "task_id": 11946 + } + ], + "result": "verified", + "observed_at": "2026-10-13T16:48:22.057414+00:00", + "scope": "Actual existing source workflow after loopback restriction; host and container routing succeed. No secret-detection required-check or full CI security claim." +} diff --git a/docs/netkingdom-security-gaps.md b/docs/netkingdom-security-gaps.md index 9d085931..6388cc35 100644 --- a/docs/netkingdom-security-gaps.md +++ b/docs/netkingdom-security-gaps.md @@ -20,7 +20,7 @@ No whole-estate SEC-DSM conformance or maturity level is claimed. | G09 | Rotation and revocation closure are incomplete: existing exposure rotation is founder-deferred, npm migration remains open, and five-field descriptions do not prove consumer adoption. | CUST-WP-0073-T05; SECRETS-WP-0006-T06; WARDEN-WP-0037-T03; RPF-WP-0035-T07 | For each relevant credential: new consumer accepted, old credential refused, copies reconciled; explicit DSM exception if still deferred | | G10 | Build-mode exceptions and close-down checklist do not uniformly contain DSM-EXC requirement, risk, compensating controls and review fields. | CUST-WP-0080-T03; existing security-close-down checklist and October 11 decisions | Requirement-level exception records with real approval references and revalidation; no invented approval or retroactive conformance | | G11 | Root recovery, independent custody and single-node resilience require scoped native proof. Old successful source preparation is insufficient. | WARDEN-WP-0027-T02; platform recovery records; RPF-WP-0015 residual | Fresh authorized scenario, off-host encrypted backup, independent recovery access, verified restore/return; honor build-mode single-holder decision | -| G12 | CI credential authority, Kubernetes delivery-copy lifecycle and at-rest protection lack complete estate coverage. Forge metadata shows Actions enabled on 165 repositories and zero protected-branch rules; this does not establish workflow execution or required checks. | RPF-WP-0036/0044; railiance-forge; existing CCRs; CUST-WP-0080-T03 | Pipeline writer scope and job identity, per-store namespace/path isolation, refresh/deletion semantics, consumer adoption, independently governed encryption keys | +| G12 | CI credential authority, Kubernetes delivery-copy lifecycle and at-rest protection lack complete estate coverage. The reproduced unauthenticated cross-pod Docker listener was closed on October 13; runner loopback connectivity and heartbeat remain healthy. Forge metadata shows Actions enabled on 165 repositories and zero protected-branch rules; this does not establish workflow execution or required checks. | RPF-WP-0036/0044; railiance-forge; existing CCRs; CUST-WP-0080-T03 | Pipeline writer scope and job identity, per-store namespace/path isolation, refresh/deletion semantics, consumer adoption, independently governed encryption keys | | G13 | PDP authenticity is separate from digest binding. FLEX-WP-0024 implements optional signatures; Secrets Engine still lacks independently anchored verification. Its reproduced redirect/proxy credential forwarding defects are now fixed in source (675 tests pass, one skipped). Native signer/consumer acceptance remains open. | SECRETS-WP-0014 T01 done; T02/T03 own trust admission, signed consumer enforcement and native verification. CUST-WP-0080-T03 tracks estate coverage | Independently trusted key or authenticated channel, configured producer, consumer verification, forged/altered/unknown-key/unsigned-downgrade refusals and key-rotation evidence | G01 source repair is deployed; authenticated preview/U08 remains open. G02 and G03 require existing owner @@ -189,6 +189,21 @@ and private response gates remain open; this is no whole-estate closure. Evidence: Custodian `docs/evidence/2026-10-13-netkingdom-security/forge-expansion-final.json` and `forge-api-route-readiness.json`. +2026-10-13 G12 runner boundary repaired: actual unauthenticated Docker ping +from another pod reproduced the wildcard listener defect. Exact revision 2 of +FORGE6-RUNNER-LOOPBACK-001 deployed source railiance-apps `706461b`, pinning the +same running images and restricting Docker to loopback with local exec readiness. +The conditional server dry-run and three patch tests pass. Generation 4 is Ready; +loopback ping succeeds, other-pod ping refuses and the socket table contains only +127.0.0.1:2375. Zero running/queued jobs were observed before/at apply and after; +resource requests and Recreate strategy are unchanged (node CPU requests 86%). +Fresh runner heartbeat is one second old. The original revision-1 preflight is +retained; revision 2 corrects rollback verification for the observed dual-stack +wildcard. Receipt: `docs/evidence/2026-10-13-netkingdom-security/forge-runner-loopback-activation.json`. +This closes the reproduced listener exposure only. Host jobs still share the +privileged runner boundary; workflow authority, required detection checks and +job-scoped credentials remain open in FORGE-WP-0006-T03 / RMGR-WP-0021-T03/T04. + ## SEC DSM requirement coverage Each numbered requirement in these families is in scope for assessment. The diff --git a/docs/security-close-down-checklist.md b/docs/security-close-down-checklist.md index 956ef19a..18970f1d 100644 --- a/docs/security-close-down-checklist.md +++ b/docs/security-close-down-checklist.md @@ -28,3 +28,4 @@ Add a row whenever you loosen, shortcut or leave unarmed a control. Remove nothi | 19 | Fresh informed-decision readback during a failed T05 rollout | Exact new trial INFD-WP-0009-T05/zlib85091-r1 approved under founder acceptance b5ce337c-5d7d-44fa-864d-706b54039f7c at2026-10-11T10:40:55Z. Image f558/source312ab97 verified Ready and actual canonical lifecycle HTTP200 at11:20:25Z, with latest database preserved. Proposed bounded30minute manual outage recovery was expressly accepted but never invoked; trial completed. Original proposed packet and pre-execution refusals remain immutable. [Outcome](evidence/2026-10-10-security-consolidation/zlib-native-completed/canonical-provider-verification.json). | Completed2026-10-11. Ordinary fresh-authority gate retained; no standing cached authority, autonomy promotion, other-trial or customer-production reuse. Any future standing recovery mechanism needs independently obtainable authority and separate review. | informed-decision, railiance-platform; Custodian tracks | 2026-10-11 | | 20 | Unattended origin publication for assembly-economics checkpoints | rein-aharness approach `assembly-checkpoint` pushes to `coulomb/assembly-economics` origin without an attended session, limited to `checkpoints/`, fast-forward only, rebase-once, never force (mirrors the FI brief grant). Founder-approved 2026-10-12: assembly-economics `docs/decisions/2026-10-12-checkpoint-origin-publication.md`. Runs on legacy profile-absent routing until 2026-12-31. | Publication through rein-aharness's governed publish lane with a per-repo, path-scoped grant and a governed execution profile; legacy route removed. | rein-aharness / assembly-economics | 2026-10-12 | | 21 | Independent repository secret detection and response (CUST-WP-0080 G07) | Scanner active on 22 workstation checkouts and 166 of 167 Forgejo repositories. Exact expansion of 143 targets passed native SSH scanner verdicts and final cleanup audit; one repository with four external submodules remains builtin-only. [Expansion evidence](evidence/2026-10-13-netkingdom-security/forge-expansion-final.json). Template inheritance deployed October 13; its first rollout broke scanner custody permissions and is retained as failed. Recovery adds a pinned startup guard; two Kubernetes replacements (generations 13/14) pass 0755 custody and native clean/secret/quarantine/recovery checks. Thirty isolated configuration/lifecycle/rollback checks pass; authenticated production API/SSO/frontend acceptance remains open. Import/mirror fetch, historical/LFS/log/artifact coverage and private finding response remain open. [Recovery evidence](evidence/2026-10-13-netkingdom-security/forge-custody-recovery-summary.json). | Complete FORGE-WP-0006 and RMGR-WP-0021: admit the full repository denominator, persist/reconcile hooks across creation and lifecycle, verify deployed write paths and unsupported input handling, and prove private detection-to-response with existing custody owners. Rejection alone does not revoke prior exposure; CUST-WP-0073-T05 remains founder-deferred. | railiance-forge, repo-manager; Custodian coordinates | 2026-10-13 | +| 22 | CI runner authority boundary (CUST-WP-0080 G12) | Reproduced unauthenticated cross-pod Docker access is closed: same-image generation 4 listens on loopback only, local ping and heartbeat pass, peer pod denied. [Evidence](evidence/2026-10-13-netkingdom-security/forge-runner-loopback-activation.json). Host-mode jobs still share the privileged runner; no full isolation or job-scoped credential claim. | Complete native workflow authority, required detection checks, job identity/expiry and privileged runner trust boundaries under FORGE-WP-0006 / RMGR-WP-0021. Preserve loopback-only listener across deployments. | railiance-forge, railiance-enablement, railiance-platform; source railiance-apps | 2026-10-13 | diff --git a/workplans/CUST-WP-0080-netkingdom-security-blueprint-and-gap-closure.md b/workplans/CUST-WP-0080-netkingdom-security-blueprint-and-gap-closure.md index f0464921..492c413e 100644 --- a/workplans/CUST-WP-0080-netkingdom-security-blueprint-and-gap-closure.md +++ b/workplans/CUST-WP-0080-netkingdom-security-blueprint-and-gap-closure.md @@ -324,6 +324,23 @@ and session-revocation receipts remain required. Packet: Custodian `docs/attended-window/2026-10-13-forge-route-acceptance.md`; evidence: `docs/evidence/2026-10-13-netkingdom-security/forge-routes-preparation.json`. +2026-10-13 G12 runner boundary repaired: actual unauthenticated Docker ping +from another pod reproduced the wildcard listener defect. Exact revision 2 of +FORGE6-RUNNER-LOOPBACK-001 deployed source railiance-apps `706461b`, pinning the +same running images and restricting Docker to loopback with local exec readiness. +The conditional server dry-run and three patch tests pass. Generation 4 is Ready; +loopback ping succeeds, other-pod ping refuses and the socket table contains only +127.0.0.1:2375. Zero running/queued jobs were observed before/at apply and after; +resource requests and Recreate strategy are unchanged (node CPU requests 86%). +Fresh runner heartbeat is one second old. Normal source push `706461b` triggered +workflow 7251; host-smoke and container-smoke both succeeded. Receipt: +`docs/evidence/2026-10-13-netkingdom-security/forge-runner-post-change-ci.json`. The original revision-1 preflight is +retained; revision 2 corrects rollback verification for the observed dual-stack +wildcard. Receipt: `docs/evidence/2026-10-13-netkingdom-security/forge-runner-loopback-activation.json`. +This closes the reproduced listener exposure only. Host jobs still share the +privileged runner boundary; workflow authority, required detection checks and +job-scoped credentials remain open in FORGE-WP-0006-T03 / RMGR-WP-0021-T03/T04. + ## Verify native closure against every requirement ```task