security: verify authenticated Forgejo routes live (FORGE6-ROUTES-001 r3)
Revision 2 live run passed 24/25; the clean merge hit Forgejo's 405 during the mergeability recheck. Revision 3 waits for mergeable, retries only 405 (max 3) and records status codes. 7 boundary tests, isolated rehearsal (27 checks) and the attended live run (26/26, verified, cleanup confirmed) pass. Revision 2 proposal and receipts retained. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
aae10f5bbb
commit
81f2f59649
10 changed files with 261 additions and 16 deletions
|
|
@ -65,6 +65,28 @@ class Suite:
|
|||
raise ValueError('invalid-object-id')
|
||||
return oid
|
||||
|
||||
def wait_mergeable(self, suffix):
|
||||
for _ in range(40):
|
||||
code, body = self.api('GET', suffix)
|
||||
if code == 200 and json.loads(body).get('mergeable') is True:
|
||||
return
|
||||
time.sleep(.5)
|
||||
raise ValueError('pull-not-mergeable')
|
||||
|
||||
def clean_merge(self, suffix):
|
||||
# A refused merge makes Forgejo recheck mergeability; until that finishes
|
||||
# it answers 405. Wait again and retry only that status, a bounded few times.
|
||||
statuses = self.evidence['clean_merge_status_codes'] = []
|
||||
for _ in range(3):
|
||||
self.wait_mergeable(suffix)
|
||||
code = self.api('POST', suffix + '/merge', {'Do': 'merge',
|
||||
'MergeTitleField': 'Clean merge', 'MergeMessageField': 'Clean proof.'})[0]
|
||||
statuses.append(code)
|
||||
if code != 405:
|
||||
break
|
||||
time.sleep(1)
|
||||
return statuses[-1]
|
||||
|
||||
def content(self, name, content, branch='main'):
|
||||
return self.api('POST', '/contents/' + name,
|
||||
{'content': base64.b64encode(content).decode(),
|
||||
|
|
@ -137,13 +159,7 @@ class Suite:
|
|||
if type(number) is not int:
|
||||
raise ValueError('invalid-pull-number')
|
||||
suffix = '/pulls/' + str(number)
|
||||
for _ in range(40):
|
||||
code, body = self.api('GET', suffix)
|
||||
if code == 200 and json.loads(body).get('mergeable') is True:
|
||||
break
|
||||
time.sleep(.5)
|
||||
else:
|
||||
raise ValueError('pull-not-mergeable')
|
||||
self.wait_mergeable(suffix)
|
||||
before = self.head()
|
||||
code, body = self.api('POST', suffix + '/merge', {'Do': 'merge',
|
||||
'MergeTitleField': 'Disposable merge', 'MergeMessageField': fixture.decode(),
|
||||
|
|
@ -152,8 +168,7 @@ class Suite:
|
|||
detected(json.loads(body).get('message', '').encode()))
|
||||
code, body = self.api('GET', suffix)
|
||||
self.check('rejected_merge_not_applied', code == 200 and json.loads(body).get('merged') is False and self.head() == before)
|
||||
self.check('clean_merge_accepted', self.api('POST', suffix + '/merge', {'Do': 'merge',
|
||||
'MergeTitleField': 'Clean merge', 'MergeMessageField': 'Clean proof.'})[0] in (200, 201))
|
||||
self.check('clean_merge_accepted', self.clean_merge(suffix) in (200, 201))
|
||||
code, body = self.api('GET', suffix)
|
||||
self.check('clean_merge_applied', code == 200 and json.loads(body).get('merged') is True and self.head() != before)
|
||||
self.evidence['acceptance_passed'] = True
|
||||
|
|
|
|||
30
docs/changes/CUST-WP-0080/forge-routes-proposal-r2.json
Normal file
30
docs/changes/CUST-WP-0080/forge-routes-proposal-r2.json
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
{
|
||||
"proposal_id": "FORGE6-ROUTES-001",
|
||||
"revision": 2,
|
||||
"state": "prepared-isolated-verified-awaiting-attended-login",
|
||||
"origin": "https://forgejo.coulomb.social",
|
||||
"user": "tegwick",
|
||||
"repository_name": "custodian-routes-f140c6b840da4bbb9de64a74",
|
||||
"source_sha256": {
|
||||
"accept_forge_routes.py": "96608481ab62445d2688e20f48c76a35c3d7be26c37452be3c46202eec4e103c",
|
||||
"run_forge_routes.py": "6258140d408e5fbed7373611cdcd65e64aee743a0b36ce4fe55b5dc94663120f",
|
||||
"receive-hook-proposal.json": "9c8ea3b7fbe16a997359432af7bcf09f429881fd23cc567188c21129e80ccf8d",
|
||||
"activate_forge_template.py": "ef2b38f655d5520d69dcd69cdb4997eaad4b50773f32c1d798d16331b204c789",
|
||||
"activate_receive_hooks.py": "9b01ee383e16463981112226e19156b0de59af2d03dd71b9403e144e280a9fb3",
|
||||
"verify_receive_native.py": "c9120122c6f6f0b7c41fe4fa975e5a2a340e776885d852b861b8c13b7664adcd"
|
||||
},
|
||||
"target": "One newly created private disposable repository; pinned deployed Forgejo and admitted hook. Existing repository content, hooks, accounts and policies remain untouched.",
|
||||
"expected_result": "Authenticated native HTTPS clean acceptance and synthetic-secret refusal with detector evidence; API private creation inherits exact hook; clean/content-secret and clean/merge-message refusal with unchanged refs; quarantine commit absent; anonymous private access refused; owned test repository deleted and absent.",
|
||||
"verification": "Exact shared Suite rehearsed against pinned isolated Forgejo; five boundary regression tests. Live receipt required; rehearsal is not deployed acceptance.",
|
||||
"rollback": "Automatic cleanup checks numeric repository ID, owner, private flag, name and ownership description before deleting only the newly created test repository. On interrupted known creation, rerun run_forge_routes.py with the same proposal and receipt plus --cleanup-only. Ambiguous POST before numeric ID receipt requires reconciliation, never replay or delete by name.",
|
||||
"abort_owner": "railiance-forge; custodian-codex prepares and verifies receipts",
|
||||
"cost_estimate_eur": 0,
|
||||
"cost_basis": "Existing service only; serialized small private probes, no added resources or spend commitments.",
|
||||
"risk_estimate": "Bounded synthetic test writes to a unique private disposable repository. No real secret fixtures, account creation, access-policy change or external publication. Unexpected acceptance may retain an unbound synthetic private-key fixture until cleanup. API/network failure stops the trial; ambiguous creation and cleanup remain explicitly unverified.",
|
||||
"authorization_ref": "canon/standards/build-mode-change-authorization_v0.1.md",
|
||||
"lane": "orange: existing caller OIDC/MFA required; no access or secret-policy scope change",
|
||||
"live_receipt": "docs/evidence/2026-10-13-netkingdom-security/forge-routes-live.json",
|
||||
"remaining_scope": "Browser/SSO journey, external gitlink repository, CI, import/mirror, historical/LFS/log/artifact detection and finding response remain open.",
|
||||
"original_revision_sha256": "a253fce3a565a2af94133da019bdc434dc57b21a8c61b06b4c27e036cf5a01d3",
|
||||
"refinement": "Pre-execution rehearsal corrected inherited Git hook mode to accept exact hash/owner with 0555 or 0755; initial failed rehearsal and source-binding limitation are retained."
|
||||
}
|
||||
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"proposal_id": "FORGE6-ROUTES-001",
|
||||
"revision": 2,
|
||||
"state": "prepared-isolated-verified-awaiting-attended-login",
|
||||
"revision": 3,
|
||||
"state": "prepared-awaiting-isolated-rehearsal",
|
||||
"origin": "https://forgejo.coulomb.social",
|
||||
"user": "tegwick",
|
||||
"repository_name": "custodian-routes-f140c6b840da4bbb9de64a74",
|
||||
"repository_name": "custodian-routes-b15df5a32e9697ab47215efc",
|
||||
"source_sha256": {
|
||||
"accept_forge_routes.py": "96608481ab62445d2688e20f48c76a35c3d7be26c37452be3c46202eec4e103c",
|
||||
"accept_forge_routes.py": "73191bb85bfb41de7084a539eb30ff3f4cb5650fd79e68ff8acc496089bb77f0",
|
||||
"run_forge_routes.py": "6258140d408e5fbed7373611cdcd65e64aee743a0b36ce4fe55b5dc94663120f",
|
||||
"receive-hook-proposal.json": "9c8ea3b7fbe16a997359432af7bcf09f429881fd23cc567188c21129e80ccf8d",
|
||||
"activate_forge_template.py": "ef2b38f655d5520d69dcd69cdb4997eaad4b50773f32c1d798d16331b204c789",
|
||||
|
|
@ -15,7 +15,7 @@
|
|||
},
|
||||
"target": "One newly created private disposable repository; pinned deployed Forgejo and admitted hook. Existing repository content, hooks, accounts and policies remain untouched.",
|
||||
"expected_result": "Authenticated native HTTPS clean acceptance and synthetic-secret refusal with detector evidence; API private creation inherits exact hook; clean/content-secret and clean/merge-message refusal with unchanged refs; quarantine commit absent; anonymous private access refused; owned test repository deleted and absent.",
|
||||
"verification": "Exact shared Suite rehearsed against pinned isolated Forgejo; five boundary regression tests. Live receipt required; rehearsal is not deployed acceptance.",
|
||||
"verification": "Seven boundary tests pass. Exact shared Suite must be re-rehearsed against pinned isolated Forgejo with the admitted binaries before live use; rehearsal is not deployed acceptance.",
|
||||
"rollback": "Automatic cleanup checks numeric repository ID, owner, private flag, name and ownership description before deleting only the newly created test repository. On interrupted known creation, rerun run_forge_routes.py with the same proposal and receipt plus --cleanup-only. Ambiguous POST before numeric ID receipt requires reconciliation, never replay or delete by name.",
|
||||
"abort_owner": "railiance-forge; custodian-codex prepares and verifies receipts",
|
||||
"cost_estimate_eur": 0,
|
||||
|
|
@ -23,8 +23,11 @@
|
|||
"risk_estimate": "Bounded synthetic test writes to a unique private disposable repository. No real secret fixtures, account creation, access-policy change or external publication. Unexpected acceptance may retain an unbound synthetic private-key fixture until cleanup. API/network failure stops the trial; ambiguous creation and cleanup remain explicitly unverified.",
|
||||
"authorization_ref": "canon/standards/build-mode-change-authorization_v0.1.md",
|
||||
"lane": "orange: existing caller OIDC/MFA required; no access or secret-policy scope change",
|
||||
"live_receipt": "docs/evidence/2026-10-13-netkingdom-security/forge-routes-live.json",
|
||||
"live_receipt": "docs/evidence/2026-10-18-netkingdom-security/forge-routes-live-r3.json",
|
||||
"remaining_scope": "Browser/SSO journey, external gitlink repository, CI, import/mirror, historical/LFS/log/artifact detection and finding response remain open.",
|
||||
"original_revision_sha256": "a253fce3a565a2af94133da019bdc434dc57b21a8c61b06b4c27e036cf5a01d3",
|
||||
"refinement": "Pre-execution rehearsal corrected inherited Git hook mode to accept exact hash/owner with 0555 or 0755; initial failed rehearsal and source-binding limitation are retained."
|
||||
"refinement": "Pre-execution rehearsal corrected inherited Git hook mode to accept exact hash/owner with 0555 or 0755; initial failed rehearsal and source-binding limitation are retained.",
|
||||
"previous_revision_sha256": "d12c12857e9ed5f4afa3c1c231fd142adad580a458cc175e74f0ea0e3118d792",
|
||||
"previous_live_receipt": "docs/evidence/2026-10-13-netkingdom-security/forge-routes-live.json",
|
||||
"refinement_r3": "Revision 2 live run (2026-10-18) passed 24/25 checks; the clean merge sent ~1s after the refused merge got 405 in 17ms (Forgejo log), i.e. mergeability recheck still running. Revision 3 waits for mergeable again before the clean merge, retries only 405 at most three times, and records the merge status codes (no response bodies). Two new boundary tests cover retry and its bounds."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,6 +54,37 @@ class CleanupTests(unittest.TestCase):
|
|||
self.assertFalse(m.detected(b'{"status":"unverified"}'))
|
||||
|
||||
|
||||
class MergeRetryTests(unittest.TestCase):
|
||||
def suite(self, directory, merges):
|
||||
calls = []
|
||||
class Backend:
|
||||
user = 'tegwick'
|
||||
scope = 'test'
|
||||
def api(self, method, path, data=None, authenticated=True):
|
||||
calls.append(method)
|
||||
if method == 'GET':
|
||||
return 200, json.dumps({'mergeable': True}).encode()
|
||||
return merges.pop(0), b'{}'
|
||||
suite = m.Suite(Backend(), Path(directory) / 'receipt.json', 'custodian-routes-' + 'a' * 24)
|
||||
return suite, calls
|
||||
|
||||
def test_recheck_405_is_waited_out_and_retried(self):
|
||||
with tempfile.TemporaryDirectory() as directory, mock.patch.object(m.time, 'sleep'):
|
||||
suite, calls = self.suite(directory, [405, 200])
|
||||
self.assertEqual(suite.clean_merge('/pulls/1'), 200)
|
||||
self.assertEqual(suite.evidence['clean_merge_status_codes'], [405, 200])
|
||||
self.assertEqual(calls, ['GET', 'POST', 'GET', 'POST'])
|
||||
|
||||
def test_other_refusals_are_not_retried_and_retries_are_bounded(self):
|
||||
with tempfile.TemporaryDirectory() as directory, mock.patch.object(m.time, 'sleep'):
|
||||
suite, _ = self.suite(directory, [409])
|
||||
self.assertEqual(suite.clean_merge('/pulls/1'), 409)
|
||||
self.assertEqual(suite.evidence['clean_merge_status_codes'], [409])
|
||||
suite, _ = self.suite(directory, [405, 405, 405, 200])
|
||||
self.assertEqual(suite.clean_merge('/pulls/1'), 405)
|
||||
self.assertEqual(suite.evidence['clean_merge_status_codes'], [405, 405, 405])
|
||||
|
||||
|
||||
class TransportTests(unittest.TestCase):
|
||||
def test_http_redirect_does_not_forward_injected_credential(self):
|
||||
hits = []
|
||||
|
|
|
|||
|
|
@ -0,0 +1,39 @@
|
|||
{
|
||||
"result": "unverified",
|
||||
"repository_name": "custodian-routes-f140c6b840da4bbb9de64a74",
|
||||
"observed_at": "2026-10-18T00:18:27.583559+00:00",
|
||||
"checks": {
|
||||
"expected_authenticated_caller": true,
|
||||
"target_does_not_preexist": true,
|
||||
"owned_private_repository_created": true,
|
||||
"anonymous_private_read_denied": true,
|
||||
"new_repository_inherits_exact_hook": true,
|
||||
"http_clean_push_accepted": true,
|
||||
"http_clean_ref_matches": true,
|
||||
"http_secret_push_detected_and_refused": true,
|
||||
"http_refused_ref_absent": true,
|
||||
"http_rejection_preserves_main": true,
|
||||
"http_quarantine_object_absent": true,
|
||||
"api_clean_content_accepted": true,
|
||||
"api_secret_content_refused": true,
|
||||
"api_refusal_has_detector_evidence": true,
|
||||
"api_refusal_preserves_main": true,
|
||||
"api_refused_content_absent": true,
|
||||
"feature_branch_created": true,
|
||||
"feature_content_accepted": true,
|
||||
"pull_request_created": true,
|
||||
"merge_secret_message_detected_and_refused": true,
|
||||
"rejected_merge_not_applied": true,
|
||||
"clean_merge_accepted": false,
|
||||
"cleanup_identity_matches": true,
|
||||
"owned_disposable_repository_deleted": true,
|
||||
"deleted_repository_absent": true
|
||||
},
|
||||
"scope": "Deployed HTTPS Git, repository creation, content API and merge API on one owned private disposable repository. No browser/SSO, CI, import/mirror or finding-response conformance inferred.",
|
||||
"proposal_sha256": "d12c12857e9ed5f4afa3c1c231fd142adad580a458cc175e74f0ea0e3118d792",
|
||||
"phase": "cleanup",
|
||||
"executor_sha256": "96608481ab62445d2688e20f48c76a35c3d7be26c37452be3c46202eec4e103c",
|
||||
"creation_attempted": true,
|
||||
"created_repository_id": 173,
|
||||
"failure_phase": "merge-api"
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"route_exit_code": 1,
|
||||
"receipt_present": true,
|
||||
"cleanup_only": false,
|
||||
"proposal_sha256": "d12c12857e9ed5f4afa3c1c231fd142adad580a458cc175e74f0ea0e3118d792",
|
||||
"raw_diagnostics_retained": false,
|
||||
"credential_returned_to_agent": false
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"observed_at": "2026-10-18T00:27:53.039275+00:00",
|
||||
"image": "code.forgejo.org/forgejo/forgejo@sha256:691657c7e64af7730a2e885e6d022cf16ae60724931c5392bd61838afadcaeba",
|
||||
"result": "fixture-ready",
|
||||
"checks": {
|
||||
"synthetic_identity_authenticated": true,
|
||||
"native_repository_created": true
|
||||
},
|
||||
"scope": "Pinned-image fixture setup only; shared suite has its own acceptance receipt.",
|
||||
"identity_http_status": 200,
|
||||
"repository_create_http_status": 201,
|
||||
"repository_create_error_kinds": [
|
||||
"email"
|
||||
],
|
||||
"inherited_hook_mode_owner": "755 1000 1000",
|
||||
"failure_diagnostics": [
|
||||
"PushRejected",
|
||||
"app.ini",
|
||||
"exit status 1"
|
||||
],
|
||||
"isolated_container_and_volumes_removed": true,
|
||||
"last_phase": "baseline",
|
||||
"executor_sha256": "88bbf4235086b7ffc40ca4d7df7fafa1d67e2e1b41d2f0055ec82421ed4840cb"
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
{
|
||||
"result": "verified",
|
||||
"repository_name": "custodian-routes-8da2932b43a6476ab883aca2",
|
||||
"observed_at": "2026-10-18T00:28:10.732135+00:00",
|
||||
"checks": {
|
||||
"expected_authenticated_caller": true,
|
||||
"target_does_not_preexist": true,
|
||||
"owned_private_repository_created": true,
|
||||
"anonymous_private_read_denied": true,
|
||||
"new_repository_inherits_exact_hook": true,
|
||||
"http_clean_push_accepted": true,
|
||||
"http_clean_ref_matches": true,
|
||||
"http_secret_push_detected_and_refused": true,
|
||||
"http_refused_ref_absent": true,
|
||||
"http_rejection_preserves_main": true,
|
||||
"http_quarantine_object_absent": true,
|
||||
"api_clean_content_accepted": true,
|
||||
"api_secret_content_refused": true,
|
||||
"api_refusal_has_detector_evidence": true,
|
||||
"api_refusal_preserves_main": true,
|
||||
"api_refused_content_absent": true,
|
||||
"feature_branch_created": true,
|
||||
"feature_content_accepted": true,
|
||||
"pull_request_created": true,
|
||||
"merge_secret_message_detected_and_refused": true,
|
||||
"rejected_merge_not_applied": true,
|
||||
"clean_merge_accepted": true,
|
||||
"clean_merge_applied": true,
|
||||
"executor_unchanged_during_rehearsal": true,
|
||||
"cleanup_identity_matches": true,
|
||||
"owned_disposable_repository_deleted": true,
|
||||
"deleted_repository_absent": true
|
||||
},
|
||||
"scope": "Exact shared HTTP/API suite on pinned isolated Forgejo, SQLite, no external network; no live credential or deployed acceptance.",
|
||||
"loaded_executor_sha256": "73191bb85bfb41de7084a539eb30ff3f4cb5650fd79e68ff8acc496089bb77f0",
|
||||
"phase": "cleanup",
|
||||
"executor_sha256": "73191bb85bfb41de7084a539eb30ff3f4cb5650fd79e68ff8acc496089bb77f0",
|
||||
"creation_attempted": true,
|
||||
"created_repository_id": 2,
|
||||
"clean_merge_status_codes": [
|
||||
200
|
||||
],
|
||||
"acceptance_passed": true
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
{
|
||||
"result": "verified",
|
||||
"repository_name": "custodian-routes-b15df5a32e9697ab47215efc",
|
||||
"observed_at": "2026-10-18T00:32:19.351604+00:00",
|
||||
"checks": {
|
||||
"expected_authenticated_caller": true,
|
||||
"target_does_not_preexist": true,
|
||||
"owned_private_repository_created": true,
|
||||
"anonymous_private_read_denied": true,
|
||||
"new_repository_inherits_exact_hook": true,
|
||||
"http_clean_push_accepted": true,
|
||||
"http_clean_ref_matches": true,
|
||||
"http_secret_push_detected_and_refused": true,
|
||||
"http_refused_ref_absent": true,
|
||||
"http_rejection_preserves_main": true,
|
||||
"http_quarantine_object_absent": true,
|
||||
"api_clean_content_accepted": true,
|
||||
"api_secret_content_refused": true,
|
||||
"api_refusal_has_detector_evidence": true,
|
||||
"api_refusal_preserves_main": true,
|
||||
"api_refused_content_absent": true,
|
||||
"feature_branch_created": true,
|
||||
"feature_content_accepted": true,
|
||||
"pull_request_created": true,
|
||||
"merge_secret_message_detected_and_refused": true,
|
||||
"rejected_merge_not_applied": true,
|
||||
"clean_merge_accepted": true,
|
||||
"clean_merge_applied": true,
|
||||
"cleanup_identity_matches": true,
|
||||
"owned_disposable_repository_deleted": true,
|
||||
"deleted_repository_absent": true
|
||||
},
|
||||
"scope": "Deployed HTTPS Git, repository creation, content API and merge API on one owned private disposable repository. No browser/SSO, CI, import/mirror or finding-response conformance inferred.",
|
||||
"proposal_sha256": "322434dcb5a363f962a62d4e03613b68e829248ff75c849997577dac45185d8d",
|
||||
"phase": "cleanup",
|
||||
"executor_sha256": "73191bb85bfb41de7084a539eb30ff3f4cb5650fd79e68ff8acc496089bb77f0",
|
||||
"creation_attempted": true,
|
||||
"created_repository_id": 174,
|
||||
"clean_merge_status_codes": [
|
||||
200
|
||||
],
|
||||
"acceptance_passed": true
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"route_exit_code": 0,
|
||||
"receipt_present": true,
|
||||
"cleanup_only": false,
|
||||
"proposal_sha256": "322434dcb5a363f962a62d4e03613b68e829248ff75c849997577dac45185d8d",
|
||||
"raw_diagnostics_retained": false,
|
||||
"credential_returned_to_agent": false
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue