security: recover Forgejo scanner custody and prove restart acceptance
This commit is contained in:
parent
e570433060
commit
8f770e1eea
39 changed files with 1984 additions and 12 deletions
320
docs/changes/CUST-WP-0080/activate_forge_template.py
Normal file
320
docs/changes/CUST-WP-0080/activate_forge_template.py
Normal file
|
|
@ -0,0 +1,320 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Exact, additive Forgejo template deployment with value-free receipts.
|
||||
|
||||
Default mode is read-only observation plus Kubernetes server dry-runs. No Secret
|
||||
objects or Helm values are requested. Reuses existing immutable scanner artifacts.
|
||||
"""
|
||||
import argparse
|
||||
import copy
|
||||
import datetime
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
HERE = Path(__file__).parent
|
||||
NAME = 'forgejo-secret-template-b1d7cf8cd13f'
|
||||
VOLUME = 'secret-scan-template'
|
||||
SETTING = 'GITEA__GIT_0X2E_CONFIG__INIT_0X2E_TEMPLATEDIR'
|
||||
TEMPLATE = '/opt/forge-secret-template'
|
||||
DEFAULT = '/usr/share/git-core/templates'
|
||||
MOUNT = {'name': VOLUME, 'mountPath': TEMPLATE + '/hooks/pre-receive.d/10-custodian-secret-scan',
|
||||
'subPath': 'hook', 'readOnly': True}
|
||||
VOL = {'name': VOLUME, 'configMap': {'name': NAME, 'defaultMode': 365}}
|
||||
|
||||
|
||||
def digest(value):
|
||||
return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(',', ':')).encode()).hexdigest()
|
||||
|
||||
|
||||
def module(name, path):
|
||||
spec = importlib.util.spec_from_file_location(name, path)
|
||||
obj = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(obj)
|
||||
return obj
|
||||
|
||||
|
||||
native = module('template_native', HERE / 'verify_receive_native.py')
|
||||
activation = module('template_activation', HERE / 'activate_receive_hooks.py')
|
||||
|
||||
|
||||
def deployment():
|
||||
return json.loads(native.command(['-n', 'forgejo', 'get', 'deployment', 'forgejo-gitea', '-o', 'json']))
|
||||
|
||||
|
||||
def target_pod(d, original):
|
||||
pods = json.loads(native.command(['-n', 'forgejo', 'get', 'pods', '-l', 'app.kubernetes.io/instance=forgejo', '-o', 'json']))['items']
|
||||
candidates = [p for p in pods if not p['metadata'].get('deletionTimestamp') and
|
||||
any(c['name'] == 'gitea' for c in p['spec']['containers']) 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(candidates) != 1:
|
||||
raise ValueError('no-unique-ready-pod')
|
||||
p = candidates[0]
|
||||
image = next(c['image'] for c in p['spec']['containers'] if c['name'] == 'gitea')
|
||||
image_id = next(c['imageID'] for c in p['status']['containerStatuses'] if c['name'] == 'gitea')
|
||||
if image != original['image'] or image_id != original['image_id']:
|
||||
raise ValueError('image-changed')
|
||||
if d['status'].get('observedGeneration', 0) < d['metadata']['generation'] or d['status'].get('availableReplicas') != 1:
|
||||
raise ValueError('deployment-not-ready')
|
||||
return dict(original, pod=p['metadata']['name'], pod_uid=p['metadata']['uid'])
|
||||
|
||||
|
||||
def patch_for(d, rollback=False):
|
||||
"""Build a conditional patch; refuse conflicts and preserve all other fields."""
|
||||
spec = d['spec']['template']['spec']
|
||||
patch = [{'op': 'test', 'path': '/metadata/uid', 'value': d['metadata']['uid']},
|
||||
{'op': 'test', 'path': '/metadata/resourceVersion', 'value': d['metadata']['resourceVersion']}]
|
||||
volumes = spec['volumes']
|
||||
found = [i for i, v in enumerate(volumes) if v['name'] == VOLUME]
|
||||
if rollback:
|
||||
if len(found) != 1 or volumes[found[0]] != VOL:
|
||||
raise ValueError('rollback-volume-changed')
|
||||
patch.append({'op': 'remove', 'path': '/spec/template/spec/volumes/' + str(found[0])})
|
||||
else:
|
||||
if found:
|
||||
raise ValueError('template-volume-preexists')
|
||||
patch.append({'op': 'add', 'path': '/spec/template/spec/volumes/-', 'value': VOL})
|
||||
configured = 0
|
||||
for kind in ['initContainers', 'containers']:
|
||||
for index, c in enumerate(spec[kind]):
|
||||
base = '/spec/template/spec/' + kind + '/' + str(index)
|
||||
mounts = c['volumeMounts']
|
||||
matches = [i for i, v in enumerate(mounts) if v['name'] == VOLUME or v['mountPath'] == MOUNT['mountPath']]
|
||||
if rollback:
|
||||
if len(matches) != 1 or mounts[matches[0]] != MOUNT:
|
||||
raise ValueError('rollback-mount-changed')
|
||||
patch.append({'op': 'remove', 'path': base + '/volumeMounts/' + str(matches[0])})
|
||||
else:
|
||||
if matches:
|
||||
raise ValueError('template-mount-preexists')
|
||||
patch.append({'op': 'add', 'path': base + '/volumeMounts/-', 'value': MOUNT})
|
||||
if kind == 'initContainers' and c['name'] == 'init-app-ini':
|
||||
env = c['env']
|
||||
found = [i for i, e in enumerate(env) if e['name'] == SETTING]
|
||||
if rollback:
|
||||
if len(found) != 1 or env[found[0]] != {'name': SETTING, 'value': TEMPLATE}:
|
||||
raise ValueError('rollback-setting-changed')
|
||||
patch.append({'op': 'replace', 'path': base + '/env/' + str(found[0]),
|
||||
'value': {'name': SETTING, 'value': DEFAULT}})
|
||||
else:
|
||||
if found:
|
||||
raise ValueError('template-setting-preexists')
|
||||
patch.append({'op': 'add', 'path': base + '/env/-', 'value': {'name': SETTING, 'value': TEMPLATE}})
|
||||
configured += 1
|
||||
if configured != 1:
|
||||
raise ValueError('unexpected-configuration-container')
|
||||
return patch
|
||||
|
||||
|
||||
def apply_locally(d, patch):
|
||||
result = copy.deepcopy(d)
|
||||
for op in patch:
|
||||
parts = op['path'].strip('/').split('/')
|
||||
obj = result
|
||||
for part in parts[:-1]:
|
||||
obj = obj[int(part)] if isinstance(obj, list) else obj[part]
|
||||
last = parts[-1]
|
||||
key = int(last) if isinstance(obj, list) and last != '-' else last
|
||||
if op['op'] == 'test':
|
||||
if obj[key] != op['value']:
|
||||
raise ValueError('precondition-changed')
|
||||
elif op['op'] == 'remove':
|
||||
del obj[key]
|
||||
elif op['op'] == 'replace':
|
||||
obj[key] = copy.deepcopy(op['value'])
|
||||
elif op['op'] == 'add':
|
||||
if last == '-':
|
||||
obj.append(copy.deepcopy(op['value']))
|
||||
else:
|
||||
obj[key] = copy.deepcopy(op['value'])
|
||||
return result
|
||||
|
||||
|
||||
def patch_remote(patch, dry_run=False):
|
||||
args = ['-n', 'forgejo', 'patch', 'deployment', 'forgejo-gitea', '--type=json', '--patch-file=/dev/stdin', '-o', 'json']
|
||||
if dry_run:
|
||||
args.append('--dry-run=server')
|
||||
return json.loads(native.command(args, payload=json.dumps(patch).encode()))
|
||||
|
||||
|
||||
def selected_template(pod):
|
||||
raw = native.execute(pod, 'sh', '-c',
|
||||
'set +e; value=$(git config --file /data/home/.gitconfig --get-all init.templateDir 2>/dev/null); code=$?; '
|
||||
'case "$code" in 0) printf "%s" "$value";; 1) printf "ABSENT";; *) exit 2;; esac')
|
||||
value = raw.decode().strip()
|
||||
if value not in ('ABSENT', TEMPLATE, DEFAULT):
|
||||
raise ValueError('unknown-template-selection')
|
||||
return value
|
||||
|
||||
|
||||
def check_artifacts(pod):
|
||||
activation.inspect(native, pod, applied=True)
|
||||
for item in pod['artifacts'][:2]:
|
||||
path = pod['version_directory'] + '/' + item['name']
|
||||
observed = native.execute(pod, 'sha256sum', path).decode().split()[0]
|
||||
if observed != item['sha256']:
|
||||
raise ValueError('installed-artifact-changed')
|
||||
native.execute(pod, 'test', '-x', path)
|
||||
native.execute(pod, 'wget', '-qO', '/dev/null', 'http://127.0.0.1:3000/api/healthz')
|
||||
|
||||
|
||||
def wait_ready(original, selection, timeout=240):
|
||||
end = time.monotonic() + timeout
|
||||
while time.monotonic() < end:
|
||||
try:
|
||||
d = deployment()
|
||||
pod = target_pod(d, original)
|
||||
if d['status'].get('updatedReplicas') != 1 or selected_template(pod) != selection:
|
||||
raise ValueError('replacement-not-ready')
|
||||
check_artifacts(pod)
|
||||
return d, pod
|
||||
except (ValueError, KeyError, subprocess.SubprocessError):
|
||||
time.sleep(3)
|
||||
raise ValueError('rollout-not-verified')
|
||||
|
||||
|
||||
def verify_inheritance(pod, proposal):
|
||||
directory = '/tmp/custodian-template-' + proposal['proposal_id'].lower()
|
||||
created = False
|
||||
try:
|
||||
native.execute(pod, 'sh', '-c', 'umask 077; test ! -e "$1" && mkdir "$1" && printf "%s" "$2" > "$1/.owner"',
|
||||
'sh', directory, proposal['proposal_id'])
|
||||
created = True
|
||||
native.execute(pod, 'sh', '-c', 'test -f "$1" && test ! -L "$1" && test -x "$1"', 'sh', MOUNT['mountPath'])
|
||||
native.execute(pod, 'git', '-c', 'include.path=/data/home/.gitconfig', 'init', '--bare', '-q', directory + '/probe.git')
|
||||
hook = directory + '/probe.git/hooks/pre-receive.d/10-custodian-secret-scan'
|
||||
native.execute(pod, 'sh', '-c', 'test -f "$1" && test ! -L "$1" && test -x "$1"', 'sh', hook)
|
||||
observed = native.execute(pod, 'sha256sum', hook).decode().split()[0]
|
||||
if observed != pod['hook_sha256']:
|
||||
raise ValueError('inherited-hook-differs')
|
||||
raw = native.execute(pod, 'sh', '-c',
|
||||
'cd "$1"; set +e; output=$(printf "malformed\\n" | hooks/pre-receive.d/10-custodian-secret-scan 2>/dev/null); '
|
||||
'code=$?; test "$code" = 2 || exit 1; printf "%s" "$output"', 'sh', directory + '/probe.git')
|
||||
if json.loads(raw)['status'] != 'unverified':
|
||||
raise ValueError('inherited-hook-not-executable')
|
||||
return {'mounted_hook_regular': True, 'inherited_hook_regular_executable': True,
|
||||
'hook_sha256': observed, 'inherited_hook_malformed_input_refused': True,
|
||||
'scope': 'Native Git init with actual Forgejo global configuration in an owned temporary bare repository; not production API repository creation.'}
|
||||
finally:
|
||||
if created:
|
||||
native.execute(pod, 'sh', '-c', 'test "$(cat "$1/.owner")" = "$2" && rm -rf -- "$1" && test ! -e "$1"',
|
||||
'sh', directory, proposal['proposal_id'])
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--proposal', type=Path, required=True)
|
||||
parser.add_argument('--receipt', type=Path, required=True)
|
||||
mode = parser.add_mutually_exclusive_group()
|
||||
mode.add_argument('--apply', action='store_true')
|
||||
mode.add_argument('--rollback', action='store_true')
|
||||
args = parser.parse_args()
|
||||
proposal = json.loads(args.proposal.read_text())
|
||||
if not re.fullmatch(r'[A-Z0-9-]{1,100}', proposal['proposal_id']):
|
||||
raise ValueError('unsafe-proposal-id')
|
||||
for filename, expected in proposal['executor_hashes'].items():
|
||||
if hashlib.sha256((HERE / filename).read_bytes()).hexdigest() != expected:
|
||||
raise ValueError('executor-revision-changed')
|
||||
if not args.rollback:
|
||||
for filename, expected in proposal['owner_source_hashes'].items():
|
||||
if hashlib.sha256(Path(filename).read_bytes()).hexdigest() != expected:
|
||||
raise ValueError('owner-source-revision-changed')
|
||||
isolated = json.loads(Path(proposal['isolated_receipt']).read_text())
|
||||
if isolated.get('result') != 'verified' or not isolated.get('isolated_container_and_volumes_removed'):
|
||||
raise ValueError('isolated-acceptance-incomplete')
|
||||
original = json.loads((HERE / 'receive-hook-proposal.json').read_text())
|
||||
nodes = json.loads(native.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('host-changed')
|
||||
apps = json.loads(native.command(['-n', 'argocd', 'get', 'applications', '-o', 'json']))['items']
|
||||
if any(a['spec'].get('destination', {}).get('namespace') == 'forgejo' for a in apps):
|
||||
raise ValueError('gitops-owner-now-present')
|
||||
d = deployment()
|
||||
if d['metadata']['uid'] != proposal['deployment_uid'] or d['spec']['strategy']['type'] != 'Recreate' or d['spec']['replicas'] != 1:
|
||||
raise ValueError('deployment-target-changed')
|
||||
if not args.rollback and digest(d['spec']['template']) != proposal['original_template_sha256']:
|
||||
raise ValueError('deployment-template-changed')
|
||||
pod = target_pod(d, original)
|
||||
check_artifacts(pod)
|
||||
selection = selected_template(pod)
|
||||
if selection != (TEMPLATE if args.rollback else 'ABSENT'):
|
||||
raise ValueError('template-selection-changed')
|
||||
cm = proposal['configmap']
|
||||
if (cm.get('apiVersion') != 'v1' or cm.get('kind') != 'ConfigMap' or set(cm.get('data', {})) != {'hook'}
|
||||
or cm['metadata']['name'] != NAME or cm['metadata']['namespace'] != 'forgejo' or not cm.get('immutable')):
|
||||
raise ValueError('invalid-configmap-target')
|
||||
if hashlib.sha256(cm['data']['hook'].encode()).hexdigest() != original['hook_sha256']:
|
||||
raise ValueError('template-hook-changed')
|
||||
found = native.command(['-n', 'forgejo', 'get', 'configmap', NAME, '--ignore-not-found', '-o', 'json'])
|
||||
if found.strip():
|
||||
existing = json.loads(found)
|
||||
if existing.get('data') != cm['data'] or existing.get('immutable') is not True:
|
||||
raise ValueError('template-configmap-conflict')
|
||||
elif not args.rollback:
|
||||
native.command(['create', '--dry-run=server', '-f', '-', '-o', 'name'], payload=json.dumps(cm).encode())
|
||||
patch = patch_for(d, rollback=args.rollback)
|
||||
simulated = apply_locally(d, patch)
|
||||
dry = patch_remote(patch, dry_run=True)
|
||||
if dry['spec']['template'] != simulated['spec']['template']:
|
||||
raise ValueError('server-dry-run-unexpected-change')
|
||||
receipt = {'proposal_id': proposal['proposal_id'], 'proposal_sha256': hashlib.sha256(args.proposal.read_bytes()).hexdigest(),
|
||||
'observed_at': datetime.datetime.now(datetime.timezone.utc).isoformat(), 'result': 'dry-run-verified',
|
||||
'old_pod_uid': pod['pod_uid'], 'old_generation': d['metadata']['generation'],
|
||||
'server_dry_run_exact_match': True, 'patch': patch,
|
||||
'original_template_sha256': digest(d['spec']['template']),
|
||||
'resulting_template_sha256': digest(simulated['spec']['template']),
|
||||
'no_secret_objects_read': True, 'mode': 'rollback' if args.rollback else 'apply' if args.apply else 'preflight'}
|
||||
args.receipt.write_text(json.dumps(receipt, indent=2) + '\n')
|
||||
if not args.apply and not args.rollback:
|
||||
print('Exact additive patch and ConfigMap server dry-runs passed; no live mutation.')
|
||||
return
|
||||
submitted = False
|
||||
try:
|
||||
if not found.strip() and not args.rollback:
|
||||
native.command(['create', '-f', '-', '-o', 'name'], payload=json.dumps(cm).encode())
|
||||
receipt['configmap_created'] = True
|
||||
submitted = True
|
||||
receipt['patch_submission_started'] = True
|
||||
changed = patch_remote(patch)
|
||||
receipt['applied_generation'] = changed['metadata']['generation']
|
||||
expected = DEFAULT if args.rollback else TEMPLATE
|
||||
ready, newpod = wait_ready(original, expected)
|
||||
if selected_template(newpod) != expected:
|
||||
raise ValueError('runtime-selection-not-adopted')
|
||||
if not args.rollback:
|
||||
receipt['inheritance'] = verify_inheritance(newpod, proposal)
|
||||
receipt.update(result='verified', new_pod_uid=newpod['pod_uid'], generation=ready['metadata']['generation'],
|
||||
selected_template=expected, existing_22_profiles_preserved=True,
|
||||
binaries_unchanged=True, health_after=True)
|
||||
except (ValueError, KeyError, subprocess.SubprocessError):
|
||||
receipt['result'] = 'unverified'
|
||||
if submitted and not args.rollback:
|
||||
try:
|
||||
current = deployment()
|
||||
observed_digest = digest(current['spec']['template'])
|
||||
if observed_digest == receipt['original_template_sha256']:
|
||||
receipt['rollback'] = 'patch was not applied; original deployment preserved; any created ConfigMap remains inert'
|
||||
elif observed_digest == receipt['resulting_template_sha256']:
|
||||
recovery = patch_for(current, rollback=True)
|
||||
patch_remote(recovery)
|
||||
_, recovered = wait_ready(original, DEFAULT)
|
||||
receipt['rollback'] = 'default template selection restored; original 22 hooks preserved; ConfigMap retained inert'
|
||||
else:
|
||||
raise ValueError('concurrent-template-change-needs-owner-review')
|
||||
except (ValueError, KeyError, subprocess.SubprocessError):
|
||||
receipt['rollback'] = 'unverified; abort owner must inspect exact deployment and receipt'
|
||||
raise
|
||||
finally:
|
||||
args.receipt.write_text(json.dumps(receipt, indent=2) + '\n')
|
||||
print('Template ' + receipt['mode'] + ' verified; existing profiles and service health preserved.')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
main()
|
||||
except (ValueError, OSError, KeyError, subprocess.SubprocessError):
|
||||
raise SystemExit('Template operation refused or incomplete; inspect value-free receipt. Raw diagnostics suppressed.')
|
||||
121
docs/changes/CUST-WP-0080/deploy_forge_custody.py
Normal file
121
docs/changes/CUST-WP-0080/deploy_forge_custody.py
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Conditional startup-guard rollout, with real installed-scanner receive probes."""
|
||||
import argparse
|
||||
import datetime
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import time
|
||||
|
||||
HERE = Path(__file__).parent
|
||||
s = importlib.util.spec_from_file_location('template', HERE / 'activate_forge_template.py')
|
||||
t = importlib.util.module_from_spec(s)
|
||||
s.loader.exec_module(t)
|
||||
|
||||
|
||||
def native_probe(pod, suffix):
|
||||
base = '/tmp/custodian-installed-custody-' + suffix
|
||||
created = False
|
||||
try:
|
||||
t.native.execute(pod, 'sh', '-c', 'umask 077; test ! -e "$1" && mkdir "$1" && printf "%s" custody-probe > "$1/.owner"', 'sh', base)
|
||||
created = True
|
||||
raw = t.native.execute(pod, 'sh', '-s', '--', base, pod['existing_dispatcher'],
|
||||
payload=(HERE / 'probe_forge_installed.sh').read_bytes(), timeout=90)
|
||||
return json.loads(raw)
|
||||
finally:
|
||||
if created:
|
||||
t.native.execute(pod, 'sh', '-c', 'test "$(cat "$1/.owner")" = custody-probe && rm -rf -- "$1" && test ! -e "$1"', 'sh', base)
|
||||
|
||||
|
||||
def patch_for(d, candidate, rollback=False):
|
||||
current = d['spec']['template']['spec']['initContainers']
|
||||
matches = [i for i,c in enumerate(current) if c['name'] == candidate['name']]
|
||||
patch = [{'op':'test', 'path':'/metadata/uid', 'value':d['metadata']['uid']},
|
||||
{'op':'test', 'path':'/metadata/resourceVersion', 'value':d['metadata']['resourceVersion']}]
|
||||
if rollback:
|
||||
if matches != [0] or current[0] != candidate:
|
||||
raise ValueError('rollback-candidate-differs')
|
||||
patch.append({'op':'remove', 'path':'/spec/template/spec/initContainers/0'})
|
||||
else:
|
||||
if matches:
|
||||
raise ValueError('candidate-already-present')
|
||||
# apply_locally's JSON array add support appends only; replace exact guarded list.
|
||||
patch.append({'op':'replace', 'path':'/spec/template/spec/initContainers', 'value':[candidate,*current]})
|
||||
return patch
|
||||
|
||||
|
||||
def verify(pod, suffix):
|
||||
t.check_artifacts(pod)
|
||||
stats = []
|
||||
for item in pod['artifacts'][:2]:
|
||||
stat = t.native.execute(pod, 'stat', '-c', '%a %u %g', pod['version_directory']+'/'+item['name']).decode().strip()
|
||||
if stat != '755 1000 1000':
|
||||
raise ValueError('custody-mode-wrong')
|
||||
stats.append({'name':item['name'], 'stat':stat})
|
||||
return {'artifacts':stats, 'receive_probe':native_probe(pod, suffix)}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--proposal',type=Path,required=True)
|
||||
parser.add_argument('--receipt',type=Path,required=True)
|
||||
group=parser.add_mutually_exclusive_group()
|
||||
group.add_argument('--apply',action='store_true')
|
||||
group.add_argument('--rollback',action='store_true')
|
||||
group.add_argument('--restart-proof',action='store_true')
|
||||
args=parser.parse_args()
|
||||
p=json.loads(args.proposal.read_text())
|
||||
for file,expected in p['executor_hashes'].items():
|
||||
if hashlib.sha256((HERE/file).read_bytes()).hexdigest()!=expected:
|
||||
raise ValueError('executor-changed')
|
||||
original=json.loads((HERE/'receive-hook-proposal.json').read_text())
|
||||
c=json.loads((HERE/'forge-custody-init.json').read_text())
|
||||
d=t.deployment()
|
||||
expected=p['patched_template_digest'] if args.rollback or args.restart_proof else p['original_template_digest']
|
||||
if d['metadata']['uid']!=p['deployment_uid'] or t.digest(d['spec']['template'])!=expected:
|
||||
raise ValueError('deployment-changed')
|
||||
pod=t.target_pod(d,original)
|
||||
if args.restart_proof:
|
||||
patch=[{'op':'test','path':'/metadata/resourceVersion','value':d['metadata']['resourceVersion']},
|
||||
{'op':'add','path':'/spec/template/metadata/annotations/custodian.coulomb.social~1custody-proof','value':p['restart_marker']}]
|
||||
else:
|
||||
patch=patch_for(d,c,args.rollback)
|
||||
local=t.apply_locally(d,patch)
|
||||
dry=t.patch_remote(patch,dry_run=True)
|
||||
if t.digest(local['spec']['template'])!=t.digest(dry['spec']['template']):
|
||||
raise ValueError('dry-run-differs')
|
||||
receipt={'proposal_id':p['proposal_id'],'revision':p['revision'],
|
||||
'proposal_sha256':hashlib.sha256(args.proposal.read_bytes()).hexdigest(),
|
||||
'at':datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
||||
'mode':'rollback' if args.rollback else 'restart-proof' if args.restart_proof else 'apply' if args.apply else 'dry-run',
|
||||
'dry_run_verified':True,'before_pod_uid':pod['pod_uid'],'result':'prepared'}
|
||||
args.receipt.write_text(json.dumps(receipt,indent=2)+'\n')
|
||||
if not (args.apply or args.rollback or args.restart_proof):
|
||||
receipt['before_probe']=verify(pod,'before')
|
||||
receipt['result']='dry-run-verified'
|
||||
else:
|
||||
applied=t.patch_remote(patch)
|
||||
receipt.update(result='rollout-started', generation=applied['metadata']['generation'])
|
||||
args.receipt.write_text(json.dumps(receipt,indent=2)+'\n')
|
||||
after,new=t.wait_ready(original,t.TEMPLATE)
|
||||
if new['pod_uid']==pod['pod_uid'] or t.digest(after['spec']['template'])!=t.digest(local['spec']['template']):
|
||||
raise ValueError('replacement-not-exact')
|
||||
receipt['after_pod_uid']=new['pod_uid']
|
||||
if args.rollback:
|
||||
# Removing the init guard permits fsGroup to widen modes again; repair explicitly.
|
||||
t.native.execute(new,'sh','-s','--','apply',payload=(HERE/'forge-custody-guard.sh').read_bytes())
|
||||
else:
|
||||
raw=json.loads(t.native.command(['-n','forgejo','get','pod',new['pod'],'-o','json']))
|
||||
status=next(x for x in raw['status']['initContainerStatuses'] if x['name']==c['name'])
|
||||
if status['state']['terminated']['exitCode']!=0:
|
||||
raise ValueError('guard-not-completed')
|
||||
receipt['guard_completed']=True
|
||||
receipt['after_probe']=verify(new,'after-'+str(after['metadata']['generation']))
|
||||
receipt['result']='verified'
|
||||
args.receipt.write_text(json.dumps(receipt,indent=2)+'\n')
|
||||
print(json.dumps(receipt))
|
||||
|
||||
|
||||
if __name__=='__main__':
|
||||
main()
|
||||
22
docs/changes/CUST-WP-0080/forge-custody-guard.sh
Normal file
22
docs/changes/CUST-WP-0080/forge-custody-guard.sh
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
#!/bin/sh
|
||||
set +vx
|
||||
set -eu
|
||||
test "$(id -u)" = 1000
|
||||
test -d /data && test ! -L /data
|
||||
test -d /data/security && test ! -L /data/security
|
||||
test -d /data/security/receive-secret-scan && test ! -L /data/security/receive-secret-scan
|
||||
test -d /data/security/receive-secret-scan/20261012-57a0642b6251 && test ! -L /data/security/receive-secret-scan/20261012-57a0642b6251
|
||||
test -f /data/security/receive-secret-scan/20261012-57a0642b6251/receive-secret-scan && test ! -L /data/security/receive-secret-scan/20261012-57a0642b6251/receive-secret-scan
|
||||
test "$(stat -c %u /data/security/receive-secret-scan/20261012-57a0642b6251/receive-secret-scan)" = 1000
|
||||
test "$(sha256sum /data/security/receive-secret-scan/20261012-57a0642b6251/receive-secret-scan | cut -d " " -f 1)" = 57a0642b625105440fe146fceb4d244fd2dd08a75beb316831675643e6cc27ec
|
||||
case "$(stat -c %a /data/security/receive-secret-scan/20261012-57a0642b6251/receive-secret-scan)" in 755|775) ;; *) exit 21;; esac
|
||||
test -f /data/security/receive-secret-scan/20261012-57a0642b6251/gitleaks && test ! -L /data/security/receive-secret-scan/20261012-57a0642b6251/gitleaks
|
||||
test "$(stat -c %u /data/security/receive-secret-scan/20261012-57a0642b6251/gitleaks)" = 1000
|
||||
test "$(sha256sum /data/security/receive-secret-scan/20261012-57a0642b6251/gitleaks | cut -d " " -f 1)" = 88f91962aa2f93ac6ab281d553b9e125f5197bbbce38f9f2437f7299c32e5509
|
||||
case "$(stat -c %a /data/security/receive-secret-scan/20261012-57a0642b6251/gitleaks)" in 755|775) ;; *) exit 21;; esac
|
||||
if [ "${1:-check}" = apply ]; then
|
||||
chmod 755 /data/security/receive-secret-scan/20261012-57a0642b6251/receive-secret-scan
|
||||
test "$(stat -c %a /data/security/receive-secret-scan/20261012-57a0642b6251/receive-secret-scan)" = 755
|
||||
chmod 755 /data/security/receive-secret-scan/20261012-57a0642b6251/gitleaks
|
||||
test "$(stat -c %a /data/security/receive-secret-scan/20261012-57a0642b6251/gitleaks)" = 755
|
||||
fi
|
||||
43
docs/changes/CUST-WP-0080/forge-custody-init.json
Normal file
43
docs/changes/CUST-WP-0080/forge-custody-init.json
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
{
|
||||
"name": "verify-secret-scanner",
|
||||
"image": "code.forgejo.org/forgejo/forgejo@sha256:691657c7e64af7730a2e885e6d022cf16ae60724931c5392bd61838afadcaeba",
|
||||
"imagePullPolicy": "IfNotPresent",
|
||||
"command": [
|
||||
"sh",
|
||||
"-c",
|
||||
"#!/bin/sh\nset +vx\nset -eu\ntest \"$(id -u)\" = 1000\ntest -d /data && test ! -L /data\ntest -d /data/security && test ! -L /data/security\ntest -d /data/security/receive-secret-scan && test ! -L /data/security/receive-secret-scan\ntest -d /data/security/receive-secret-scan/20261012-57a0642b6251 && test ! -L /data/security/receive-secret-scan/20261012-57a0642b6251\ntest -f /data/security/receive-secret-scan/20261012-57a0642b6251/receive-secret-scan && test ! -L /data/security/receive-secret-scan/20261012-57a0642b6251/receive-secret-scan\ntest \"$(stat -c %u /data/security/receive-secret-scan/20261012-57a0642b6251/receive-secret-scan)\" = 1000\ntest \"$(sha256sum /data/security/receive-secret-scan/20261012-57a0642b6251/receive-secret-scan | cut -d \" \" -f 1)\" = 57a0642b625105440fe146fceb4d244fd2dd08a75beb316831675643e6cc27ec\ncase \"$(stat -c %a /data/security/receive-secret-scan/20261012-57a0642b6251/receive-secret-scan)\" in 755|775) ;; *) exit 21;; esac\ntest -f /data/security/receive-secret-scan/20261012-57a0642b6251/gitleaks && test ! -L /data/security/receive-secret-scan/20261012-57a0642b6251/gitleaks\ntest \"$(stat -c %u /data/security/receive-secret-scan/20261012-57a0642b6251/gitleaks)\" = 1000\ntest \"$(sha256sum /data/security/receive-secret-scan/20261012-57a0642b6251/gitleaks | cut -d \" \" -f 1)\" = 88f91962aa2f93ac6ab281d553b9e125f5197bbbce38f9f2437f7299c32e5509\ncase \"$(stat -c %a /data/security/receive-secret-scan/20261012-57a0642b6251/gitleaks)\" in 755|775) ;; *) exit 21;; esac\nif [ \"${1:-check}\" = apply ]; then\n chmod 755 /data/security/receive-secret-scan/20261012-57a0642b6251/receive-secret-scan\n test \"$(stat -c %a /data/security/receive-secret-scan/20261012-57a0642b6251/receive-secret-scan)\" = 755\n chmod 755 /data/security/receive-secret-scan/20261012-57a0642b6251/gitleaks\n test \"$(stat -c %a /data/security/receive-secret-scan/20261012-57a0642b6251/gitleaks)\" = 755\nfi\n",
|
||||
"sh",
|
||||
"apply"
|
||||
],
|
||||
"securityContext": {
|
||||
"runAsUser": 1000,
|
||||
"runAsGroup": 1000,
|
||||
"runAsNonRoot": true,
|
||||
"allowPrivilegeEscalation": false,
|
||||
"readOnlyRootFilesystem": true,
|
||||
"capabilities": {
|
||||
"drop": [
|
||||
"ALL"
|
||||
]
|
||||
}
|
||||
},
|
||||
"resources": {
|
||||
"requests": {
|
||||
"cpu": "10m",
|
||||
"memory": "32Mi"
|
||||
},
|
||||
"limits": {
|
||||
"cpu": "100m",
|
||||
"memory": "64Mi"
|
||||
}
|
||||
},
|
||||
"volumeMounts": [
|
||||
{
|
||||
"name": "data",
|
||||
"mountPath": "/data/security",
|
||||
"subPath": "security"
|
||||
}
|
||||
],
|
||||
"terminationMessagePath": "/dev/termination-log",
|
||||
"terminationMessagePolicy": "File"
|
||||
}
|
||||
19
docs/changes/CUST-WP-0080/forge-custody-repair-proposal.json
Normal file
19
docs/changes/CUST-WP-0080/forge-custody-repair-proposal.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"proposal_id": "FORGE6-TEMPLATE-LIFECYCLE-001",
|
||||
"revision": 2,
|
||||
"original_revision": 1,
|
||||
"original_digest_sha256": "7eddea55359b25c5ad307ed8ccd392dfadfe167d65cbe34b02f1955350bbcb36",
|
||||
"kind": "failed-trial-recovery",
|
||||
"pod_uid": "f818d3f5-5c3d-4a2a-8e78-50e10b10d2ce",
|
||||
"executor_sha256": "1cf0f846fd4967e6a3a8b2a3844a7736a053c34fa3a9c54d16ea7ba3c18765ad",
|
||||
"guard_sha256": "75fdd0722a8749d5038f9e575c547fe39f41f599c40344230dbfdfde489c0f1c",
|
||||
"diff_ref": "forge-custody-guard.sh: fixed two admitted, regular, owned, hash-pinned files mode 0775 to 0755; check mode has no mutations",
|
||||
"authorization_ref": "canon/standards/build-mode-change-authorization_v0.1.md",
|
||||
"rollback": "No content or owner change. Exact inverse chmod 0775 only if restoring the failed pre-repair state is required by abort owner; that deliberately re-blocks pushes. Preferred safe recovery is retain hash-verified 0755 and stop on any artifact mismatch. No hook disabling. Durable restart protection follows separately.",
|
||||
"abort_owner": "railiance-forge; executor custodian-codex",
|
||||
"receipt_path": "docs/evidence/2026-10-13-netkingdom-security/forge-custody-repair.json",
|
||||
"expected_result": "Both exact artifacts 0755, then ordinary SSH source push succeeds; restart durability not yet claimed.",
|
||||
"cost_estimate_eur": 0,
|
||||
"risk_estimate": "Only removes group write from two existing executable files. No rollout, content, account, policy scope, or spend change. Any mismatch aborts before chmod. Concurrent artifact replacement not expected during this single-agent repair.",
|
||||
"promotion_evidence": false
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
{
|
||||
"proposal_id": "FORGE6-TEMPLATE-LIFECYCLE-001",
|
||||
"revision": 4,
|
||||
"original_revision": 1,
|
||||
"original_digest_sha256": "7eddea55359b25c5ad307ed8ccd392dfadfe167d65cbe34b02f1955350bbcb36",
|
||||
"deployment_uid": "e8eff0aa-64bb-4b44-9a80-a66612986355",
|
||||
"original_template_digest": "b10384c9564531bff2ca756854b998fdc520f0a25f6acf195f024f675fc3b3e6",
|
||||
"executor_hashes": {
|
||||
"deploy_forge_custody.py": "5125b29ac20ab2b0a9bfcf686c2bb2e6e9c143f716bf119035960b345db497b9",
|
||||
"probe_forge_installed.sh": "66b571a33822cbd58a4956aef3b2ac484d07ab887c296cd2c9ece354117e9440",
|
||||
"forge-custody-init.json": "cccb9999785fe0151a973d9a8b554975daf17c91432ed3e8735e432f3cd80e8f",
|
||||
"forge-custody-guard.sh": "75fdd0722a8749d5038f9e575c547fe39f41f599c40344230dbfdfde489c0f1c",
|
||||
"activate_forge_template.py": "ef2b38f655d5520d69dcd69cdb4997eaad4b50773f32c1d798d16331b204c789",
|
||||
"verify_receive_native.py": "c9120122c6f6f0b7c41fe4fa975e5a2a340e776885d852b861b8c13b7664adcd",
|
||||
"activate_receive_hooks.py": "9b01ee383e16463981112226e19156b0de59af2d03dd71b9403e144e280a9fb3",
|
||||
"restart_forge_custody.py": "104ed6ce5f88739f64b6f6f62e2869252408a390b6780117a499dcc9be0496f2"
|
||||
},
|
||||
"authorization_ref": "canon/standards/build-mode-change-authorization_v0.1.md",
|
||||
"diff_ref": "Only add owned custody-proof pod-template annotation; no config or image change.",
|
||||
"rollback": "Marker may remain inert. To undo guard, conditionally remove only custody-proof annotation matching this marker, wait for replacement, then use revision3 --rollback. No workload spec reversion from a stale snapshot.",
|
||||
"abort_owner": "railiance-forge; executor custodian-codex; source railiance-apps",
|
||||
"receipt_path": "docs/evidence/2026-10-13-netkingdom-security/forge-custody-restart.json",
|
||||
"expected_result": "New pod runs verified guard to completion; same exact image/artifacts and 22 hooks; modes755; inherited native receive clean, secret refusal, quarantine and recovery pass. Repeat with another replacement pod.",
|
||||
"restart_marker": "FORGE6-TEMPLATE-LIFECYCLE-001-r4",
|
||||
"cost_estimate_eur": 0,
|
||||
"risk_estimate": "Recreate briefly interrupts forge. Existing init maximum100m/128Mi exceeds new10m/32Mi so no increased scheduling reservation; node86percent requests. Bound240s readiness. Mismatched artifacts deliberately block startup; rollback restores prior deployment plus verified755 repair. Mount only existing security subtree, no credentials/RBAC/policy/image changes. No spend.",
|
||||
"promotion_evidence": false,
|
||||
"preparation_refinement": {
|
||||
"previous_revision": 3,
|
||||
"failure": "Second restart dry-run refused because original local patch helper did not decode JSON Pointer ~1. No second restart occurred. New executor replaces the annotations map while preserving existing fields; server equality remains required."
|
||||
},
|
||||
"annotation_key": "custodian.coulomb.social/custody-proof"
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
{
|
||||
"proposal_id": "FORGE6-TEMPLATE-LIFECYCLE-001",
|
||||
"revision": 3,
|
||||
"original_revision": 1,
|
||||
"original_digest_sha256": "7eddea55359b25c5ad307ed8ccd392dfadfe167d65cbe34b02f1955350bbcb36",
|
||||
"deployment_uid": "e8eff0aa-64bb-4b44-9a80-a66612986355",
|
||||
"original_template_digest": "73f6c1ef484c60f93285d61e6df6651b4f1268fafb3e470f35f450bce4f34aba",
|
||||
"patched_template_digest": "b27e5658ee3d6bcc1933c5d0f0a0acd88c16c54865096c0aae73d689e1066242",
|
||||
"executor_hashes": {
|
||||
"deploy_forge_custody.py": "5125b29ac20ab2b0a9bfcf686c2bb2e6e9c143f716bf119035960b345db497b9",
|
||||
"probe_forge_installed.sh": "66b571a33822cbd58a4956aef3b2ac484d07ab887c296cd2c9ece354117e9440",
|
||||
"forge-custody-init.json": "833c2a5428999a8f2d0cf8319ca69bf1bf8c8017809bfe73450cdf9d31d5a8d4",
|
||||
"forge-custody-guard.sh": "75fdd0722a8749d5038f9e575c547fe39f41f599c40344230dbfdfde489c0f1c",
|
||||
"activate_forge_template.py": "ef2b38f655d5520d69dcd69cdb4997eaad4b50773f32c1d798d16331b204c789",
|
||||
"verify_receive_native.py": "c9120122c6f6f0b7c41fe4fa975e5a2a340e776885d852b861b8c13b7664adcd",
|
||||
"activate_receive_hooks.py": "9b01ee383e16463981112226e19156b0de59af2d03dd71b9403e144e280a9fb3"
|
||||
},
|
||||
"authorization_ref": "canon/standards/build-mode-change-authorization_v0.1.md",
|
||||
"diff_ref": "forge-custody-init.json plus owner helm/forgejo-values.yaml; additive first init container, no other changes",
|
||||
"rollback": "Same executor --rollback under exact patched-template digest; removes only this init container, waits for Ready then verifies hashes and restores 0755 via the exact guard. After restart-proof remove only owned custody-proof annotation before rollback. Reconcile source by removing preExtraInitContainers before future Helm apply. If rollout cannot become Ready, abort owner may remove exact added init container after inspecting unchanged template; no hook bypass.",
|
||||
"abort_owner": "railiance-forge; executor custodian-codex; source railiance-apps",
|
||||
"receipt_path": "docs/evidence/2026-10-13-netkingdom-security/forge-custody-rollout.json",
|
||||
"expected_result": "New pod runs verified guard to completion; same exact image/artifacts and 22 hooks; modes755; inherited native receive clean, secret refusal, quarantine and recovery pass. Repeat with another replacement pod.",
|
||||
"restart_marker": "FORGE6-TEMPLATE-LIFECYCLE-001-r3",
|
||||
"cost_estimate_eur": 0,
|
||||
"risk_estimate": "Recreate briefly interrupts forge. Existing init maximum100m/128Mi exceeds new10m/32Mi so no increased scheduling reservation; node86percent requests. Bound240s readiness. Mismatched artifacts deliberately block startup; rollback restores prior deployment plus verified755 repair. Mount only existing security subtree, no credentials/RBAC/policy/image changes. No spend.",
|
||||
"promotion_evidence": false
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
{
|
||||
"proposal_id": "FORGE6-TEMPLATE-LIFECYCLE-001",
|
||||
"revision": 3,
|
||||
"original_revision": 1,
|
||||
"original_digest_sha256": "7eddea55359b25c5ad307ed8ccd392dfadfe167d65cbe34b02f1955350bbcb36",
|
||||
"deployment_uid": "e8eff0aa-64bb-4b44-9a80-a66612986355",
|
||||
"original_template_digest": "73f6c1ef484c60f93285d61e6df6651b4f1268fafb3e470f35f450bce4f34aba",
|
||||
"patched_template_digest": "b10384c9564531bff2ca756854b998fdc520f0a25f6acf195f024f675fc3b3e6",
|
||||
"executor_hashes": {
|
||||
"deploy_forge_custody.py": "5125b29ac20ab2b0a9bfcf686c2bb2e6e9c143f716bf119035960b345db497b9",
|
||||
"probe_forge_installed.sh": "66b571a33822cbd58a4956aef3b2ac484d07ab887c296cd2c9ece354117e9440",
|
||||
"forge-custody-init.json": "cccb9999785fe0151a973d9a8b554975daf17c91432ed3e8735e432f3cd80e8f",
|
||||
"forge-custody-guard.sh": "75fdd0722a8749d5038f9e575c547fe39f41f599c40344230dbfdfde489c0f1c",
|
||||
"activate_forge_template.py": "ef2b38f655d5520d69dcd69cdb4997eaad4b50773f32c1d798d16331b204c789",
|
||||
"verify_receive_native.py": "c9120122c6f6f0b7c41fe4fa975e5a2a340e776885d852b861b8c13b7664adcd",
|
||||
"activate_receive_hooks.py": "9b01ee383e16463981112226e19156b0de59af2d03dd71b9403e144e280a9fb3"
|
||||
},
|
||||
"authorization_ref": "canon/standards/build-mode-change-authorization_v0.1.md",
|
||||
"diff_ref": "forge-custody-init.json plus owner helm/forgejo-values.yaml; additive first init container, no other changes",
|
||||
"rollback": "Same executor --rollback under exact patched-template digest; removes only this init container, waits for Ready then verifies hashes and restores 0755 via the exact guard. After restart-proof remove only owned custody-proof annotation before rollback. Reconcile source by removing preExtraInitContainers before future Helm apply. If rollout cannot become Ready, abort owner may remove exact added init container after inspecting unchanged template; no hook bypass.",
|
||||
"abort_owner": "railiance-forge; executor custodian-codex; source railiance-apps",
|
||||
"receipt_path": "docs/evidence/2026-10-13-netkingdom-security/forge-custody-rollout.json",
|
||||
"expected_result": "New pod runs verified guard to completion; same exact image/artifacts and 22 hooks; modes755; inherited native receive clean, secret refusal, quarantine and recovery pass. Repeat with another replacement pod.",
|
||||
"restart_marker": "FORGE6-TEMPLATE-LIFECYCLE-001-r3",
|
||||
"cost_estimate_eur": 0,
|
||||
"risk_estimate": "Recreate briefly interrupts forge. Existing init maximum100m/128Mi exceeds new10m/32Mi so no increased scheduling reservation; node86percent requests. Bound240s readiness. Mismatched artifacts deliberately block startup; rollback restores prior deployment plus verified755 repair. Mount only existing security subtree, no credentials/RBAC/policy/image changes. No spend.",
|
||||
"promotion_evidence": false,
|
||||
"preparation_refinement": {
|
||||
"original_candidate_ref": "forge-custody-rollout-pre-defaults.json",
|
||||
"reason": "Server dry-run defaulted terminationMessagePath/Policy; candidate now declares exact defaults. No live change occurred."
|
||||
}
|
||||
}
|
||||
145
docs/changes/CUST-WP-0080/forge-template-operations.md
Normal file
145
docs/changes/CUST-WP-0080/forge-template-operations.md
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
# Forgejo template inheritance
|
||||
|
||||
FORGE-WP-0006-T02, coordinated by CUST-WP-0080 G07. Proposal
|
||||
`forge-template-proposal.json` records the exact source, executor, target,
|
||||
verification and recovery for trial `FORGE6-TEMPLATE-LIFECYCLE-001`.
|
||||
Standing build-mode blue-lane authorization applies; this is not an autopilot
|
||||
grant or a claim of independent owner approval.
|
||||
|
||||
The October 13 execution reached Deployment generation 12 Ready, preserving the
|
||||
image and all 22 previously installed profiles. The live Forgejo global Git
|
||||
configuration selects `/opt/forge-secret-template`. Native Git initialization
|
||||
using that configuration inherited the exact regular, executable scanner hook;
|
||||
malformed input was refused and the owned temporary repository was removed.
|
||||
The execution receipt is
|
||||
`docs/evidence/2026-10-13-netkingdom-security/forge-template-activation.json`.
|
||||
|
||||
## Durable source
|
||||
|
||||
railiance-apps commit `390a30a` owns the immutable non-secret ConfigMap manifest,
|
||||
Helm volume/mount/encoded environment setting, and the Make prerequisite that
|
||||
installs the ConfigMap before a future Helm deployment. A file `subPath` mount
|
||||
is intentional: Git must copy a regular executable, not a ConfigMap projection
|
||||
symlink. The hook still invokes the two previously admitted binaries on the
|
||||
existing data PVC. Backup/restore must preserve or re-admit those exact binaries;
|
||||
the template alone cannot recreate them. Missing scanner artifacts refuse
|
||||
pushes, rather than disabling detection.
|
||||
|
||||
The live change used a conditional JSON Patch because the running Deployment
|
||||
contains changes newer than its last Helm revision. Reapplying that old Helm
|
||||
release was outside scope. The patch added one volume, four file mounts and one
|
||||
configuration environment variable; resourceVersion/UID tests guard the write.
|
||||
Images, replica/strategy/resource settings, identities, existing environment
|
||||
entries and Secret objects were unchanged. Initial-only admin bootstrap and
|
||||
the existing admin row were verified before the restart. Configuration conversion
|
||||
uses the existing chart's environment-to-ini process.
|
||||
|
||||
## Recovery
|
||||
|
||||
Do not replay the apply operation after activation. It refuses the changed
|
||||
Deployment template. To recover this exact change, after checking the current
|
||||
target and the source record:
|
||||
|
||||
```sh
|
||||
python3 docs/changes/CUST-WP-0080/activate_forge_template.py \
|
||||
--proposal docs/changes/CUST-WP-0080/forge-template-proposal.json \
|
||||
--receipt /tmp/forge-template-rollback.json --rollback
|
||||
```
|
||||
|
||||
Rollback removes only the exact added mounts/volume and sets the added environment
|
||||
variable to `/usr/share/git-core/templates`. Merely removing the environment
|
||||
variable would leave the saved application/global-Git setting behind. The
|
||||
ConfigMap remains inert; original and already inherited repository hooks remain.
|
||||
Readiness, health, exact original 22 profiles, binary custody and default template
|
||||
selection must pass again. Reconcile the owner Helm source to that explicit default
|
||||
before another deployment; the original source otherwise intentionally re-enables
|
||||
the desired control. Unknown concurrent changes cause refusal and require the
|
||||
railiance-forge abort owner to review the current target, not overwrite it.
|
||||
|
||||
Automatic recovery after an incomplete apply is limited to a Deployment whose
|
||||
template matches the exact attempted result. An uncertain submission checks
|
||||
current state before deciding whether recovery is needed. The readiness budget
|
||||
is 240 seconds per rollout/recovery; failure remains unverified in the receipt.
|
||||
|
||||
The original October 12 receive-hook activation retains its old pod binding.
|
||||
Do not edit that old proposal to pretend it targets the replacement pod.
|
||||
|
||||
## Remaining acceptance
|
||||
|
||||
This closes configuration persistence and native Git inheritance for future
|
||||
creation paths that use Forgejo's configured Git initialization. It does not prove
|
||||
an authenticated production API/browser creation, SSO journey or imported/mirrored
|
||||
content check. Imports and mirrors use clone/fetch; a receive hook is insufficient
|
||||
for those paths. The refreshed inventory has 166 repositories: 22 installed
|
||||
profiles and 144 builtin-only inventories. Existing repository expansion, CI
|
||||
authority, private finding-to-response, historical/LFS/log/artifact coverage and
|
||||
the applicable DSM requirements remain open in the existing owner plans.
|
||||
|
||||
Validation: 30 isolated exact-image lifecycle/configuration/rollback checks, six
|
||||
conditional-patch/recovery tests, non-secret Helm rendering and matching server
|
||||
dry-run. Local test containers were removed. The isolated environment used SQLite;
|
||||
the deployed proof is scoped to configuration, restart, Git inheritance and the
|
||||
preserved existing profiles.
|
||||
|
||||
## October 13 custody regression and recovery
|
||||
|
||||
The original generation-12 receipt proves only its listed checks. Ordinary source
|
||||
push acceptance subsequently failed: Kubernetes volume processing left the
|
||||
scanner binaries group-writable (0775), which the scanner correctly refuses.
|
||||
The trial remains failed; later corrections are recovery revisions of the same
|
||||
proposal, not new unchanged successes. Do not use the original malformed-input
|
||||
check as evidence that the scanner can open and scan its admitted executable.
|
||||
|
||||
- Revision 2: `forge-custody-repair-proposal.json` and
|
||||
`repair_forge_custody.py` restore only the two hash-pinned files to 0755.
|
||||
- Revision 3: `forge-custody-rollout-proposal.json` and
|
||||
`deploy_forge_custody.py` add the unprivileged first init container. The exact
|
||||
Helm source is railiance-apps `50b66c4`; only the existing security subtree is
|
||||
mounted. Kubernetes defaults are declared explicitly. The first startup and
|
||||
real installed-scanner receive probe passed.
|
||||
- Revision 4: `forge-custody-restart-proposal.json` and
|
||||
`restart_forge_custody.py` repeat acceptance on another pod. Use its receipt to
|
||||
determine completion. The revision-3 `--restart-proof` preparation is withdrawn:
|
||||
its local JSON Pointer helper did not decode the annotation's slash. Its dry-run
|
||||
refused before mutation. Original executed files and hashes remain preserved.
|
||||
|
||||
The guard checks all artifact hashes and owners before changing either mode. It
|
||||
refuses unexpected permissions, symlinks and mismatched binaries. Missing or
|
||||
changed artifacts stop startup for investigation; never disable scanning to make
|
||||
that check pass. The six isolated custody checks simulate fsGroup mode changes.
|
||||
Native probes use temporary repositories and the real inherited installed hook;
|
||||
production builtin authorization is excluded there. A normal source sync push is
|
||||
required separately for authenticated SSH acceptance.
|
||||
|
||||
For rollback after revision 4, first conditionally remove only the owned
|
||||
`custodian.coulomb.social/custody-proof` annotation whose value is the revision-4
|
||||
marker. Review the resulting template against revision 3's patched digest; wait
|
||||
for that replacement to finish. Then revision 3 `--rollback` removes only the
|
||||
exact guard, waits for readiness, and repairs the exact binaries to 0755 after
|
||||
remount. The next unguarded restart may widen modes again, so rollback is temporary
|
||||
recovery and does not establish durable acceptance. Reconcile the owner Helm
|
||||
source before its next deployment. Any unexpected deployment or artifact change
|
||||
stops this procedure for the named abort owner. Original template rollback is a
|
||||
separate operation and must not silently remove the custody repair.
|
||||
|
||||
### Exact original proposal storage
|
||||
|
||||
`forge-template-proposal.json` and `forge-template-source-render.json` are now
|
||||
lossless archival envelopes. A public ConfigMap source checksum under a filename
|
||||
containing `secret` triggered the generic API-key detector. The archive keeps
|
||||
paths and SHA-256 values in separate fields and retains all original metadata.
|
||||
No detector ignore, exception or rule change was added. The executed original
|
||||
proposal digest remains unchanged; it is the digest of the restored bytes, not
|
||||
of the envelope. `forge-custody-metadata-triage.json` records this classification.
|
||||
|
||||
Restore the historical proposal to a new private file before using its original
|
||||
executor (which remains byte-for-byte unchanged):
|
||||
|
||||
```sh
|
||||
python3 docs/changes/CUST-WP-0080/restore_forge_proposal.py \
|
||||
docs/changes/CUST-WP-0080/forge-template-proposal.json \
|
||||
/tmp/forge-template-original.json
|
||||
```
|
||||
|
||||
The helper refuses an existing destination or any reconstruction whose digest
|
||||
differs. Restoration does not authorize replaying a stale live proposal.
|
||||
63
docs/changes/CUST-WP-0080/forge-template-proposal.json
Normal file
63
docs/changes/CUST-WP-0080/forge-template-proposal.json
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
{
|
||||
"archival_format": "json-checksum-map-entries-v1",
|
||||
"original_sha256": "7eddea55359b25c5ad307ed8ccd392dfadfe167d65cbe34b02f1955350bbcb36",
|
||||
"converted_map_field": "owner_source_hashes",
|
||||
"reason": "Public non-secret ConfigMap source checksum was classified as generic-api-key when its filename was used as a JSON property name. Structured path/sha256 entries preserve exact historical reconstruction without detector exceptions.",
|
||||
"document": {
|
||||
"proposal_id": "FORGE6-TEMPLATE-LIFECYCLE-001",
|
||||
"revision": 1,
|
||||
"prepared_at": "2026-10-13T11:56:51.534869+00:00",
|
||||
"deployment_uid": "e8eff0aa-64bb-4b44-9a80-a66612986355",
|
||||
"original_template_sha256": "18855f7b4c78f79117884684e44550406c72fc7be08c0b8e61ec16cfcb855746",
|
||||
"original_generation": 11,
|
||||
"executor_hashes": {
|
||||
"activate_forge_template.py": "ef2b38f655d5520d69dcd69cdb4997eaad4b50773f32c1d798d16331b204c789",
|
||||
"verify_receive_native.py": "c9120122c6f6f0b7c41fe4fa975e5a2a340e776885d852b861b8c13b7664adcd",
|
||||
"activate_receive_hooks.py": "9b01ee383e16463981112226e19156b0de59af2d03dd71b9403e144e280a9fb3",
|
||||
"receive-hook-proposal.json": "9c8ea3b7fbe16a997359432af7bcf09f429881fd23cc567188c21129e80ccf8d"
|
||||
},
|
||||
"owner_source_hashes": [
|
||||
{
|
||||
"path": "/home/worsch/railiance-apps/helm/forgejo-values.yaml",
|
||||
"sha256": "e97aa62fe767f4dc55f2c68c7ae1b69e61e632826a8ba3ccb4ffaca6e302174c"
|
||||
},
|
||||
{
|
||||
"path": "/home/worsch/railiance-apps/Makefile",
|
||||
"sha256": "6bd20555631eb4c22e0729779d6c24d6888a4e6d555ab1f03083697016d42b15"
|
||||
},
|
||||
{
|
||||
"path": "/home/worsch/railiance-apps/manifests/forgejo-secret-scan-template.yaml",
|
||||
"sha256": "8fad71aff5a25d8e31289072b9c06c6ec74ac74fedafc54dde7177bfe187c96a"
|
||||
}
|
||||
],
|
||||
"configmap": {
|
||||
"apiVersion": "v1",
|
||||
"kind": "ConfigMap",
|
||||
"metadata": {
|
||||
"name": "forgejo-secret-template-b1d7cf8cd13f",
|
||||
"namespace": "forgejo",
|
||||
"labels": {
|
||||
"app.kubernetes.io/part-of": "forgejo",
|
||||
"custodian.coulomb.social/workplan": "FORGE-WP-0006"
|
||||
}
|
||||
},
|
||||
"immutable": true,
|
||||
"data": {
|
||||
"hook": "#!/bin/sh\nset +vx\nset -eu\nexec /data/security/receive-secret-scan/20261012-57a0642b6251/receive-secret-scan --scanner /data/security/receive-secret-scan/20261012-57a0642b6251/gitleaks\n"
|
||||
}
|
||||
},
|
||||
"isolated_receipt": "docs/evidence/2026-10-13-netkingdom-security/forge-template-isolated.json",
|
||||
"lane": "blue",
|
||||
"authorization_ref": "canon/standards/build-mode-change-authorization_v0.1.md",
|
||||
"diff_ref": "owner source patch; docs/evidence/2026-10-13-netkingdom-security/forge-template-source-render.json; exact additive JSON Patch in server dry-run receipt",
|
||||
"rollback": "Same executor --rollback with original proposal. Removes only exact added mounts and volume, replaces added init setting with /usr/share/git-core/templates, waits for healthy replacement and verifies original 22 hooks/binaries. Keeps immutable ConfigMap inert and any already inherited repository hooks. If rollback used, reconcile owner source to explicit default selection before another Helm deploy. Automatic recovery only when current deployment template exactly matches the attempted change; unknown concurrent changes require abort-owner review.",
|
||||
"abort_owner": "railiance-forge; executing custodian-codex; release source railiance-apps",
|
||||
"receipt_path": "docs/evidence/2026-10-13-netkingdom-security/forge-template-activation.json",
|
||||
"target": "railiance01 namespace forgejo: Deployment forgejo-gitea and immutable ConfigMap forgejo-secret-template-b1d7cf8cd13f",
|
||||
"expected_result": "Same image and artifact hashes, Recreate replacement Ready, template selected in actual Forgejo global Git configuration, regular/executable inherited hook in temporary native Git repository, existing 22 hook inventories unchanged, health passing. No new production repository, identity, credential or publication.",
|
||||
"cost_estimate_eur": 0,
|
||||
"risk_estimate": "No new resource requests, spend commitments, RBAC or credential scope. Existing Recreate deployment incurs a brief forge interruption; abort after 240-second readiness budget, restore default template under conditional rollback with its own 240-second verification budget. Unknown state stops automatic recovery. New repository writes can refuse if scanner missing, overloaded or input unsupported; existing 22 profiles unchanged. Recorded node CPU requests 3460m/4000m and memory 9472Mi (59%).",
|
||||
"verification_limit": "Native config and Git inheritance, not authenticated production HTTP/SSO/frontend repository creation. Initial 22 repositories remain sole existing-repository activation scope; other 143, imported/mirrored history, CI and private response remain open.",
|
||||
"promotion_evidence": false
|
||||
}
|
||||
}
|
||||
43
docs/changes/CUST-WP-0080/probe_forge_installed.sh
Normal file
43
docs/changes/CUST-WP-0080/probe_forge_installed.sh
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
#!/bin/sh
|
||||
# Caller owns a unique temporary directory and removes it by marker afterwards.
|
||||
set +vx
|
||||
set -eu
|
||||
base=$1
|
||||
dispatcher=$2
|
||||
cd "$base"
|
||||
umask 077
|
||||
export HOME="$base" GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL=/dev/null GIT_TERMINAL_PROMPT=0
|
||||
git -c include.path=/data/home/.gitconfig init -q --bare server.git
|
||||
git init -q -b main client
|
||||
git -C client config core.hooksPath "$base/no-hooks"
|
||||
git -C client config user.name 'Custodian isolated receive probe'
|
||||
git -C client config user.email 'probe@example.test'
|
||||
test -x server.git/hooks/pre-receive.d/10-custodian-secret-scan
|
||||
cp "$dispatcher" server.git/hooks/pre-receive
|
||||
chmod 755 server.git/hooks/pre-receive
|
||||
# No production database identity: native dispatcher with only the inherited scan hook.
|
||||
printf 'clean baseline\n' > client/source.txt
|
||||
git -C client add source.txt
|
||||
git -C client commit -qm 'isolated clean baseline'
|
||||
git -C client push "$base/server.git" HEAD:refs/heads/main > clean.out 2>&1
|
||||
grep -q '"status":"clean"' clean.out
|
||||
initial=$(git -C client rev-parse HEAD)
|
||||
ssh-keygen -q -t ed25519 -N '' -f "$base/synthetic-key" >/dev/null 2>&1
|
||||
cp synthetic-key client/fixture.txt
|
||||
git -C client add fixture.txt
|
||||
git -C client commit -qm 'isolated synthetic fixture'
|
||||
rejected=$(git -C client rev-parse HEAD)
|
||||
git -C client rm -q fixture.txt
|
||||
git -C client commit -qm 'remove fixture from final tree'
|
||||
if git -C client push "$base/server.git" HEAD:refs/heads/main > blocked.out 2>&1; then exit 22; fi
|
||||
grep -q '"status":"blocked"' blocked.out
|
||||
if grep -q 'BEGIN OPENSSH PRIVATE KEY' blocked.out; then exit 23; fi
|
||||
if git -C server.git cat-file -e "$rejected" >/dev/null 2>&1; then exit 24; fi
|
||||
test "$(git -C server.git rev-parse refs/heads/main)" = "$initial"
|
||||
git -C client checkout -qb recovery "$initial"
|
||||
printf 'clean recovery\n' > client/source.txt
|
||||
git -C client add source.txt
|
||||
git -C client commit -qm 'isolated clean recovery'
|
||||
git -C client push "$base/server.git" HEAD:refs/heads/main > recovered.out 2>&1
|
||||
grep -q '"status":"clean"' recovered.out
|
||||
printf '{"inherited_installed_hook":true,"clean_push":true,"intermediate_secret_refused":true,"quarantine_discarded":true,"clean_recovery":true,"builtin_authorization_excluded":true}\n'
|
||||
68
docs/changes/CUST-WP-0080/repair_forge_custody.py
Normal file
68
docs/changes/CUST-WP-0080/repair_forge_custody.py
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Guarded repair of the two admitted scanner modes; capture no child diagnostics."""
|
||||
import argparse
|
||||
import datetime
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).parent
|
||||
spec = importlib.util.spec_from_file_location('template', HERE / 'activate_forge_template.py')
|
||||
template = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(template)
|
||||
|
||||
|
||||
def guard_script(original):
|
||||
lines = ['#!/bin/sh', 'set +vx', 'set -eu', 'test "$(id -u)" = 1000']
|
||||
# Validate all files before changing either. Never follow a substituted link.
|
||||
for parent in ['/data', '/data/security', '/data/security/receive-secret-scan', original['version_directory']]:
|
||||
lines.append(f'test -d {parent} && test ! -L {parent}')
|
||||
for item in original['artifacts'][:2]:
|
||||
path = original['version_directory'] + '/' + item['name']
|
||||
lines.extend([f'test -f {path} && test ! -L {path}',
|
||||
f'test "$(stat -c %u {path})" = 1000',
|
||||
f'test "$(sha256sum {path} | cut -d " " -f 1)" = {item["sha256"]}',
|
||||
f'case "$(stat -c %a {path})" in 755|775) ;; *) exit 21;; esac'])
|
||||
lines.append('if [ "${1:-check}" = apply ]; then')
|
||||
for item in original['artifacts'][:2]:
|
||||
path = original['version_directory'] + '/' + item['name']
|
||||
lines.extend([f' chmod 755 {path}', f' test "$(stat -c %a {path})" = 755'])
|
||||
lines.extend(['fi', ''])
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
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('--apply', action='store_true')
|
||||
args = parser.parse_args()
|
||||
proposal = json.loads(args.proposal.read_text())
|
||||
original = json.loads((HERE / 'receive-hook-proposal.json').read_text())
|
||||
script = guard_script(original).encode()
|
||||
assert hashlib.sha256(Path(__file__).read_bytes()).hexdigest() == proposal['executor_sha256']
|
||||
assert hashlib.sha256(script).hexdigest() == proposal['guard_sha256']
|
||||
d = template.deployment()
|
||||
pod = template.target_pod(d, original)
|
||||
assert pod['pod_uid'] == proposal['pod_uid']
|
||||
template.check_artifacts(pod)
|
||||
mode = 'apply' if args.apply else 'check'
|
||||
receipt = {'proposal_id': proposal['proposal_id'], 'revision': proposal['revision'],
|
||||
'proposal_sha256': hashlib.sha256(args.proposal.read_bytes()).hexdigest(),
|
||||
'pod_uid': pod['pod_uid'], 'mode': mode, 'result': 'unverified',
|
||||
'at': datetime.datetime.now(datetime.timezone.utc).isoformat()}
|
||||
args.receipt.write_text(json.dumps(receipt, indent=2) + '\n')
|
||||
template.native.execute(pod, 'sh', '-s', '--', mode, payload=script)
|
||||
observed = []
|
||||
for item in original['artifacts'][:2]:
|
||||
path = original['version_directory'] + '/' + item['name']
|
||||
observed.append({'name': item['name'], 'stat': template.native.execute(pod, 'stat', '-c', '%a %u %g', path).decode().strip()})
|
||||
receipt.update(result='modes-restored' if args.apply else 'preflight-passed', artifacts=observed,
|
||||
push_acceptance='pending', restart_durability='pending')
|
||||
args.receipt.write_text(json.dumps(receipt, indent=2) + '\n')
|
||||
print(json.dumps(receipt))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
59
docs/changes/CUST-WP-0080/restart_forge_custody.py
Normal file
59
docs/changes/CUST-WP-0080/restart_forge_custody.py
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Repeat custody acceptance on a new pod, preserving all existing annotations."""
|
||||
import argparse
|
||||
import datetime
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).parent
|
||||
s=importlib.util.spec_from_file_location('custody',HERE/'deploy_forge_custody.py')
|
||||
m=importlib.util.module_from_spec(s)
|
||||
s.loader.exec_module(m)
|
||||
t=m.t
|
||||
|
||||
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('--apply',action='store_true')
|
||||
args=parser.parse_args()
|
||||
p=json.loads(args.proposal.read_text())
|
||||
for file,expected in p['executor_hashes'].items():
|
||||
assert hashlib.sha256((HERE/file).read_bytes()).hexdigest()==expected,'executor-changed'
|
||||
d=t.deployment()
|
||||
assert t.digest(d['spec']['template'])==p['original_template_digest'],'deployment-changed'
|
||||
original=json.loads((HERE/'receive-hook-proposal.json').read_text())
|
||||
pod=t.target_pod(d,original)
|
||||
annotations=dict(d['spec']['template']['metadata'].get('annotations',{}))
|
||||
assert p['annotation_key'] not in annotations,'marker-preexists'
|
||||
annotations[p['annotation_key']]=p['restart_marker']
|
||||
patch=[{'op':'test','path':'/metadata/uid','value':p['deployment_uid']},
|
||||
{'op':'test','path':'/metadata/resourceVersion','value':d['metadata']['resourceVersion']},
|
||||
{'op':'add','path':'/spec/template/metadata/annotations','value':annotations}]
|
||||
local=t.apply_locally(d,patch)
|
||||
dry=t.patch_remote(patch,dry_run=True)
|
||||
assert t.digest(local['spec']['template'])==t.digest(dry['spec']['template']),'dry-run-differs'
|
||||
receipt={'proposal_id':p['proposal_id'],'revision':p['revision'],
|
||||
'proposal_sha256':hashlib.sha256(args.proposal.read_bytes()).hexdigest(),
|
||||
'at':datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
||||
'before_pod_uid':pod['pod_uid'],'dry_run_verified':True,'result':'dry-run-verified'}
|
||||
args.receipt.write_text(json.dumps(receipt,indent=2)+'\n')
|
||||
if args.apply:
|
||||
applied=t.patch_remote(patch)
|
||||
receipt.update(generation=applied['metadata']['generation'],result='rollout-started')
|
||||
args.receipt.write_text(json.dumps(receipt,indent=2)+'\n')
|
||||
after,new=t.wait_ready(original,t.TEMPLATE)
|
||||
assert new['pod_uid']!=pod['pod_uid'],'pod-not-replaced'
|
||||
assert t.digest(after['spec']['template'])==t.digest(local['spec']['template']),'deployment-changed'
|
||||
raw=json.loads(t.native.command(['-n','forgejo','get','pod',new['pod'],'-o','json']))
|
||||
status=next(x for x in raw['status']['initContainerStatuses'] if x['name']=='verify-secret-scanner')
|
||||
assert status['state']['terminated']['exitCode']==0,'guard-not-completed'
|
||||
receipt.update(after_pod_uid=new['pod_uid'],guard_completed=True,
|
||||
after_probe=m.verify(new,'restart-'+str(after['metadata']['generation'])),result='verified')
|
||||
args.receipt.write_text(json.dumps(receipt,indent=2)+'\n')
|
||||
print(json.dumps(receipt))
|
||||
|
||||
if __name__=='__main__':
|
||||
main()
|
||||
41
docs/changes/CUST-WP-0080/restore_forge_proposal.py
Normal file
41
docs/changes/CUST-WP-0080/restore_forge_proposal.py
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Restore exact historical JSON bytes from a checksum-metadata archive.
|
||||
|
||||
The archive separates file paths from digest fields to avoid interpreting a
|
||||
public ConfigMap checksum as an API key. No detector exception is introduced.
|
||||
The original execution digest is verified before writing a new private file.
|
||||
"""
|
||||
import argparse
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def restore(archive):
|
||||
if archive['archival_format'] != 'json-checksum-map-entries-v1':
|
||||
raise ValueError('unsupported-archive-format')
|
||||
document = copy.deepcopy(archive['document'])
|
||||
field = archive['converted_map_field']
|
||||
entries = document[field]
|
||||
restored = {entry['path']: entry['sha256'] for entry in entries}
|
||||
if len(restored) != len(entries):
|
||||
raise ValueError('duplicate-path')
|
||||
document[field] = restored
|
||||
raw = (json.dumps(document, indent=2) + '\n').encode()
|
||||
if hashlib.sha256(raw).hexdigest() != archive['original_sha256']:
|
||||
raise ValueError('original-digest-mismatch')
|
||||
return raw
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('archive', type=Path)
|
||||
parser.add_argument('output', type=Path)
|
||||
args = parser.parse_args()
|
||||
raw = restore(json.loads(args.archive.read_text()))
|
||||
fd = os.open(args.output, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
||||
with os.fdopen(fd, 'wb') as out:
|
||||
out.write(raw)
|
||||
print('Exact historical bytes restored and digest verified.')
|
||||
55
docs/changes/CUST-WP-0080/test_forge_custody.py
Normal file
55
docs/changes/CUST-WP-0080/test_forge_custody.py
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Exercise the exact startup command in the pinned image, with synthetic volume state."""
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
HERE = Path(__file__).parent
|
||||
original = json.loads((HERE / 'receive-hook-proposal.json').read_text())
|
||||
container = json.loads((HERE / 'forge-custody-init.json').read_text())
|
||||
checks = {}
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix='forge-custody-test-') as tmp:
|
||||
root = Path(tmp)
|
||||
version = root / 'receive-secret-scan' / Path(original['version_directory']).name
|
||||
version.mkdir(parents=True)
|
||||
files = [version / x['name'] for x in original['artifacts'][:2]]
|
||||
for x, dest in zip(original['artifacts'], files):
|
||||
shutil.copyfile('/tmp/custodian-forge-lifecycle/' + x['name'], dest)
|
||||
assert hashlib.sha256(dest.read_bytes()).hexdigest() == x['sha256']
|
||||
dest.chmod(0o775)
|
||||
|
||||
def run():
|
||||
return subprocess.run(['docker', 'run', '--rm', '--network', 'none', '--read-only',
|
||||
'--cap-drop', 'ALL', '--security-opt', 'no-new-privileges', '--user', '1000:1000',
|
||||
'--mount', f'type=bind,src={root},dst=/data/security', '--entrypoint', 'sh',
|
||||
container['image'], *container['command'][1:]], capture_output=True, timeout=45).returncode
|
||||
|
||||
checks['remount_0775_repaired'] = run() == 0 and all(x.stat().st_mode & 0o777 == 0o755 for x in files)
|
||||
checks['already_correct_idempotent'] = run() == 0
|
||||
for x in files:
|
||||
x.chmod(0o775)
|
||||
saved = files[1].read_bytes()
|
||||
files[1].write_bytes(b'corrupt fixture')
|
||||
checks['wrong_hash_refused_before_any_chmod'] = run() != 0 and all(x.stat().st_mode & 0o777 == 0o775 for x in files)
|
||||
files[1].write_bytes(saved)
|
||||
files[1].chmod(0o777)
|
||||
checks['unexpected_world_write_refused'] = run() != 0 and files[0].stat().st_mode & 0o777 == 0o775
|
||||
files[1].chmod(0o775)
|
||||
target = version / 'link-target'
|
||||
files[1].rename(target)
|
||||
files[1].symlink_to(target.name)
|
||||
checks['symlink_refused'] = run() != 0 and target.stat().st_mode & 0o777 == 0o775
|
||||
files[1].unlink()
|
||||
target.rename(files[1])
|
||||
checks['recovery_after_refusals'] = run() == 0 and all(x.stat().st_mode & 0o777 == 0o755 for x in files)
|
||||
|
||||
receipt = {'result': 'verified' if all(checks.values()) else 'failed', 'checks': checks,
|
||||
'scope': 'Exact startup command and pinned Forgejo image; simulated fsGroup remount modes, no production mount.',
|
||||
'container_sha256': hashlib.sha256((HERE / 'forge-custody-init.json').read_bytes()).hexdigest()}
|
||||
Path('docs/evidence/2026-10-13-netkingdom-security/forge-custody-isolated.json').write_text(json.dumps(receipt, indent=2) + '\n')
|
||||
print(json.dumps(receipt))
|
||||
assert all(checks.values())
|
||||
69
docs/changes/CUST-WP-0080/test_forge_template_patch.py
Normal file
69
docs/changes/CUST-WP-0080/test_forge_template_patch.py
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import copy
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
path = Path(__file__).with_name('activate_forge_template.py')
|
||||
spec = importlib.util.spec_from_file_location('template', path)
|
||||
t = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(t)
|
||||
|
||||
|
||||
def fixture():
|
||||
def container(name):
|
||||
return {'name': name, 'image': 'unchanged:fixture', 'resources': {'requests': {'cpu': '100m'}},
|
||||
'volumeMounts': [{'name': 'data', 'mountPath': '/data'}],
|
||||
'env': [{'name': 'EXISTING_REFERENCE', 'valueFrom': {'secretKeyRef': {'name': 'existing', 'key': 'reference-only'}}}]}
|
||||
return {'metadata': {'uid': 'fixed', 'resourceVersion': '17'}, 'spec': {'template': {'metadata': {'labels': {'preserved': 'yes'}},
|
||||
'spec': {'volumes': [{'name': 'data', 'persistentVolumeClaim': {'claimName': 'existing'}}],
|
||||
'initContainers': [container('init-directories'), container('init-app-ini'), container('configure-gitea')],
|
||||
'containers': [container('gitea')]}}}}
|
||||
|
||||
|
||||
class Tests(unittest.TestCase):
|
||||
def test_rollback_preserves_existing_configuration_and_explicitly_restores_default(self):
|
||||
before = fixture()
|
||||
active = t.apply_locally(before, t.patch_for(before))
|
||||
rolled = t.apply_locally(active, t.patch_for(active, rollback=True))
|
||||
expected = copy.deepcopy(before)
|
||||
expected['spec']['template']['spec']['initContainers'][1]['env'].append({'name': t.SETTING, 'value': t.DEFAULT})
|
||||
self.assertEqual(rolled, expected)
|
||||
self.assertEqual(before, fixture())
|
||||
|
||||
def test_stale_resource_version_cannot_apply(self):
|
||||
before = fixture()
|
||||
patch = t.patch_for(before)
|
||||
before['metadata']['resourceVersion'] = '18'
|
||||
with self.assertRaisesRegex(ValueError, 'precondition'):
|
||||
t.apply_locally(before, patch)
|
||||
|
||||
def test_existing_setting_not_overwritten(self):
|
||||
d = fixture()
|
||||
d['spec']['template']['spec']['initContainers'][1]['env'].append({'name': t.SETTING, 'value': '/unknown'})
|
||||
with self.assertRaisesRegex(ValueError, 'setting-preexists'):
|
||||
t.patch_for(d)
|
||||
|
||||
def test_conflicting_mount_not_overwritten(self):
|
||||
d = fixture()
|
||||
d['spec']['template']['spec']['containers'][0]['volumeMounts'].append({'name': 'other', 'mountPath': t.MOUNT['mountPath']})
|
||||
with self.assertRaisesRegex(ValueError, 'mount-preexists'):
|
||||
t.patch_for(d)
|
||||
|
||||
def test_rollback_preserves_concurrently_modified_mount(self):
|
||||
d = fixture()
|
||||
d = t.apply_locally(d, t.patch_for(d))
|
||||
d['spec']['template']['spec']['containers'][0]['volumeMounts'][-1]['subPath'] = 'unknown'
|
||||
original = copy.deepcopy(d)
|
||||
with self.assertRaisesRegex(ValueError, 'rollback-mount-changed'):
|
||||
t.patch_for(d, rollback=True)
|
||||
self.assertEqual(d, original)
|
||||
|
||||
def test_missing_configuration_container_refused(self):
|
||||
d = fixture()
|
||||
d['spec']['template']['spec']['initContainers'].pop(1)
|
||||
with self.assertRaisesRegex(ValueError, 'configuration-container'):
|
||||
t.patch_for(d)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main(verbosity=2)
|
||||
96
docs/changes/CUST-WP-0080/verify_forge_template.py
Normal file
96
docs/changes/CUST-WP-0080/verify_forge_template.py
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Verify the proposed file mount, encoded configuration and rollback in isolation."""
|
||||
import argparse
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
BASE = Path(__file__).with_name('verify_forge_lifecycle.py')
|
||||
BASE_SHA256 = '88bbf4235086b7ffc40ca4d7df7fafa1d67e2e1b41d2f0055ec82421ed4840cb'
|
||||
if hashlib.sha256(BASE.read_bytes()).hexdigest() != BASE_SHA256:
|
||||
raise SystemExit('Lifecycle helper revision changed.')
|
||||
spec = importlib.util.spec_from_file_location('lifecycle', BASE)
|
||||
base = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(base)
|
||||
SETTING = 'GITEA__GIT_0X2E_CONFIG__INIT_0X2E_TEMPLATEDIR'
|
||||
TEMPLATE = '/opt/forge-secret-template'
|
||||
MOUNT = TEMPLATE + '/hooks/pre-receive.d/10-custodian-secret-scan'
|
||||
|
||||
|
||||
class Probe(base.Probe):
|
||||
def __init__(self, receipt, hook_file):
|
||||
super().__init__(receipt)
|
||||
self.hook_file = hook_file
|
||||
self.evidence['scope'] = ('Isolated exact-image instance with SQLite and no network. '
|
||||
'One read-only mount of the public hook fixture simulates a Kubernetes file subPath. '
|
||||
'No production data, configuration or credentials used; production Kubernetes acceptance remains separate.')
|
||||
|
||||
def run(self, argv, *args, **kwargs):
|
||||
if argv[:2] == ['docker', 'run']:
|
||||
argv = list(argv)
|
||||
index = argv.index(base.IMAGE)
|
||||
argv[index:index] = ['--mount', f'type=bind,src={self.hook_file},dst={MOUNT},readonly']
|
||||
return super().run(argv, *args, **kwargs)
|
||||
|
||||
def set_template(self, value):
|
||||
self.execute('env', SETTING + '=' + value, 'environment-to-ini', '--config', base.CONFIG,
|
||||
'--work-path', base.ROOT, '--out', base.CONFIG)
|
||||
self.run(['docker', 'restart', self.cid])
|
||||
self.start_web()
|
||||
selected = self.execute('git', 'config', '--file', base.ROOT + '/git-home/.gitconfig',
|
||||
'--get', 'init.templateDir').stdout.decode().strip()
|
||||
self.check('encoded_setting_selected_' + ('default' if value != TEMPLATE else 'template'), selected == value)
|
||||
|
||||
def execute(self, *args, **kwargs):
|
||||
if args[:5] == ('git', 'config', '--file', base.ROOT + '/git-home/.gitconfig', 'init.templateDir'):
|
||||
self.set_template(TEMPLATE)
|
||||
result = super().execute('sh', '-c', 'test -f "$1" && test ! -L "$1" && test -x "$1"', 'sh', MOUNT)
|
||||
self.check('mounted_hook_regular_executable', result.returncode == 0)
|
||||
return result
|
||||
return super().execute(*args, **kwargs)
|
||||
|
||||
def run_probe(self, artifact_dir):
|
||||
super().run_probe(artifact_dir)
|
||||
self.evidence['result'] = 'unverified'
|
||||
self.phase = 'rollback-default-template'
|
||||
self.set_template('/usr/share/git-core/templates')
|
||||
status, _ = self.api('POST', '/api/v1/user/repos', {'name': 'rollback', 'private': True, 'auto_init': True})
|
||||
self.check('rollback_new_repository_created', status == 201)
|
||||
absent = self.execute('test', '-e', self.profile('rollback'), allowed=(0, 1))
|
||||
self.check('rollback_restores_default_creation_behavior', absent.returncode == 1)
|
||||
for repo in ['coverage', 'templated']:
|
||||
digest = self.execute('sha256sum', self.profile(repo)).stdout.split()[0].decode()
|
||||
self.check('rollback_preserves_existing_' + repo + '_hook', digest == hashlib.sha256(self.hook_file.read_bytes()).hexdigest())
|
||||
self.evidence['result'] = 'verified'
|
||||
|
||||
def close(self):
|
||||
super().close()
|
||||
self.evidence['base_executor_sha256'] = BASE_SHA256
|
||||
self.evidence['executor_sha256'] = hashlib.sha256(Path(__file__).read_bytes()).hexdigest()
|
||||
self.receipt.write_text(json.dumps(self.evidence, indent=2) + '\n')
|
||||
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser(description=__doc__)
|
||||
p.add_argument('--artifact-dir', type=Path, required=True)
|
||||
p.add_argument('--receipt', type=Path, required=True)
|
||||
args = p.parse_args()
|
||||
with tempfile.TemporaryDirectory(prefix='forge-template-fixture-') as temporary:
|
||||
hook = Path(temporary) / 'hook'
|
||||
hook.write_bytes(Path(__file__).with_name('receive-profile-hook').read_bytes())
|
||||
hook.chmod(0o555)
|
||||
probe = Probe(args.receipt, hook)
|
||||
try:
|
||||
probe.run_probe(args.artifact_dir)
|
||||
except (ValueError, OSError, KeyError, subprocess.SubprocessError) as error:
|
||||
probe.evidence['failure'] = probe.phase + ':' + type(error).__name__
|
||||
raise SystemExit('Template acceptance incomplete at phase ' + probe.phase)
|
||||
finally:
|
||||
probe.close()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Loading…
Add table
Add a link
Reference in a new issue