@mriekki/project-forge 4.6.0 → 4.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/bootstrap-migrations/managed-baseline-verifier/check-managed-baselines.py +180 -0
- package/assets/bootstrap-migrations/managed-baseline-verifier/manifest.json +17 -0
- package/assets/components.json +3 -3
- package/assets/deployment/distribution.json +2 -0
- package/assets/generated/ASSEMBLY.json +22 -12
- package/assets/generated/core/core/skills/deploy/SKILL.md +124 -12
- package/assets/generated/core/core/skills/deployment-readiness/SKILL.md +89 -9
- package/assets/generated/core/core/skills/setup/SKILL.md +94 -5
- package/assets/generated/deployment/src/project_forge_deployment/access_cli.py +65 -0
- package/assets/generated/deployment/src/project_forge_deployment/access_diagnosis.py +116 -0
- package/assets/generated/deployment/src/project_forge_deployment/app_cli.py +46 -20
- package/assets/generated/deployment/src/project_forge_deployment/app_deployment.py +100 -0
- package/assets/generated/deployment/src/project_forge_deployment/entrypoint.py +4 -0
- package/assets/generated/deployment/src/project_forge_deployment/registry_access.py +135 -5
- package/assets/generated/deployment/src/project_forge_deployment/registry_cli.py +33 -4
- package/assets/generated/deployment/src/project_forge_deployment/security.py +44 -15
- package/package.json +1 -1
- package/src/bootstrap-migrations.mjs +160 -0
- package/src/cli.mjs +20 -8
|
@@ -10,15 +10,19 @@ from __future__ import annotations
|
|
|
10
10
|
|
|
11
11
|
from typing import Any, Iterable
|
|
12
12
|
|
|
13
|
+
from .app_runtime import PRODUCTION_APP_DEPLOY_WRITE_PHRASE
|
|
13
14
|
from .app_runtime import apply_app_deploy as _apply_runtime_deploy
|
|
15
|
+
from .app_runtime import deploy_write_phrase
|
|
14
16
|
from .app_runtime import plan_app_deploy as _plan_runtime_deploy
|
|
15
17
|
from .app_runtime_workstation import apply_app_deploy_with_preloaded_image
|
|
16
18
|
from .environment_isolation import environment_isolation_preflight
|
|
17
19
|
from .errors import DeploymentKitError
|
|
18
20
|
from .image_transfer import transfer_image
|
|
21
|
+
from .route_registry import ROUTE_WRITE_PHRASE, plan_route_registration
|
|
19
22
|
|
|
20
23
|
|
|
21
24
|
ISOLATION_REVIEW_KEY = "isolationReview"
|
|
25
|
+
ROLLOUT_SCHEMA_VERSION = 1
|
|
22
26
|
|
|
23
27
|
|
|
24
28
|
def app_isolation_check(
|
|
@@ -139,6 +143,100 @@ def plan_app_deploy(
|
|
|
139
143
|
return plan
|
|
140
144
|
|
|
141
145
|
|
|
146
|
+
def _endpoint_states(route_plan: dict[str, Any] | None) -> dict[str, str]:
|
|
147
|
+
if route_plan is None:
|
|
148
|
+
return {
|
|
149
|
+
"beforeRuntime": "NO_ROUTE_REQUESTED",
|
|
150
|
+
"afterRuntimeBeforeRoute": "NO_ROUTE_REQUESTED",
|
|
151
|
+
"afterRoute": "NO_ROUTE_REQUESTED",
|
|
152
|
+
}
|
|
153
|
+
if route_plan["changeType"] == "create":
|
|
154
|
+
before = "NOT_ACTIVE_ROUTE_ABSENT"
|
|
155
|
+
after_runtime = "NOT_ACTIVE_ROUTE_PENDING"
|
|
156
|
+
else:
|
|
157
|
+
before = "ROUTE_PRESENT_RUNTIME_STATE_NOT_VALIDATED"
|
|
158
|
+
after_runtime = "ROUTE_PRESENT_ROUTE_CHANGE_PENDING"
|
|
159
|
+
return {
|
|
160
|
+
"beforeRuntime": before,
|
|
161
|
+
"afterRuntimeBeforeRoute": after_runtime,
|
|
162
|
+
"afterRoute": "ROUTE_REGISTERED_BROWSER_VERIFICATION_PENDING",
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def plan_app_rollout(
|
|
167
|
+
target_name: str,
|
|
168
|
+
spec: dict[str, Any],
|
|
169
|
+
*,
|
|
170
|
+
production: bool = False,
|
|
171
|
+
database_file: str | None = None,
|
|
172
|
+
route_hostnames: Iterable[str] | None = None,
|
|
173
|
+
) -> dict[str, Any]:
|
|
174
|
+
"""Compose read-only runtime and route plans into one staged rollout preview."""
|
|
175
|
+
|
|
176
|
+
hosts = list(route_hostnames or [])
|
|
177
|
+
app_plan = plan_app_deploy(
|
|
178
|
+
target_name,
|
|
179
|
+
spec,
|
|
180
|
+
production=production,
|
|
181
|
+
database_file=database_file,
|
|
182
|
+
route_hostnames=hosts,
|
|
183
|
+
)
|
|
184
|
+
route_plan = None
|
|
185
|
+
if hosts:
|
|
186
|
+
route_plan = plan_route_registration(
|
|
187
|
+
target_name,
|
|
188
|
+
owner_repo=spec["ownerRepo"],
|
|
189
|
+
environment=spec["environment"],
|
|
190
|
+
app_slug=spec["appSlug"],
|
|
191
|
+
upstream_container=spec["containerName"],
|
|
192
|
+
upstream_port=spec["runtimePort"],
|
|
193
|
+
hostnames=hosts,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
runtime_authorization = (
|
|
197
|
+
PRODUCTION_APP_DEPLOY_WRITE_PHRASE
|
|
198
|
+
if production or spec["environment"] == "production"
|
|
199
|
+
else deploy_write_phrase(app_plan)
|
|
200
|
+
)
|
|
201
|
+
stages = [
|
|
202
|
+
{
|
|
203
|
+
"stage": "runtime",
|
|
204
|
+
"action": "deploy-application-runtime",
|
|
205
|
+
"requiredAuthorization": runtime_authorization,
|
|
206
|
+
"mutated": False,
|
|
207
|
+
}
|
|
208
|
+
]
|
|
209
|
+
if route_plan is not None:
|
|
210
|
+
stages.append(
|
|
211
|
+
{
|
|
212
|
+
"stage": "route",
|
|
213
|
+
"action": "register-application-route",
|
|
214
|
+
"requiredAuthorization": ROUTE_WRITE_PHRASE,
|
|
215
|
+
"mutated": False,
|
|
216
|
+
}
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
runtime_view = {key: value for key, value in app_plan.items() if key != "spec"}
|
|
220
|
+
return {
|
|
221
|
+
"schemaVersion": ROLLOUT_SCHEMA_VERSION,
|
|
222
|
+
"targetName": target_name,
|
|
223
|
+
"environment": spec["environment"],
|
|
224
|
+
"production": bool(production or spec["environment"] == "production"),
|
|
225
|
+
"image": spec["image"],
|
|
226
|
+
"runtimePlan": runtime_view,
|
|
227
|
+
"routePlan": route_plan,
|
|
228
|
+
"stages": stages,
|
|
229
|
+
"separateAuthorizationsRequired": route_plan is not None,
|
|
230
|
+
"endpointState": _endpoint_states(route_plan),
|
|
231
|
+
"postRouteVerification": (
|
|
232
|
+
["application-health", "route-http", "workstation-browser"]
|
|
233
|
+
if route_plan is not None
|
|
234
|
+
else ["application-health"]
|
|
235
|
+
),
|
|
236
|
+
"mutated": False,
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
|
|
142
240
|
def revalidate_app_isolation(plan: dict[str, Any]) -> dict[str, Any]:
|
|
143
241
|
"""Recheck the reviewed application footprint immediately before mutation."""
|
|
144
242
|
|
|
@@ -186,9 +284,11 @@ def apply_app_deploy_from_workstation(plan: dict[str, Any]) -> dict[str, Any]:
|
|
|
186
284
|
|
|
187
285
|
__all__ = [
|
|
188
286
|
"ISOLATION_REVIEW_KEY",
|
|
287
|
+
"ROLLOUT_SCHEMA_VERSION",
|
|
189
288
|
"app_isolation_check",
|
|
190
289
|
"apply_app_deploy",
|
|
191
290
|
"apply_app_deploy_from_workstation",
|
|
192
291
|
"plan_app_deploy",
|
|
292
|
+
"plan_app_rollout",
|
|
193
293
|
"revalidate_app_isolation",
|
|
194
294
|
]
|
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import sys
|
|
6
6
|
|
|
7
|
+
from .access_cli import main as access_main
|
|
7
8
|
from .app_cli import main as app_main
|
|
8
9
|
from .cli import main as core_main
|
|
9
10
|
from .environment_cli import main as environment_main
|
|
@@ -16,6 +17,8 @@ from .topology_cli import main as topology_main
|
|
|
16
17
|
|
|
17
18
|
def main(argv: list[str] | None = None) -> int:
|
|
18
19
|
args = list(sys.argv[1:] if argv is None else argv)
|
|
20
|
+
if args and args[0] == "access":
|
|
21
|
+
return access_main(args[1:])
|
|
19
22
|
if args and args[0] == "app":
|
|
20
23
|
return app_main(args[1:])
|
|
21
24
|
if args and args[0] == "environment":
|
|
@@ -31,6 +34,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
31
34
|
if args and args[0] == "topology":
|
|
32
35
|
return topology_main(args[1:])
|
|
33
36
|
if not args or args == ["--help"] or args == ["-h"]:
|
|
37
|
+
print("Command group: access Read-only strict SSH target-access diagnosis")
|
|
34
38
|
print("Command group: app Remote immutable application runtime inventory/deployment")
|
|
35
39
|
print("Command group: environment Portable profiles, Home Lab onboarding, and image transfer")
|
|
36
40
|
print("Command group: local Loopback-only workstation application runtime")
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
"""Read-only exact-image registry
|
|
1
|
+
"""Read-only exact-image registry capability verification by execution context."""
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import os
|
|
5
6
|
import re
|
|
6
7
|
import shlex
|
|
7
8
|
import subprocess
|
|
@@ -13,6 +14,17 @@ from .ssh import ssh_args, verify_access
|
|
|
13
14
|
|
|
14
15
|
|
|
15
16
|
_IMAGE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/:+-]*@sha256:[0-9a-f]{64}$")
|
|
17
|
+
_REMOTE_DOES_NOT_PROVE = [
|
|
18
|
+
"workstation-registry-access",
|
|
19
|
+
"workstation-image-pull",
|
|
20
|
+
"actions-registry-transport",
|
|
21
|
+
"package-metadata-api-access",
|
|
22
|
+
]
|
|
23
|
+
_WORKSTATION_DOES_NOT_PROVE = [
|
|
24
|
+
"remote-target-registry-access",
|
|
25
|
+
"actions-registry-transport",
|
|
26
|
+
"package-metadata-api-access",
|
|
27
|
+
]
|
|
16
28
|
|
|
17
29
|
_REMOTE_SCRIPT = r'''#!/usr/bin/env bash
|
|
18
30
|
set -Eeuo pipefail
|
|
@@ -61,6 +73,14 @@ printf 'ACCESS\t%s\n' "$credential_state"
|
|
|
61
73
|
'''
|
|
62
74
|
|
|
63
75
|
|
|
76
|
+
class RegistryCapabilityError(DeploymentKitError):
|
|
77
|
+
"""Fail-closed registry capability error with safe structured evidence."""
|
|
78
|
+
|
|
79
|
+
def __init__(self, message: str, result: dict[str, Any]) -> None:
|
|
80
|
+
super().__init__(message)
|
|
81
|
+
self.result = result
|
|
82
|
+
|
|
83
|
+
|
|
64
84
|
def normalize_registry_image(value: str) -> str:
|
|
65
85
|
if not isinstance(value, str) or _IMAGE_RE.fullmatch(value) is None:
|
|
66
86
|
raise DeploymentKitError(
|
|
@@ -83,13 +103,55 @@ def _parse_access(output: str) -> str:
|
|
|
83
103
|
return parts[1]
|
|
84
104
|
|
|
85
105
|
|
|
106
|
+
def _remote_failure_result(
|
|
107
|
+
*,
|
|
108
|
+
name: str,
|
|
109
|
+
image: str,
|
|
110
|
+
address: str,
|
|
111
|
+
returncode: int,
|
|
112
|
+
) -> dict[str, Any]:
|
|
113
|
+
failure_codes = {
|
|
114
|
+
42: "docker-cli-unavailable",
|
|
115
|
+
43: "credential-config-unsafe",
|
|
116
|
+
44: "manifest-access-failed",
|
|
117
|
+
}
|
|
118
|
+
return {
|
|
119
|
+
"status": "failed",
|
|
120
|
+
"executionLocation": "remote-target",
|
|
121
|
+
"targetName": name,
|
|
122
|
+
"verifiedAddress": address,
|
|
123
|
+
"capabilityAttempted": "registry-manifest-read",
|
|
124
|
+
"image": image,
|
|
125
|
+
"failureCode": failure_codes.get(returncode, "remote-command-failed"),
|
|
126
|
+
"doesNotProve": list(_REMOTE_DOES_NOT_PROVE),
|
|
127
|
+
"mutated": False,
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _workstation_failure_result(*, image: str, failure_code: str) -> dict[str, Any]:
|
|
132
|
+
return {
|
|
133
|
+
"status": "failed",
|
|
134
|
+
"executionLocation": "workstation",
|
|
135
|
+
"credentialContext": "workstation-docker-client",
|
|
136
|
+
"dockerConfigSource": "explicit-environment" if os.environ.get("DOCKER_CONFIG") else "default",
|
|
137
|
+
"capabilityAttempted": "registry-manifest-read",
|
|
138
|
+
"image": image,
|
|
139
|
+
"failureCode": failure_code,
|
|
140
|
+
"doesNotProve": list(_WORKSTATION_DOES_NOT_PROVE),
|
|
141
|
+
"mutated": False,
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
|
|
86
145
|
def verify_registry_access(name: str, image: str) -> dict[str, Any]:
|
|
146
|
+
"""Verify exact-image manifest access from the named remote target."""
|
|
147
|
+
|
|
87
148
|
exact_image = normalize_registry_image(image)
|
|
88
149
|
profile = load_profile(name)
|
|
89
150
|
access = verify_access(profile)
|
|
151
|
+
address = access["address"]
|
|
90
152
|
command = ssh_args(
|
|
91
153
|
profile,
|
|
92
|
-
|
|
154
|
+
address,
|
|
93
155
|
command="bash -s -- " + shlex.quote(exact_image),
|
|
94
156
|
)
|
|
95
157
|
completed = subprocess.run(
|
|
@@ -99,16 +161,84 @@ def verify_registry_access(name: str, image: str) -> dict[str, Any]:
|
|
|
99
161
|
capture_output=True,
|
|
100
162
|
)
|
|
101
163
|
stdout = completed.stdout.decode("utf-8", errors="replace")
|
|
102
|
-
stderr = completed.stderr.decode("utf-8", errors="replace")
|
|
103
164
|
if completed.returncode != 0:
|
|
104
|
-
|
|
105
|
-
|
|
165
|
+
result = _remote_failure_result(
|
|
166
|
+
name=name,
|
|
167
|
+
image=exact_image,
|
|
168
|
+
address=address,
|
|
169
|
+
returncode=completed.returncode,
|
|
170
|
+
)
|
|
171
|
+
raise RegistryCapabilityError(
|
|
172
|
+
f"Remote-target registry capability check failed ({result['failureCode']}).",
|
|
173
|
+
result,
|
|
174
|
+
)
|
|
106
175
|
|
|
107
176
|
credential_state = _parse_access(stdout)
|
|
108
177
|
return {
|
|
178
|
+
"status": "verified",
|
|
179
|
+
"executionLocation": "remote-target",
|
|
109
180
|
"targetName": name,
|
|
181
|
+
"verifiedAddress": address,
|
|
182
|
+
"credentialContext": (
|
|
183
|
+
"target-docker-config" if credential_state == "protected" else "target-anonymous"
|
|
184
|
+
),
|
|
185
|
+
"capabilityProved": "registry-manifest-read",
|
|
110
186
|
"image": exact_image,
|
|
111
187
|
"manifestAccess": "verified",
|
|
112
188
|
"credentialConfig": credential_state,
|
|
189
|
+
"doesNotProve": list(_REMOTE_DOES_NOT_PROVE),
|
|
113
190
|
"mutated": False,
|
|
114
191
|
}
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def verify_workstation_registry_access(image: str) -> dict[str, Any]:
|
|
195
|
+
"""Verify exact-image manifest access from this workstation without pulling."""
|
|
196
|
+
|
|
197
|
+
exact_image = normalize_registry_image(image)
|
|
198
|
+
command = ["docker", "manifest", "inspect", exact_image]
|
|
199
|
+
try:
|
|
200
|
+
completed = subprocess.run(
|
|
201
|
+
command,
|
|
202
|
+
check=False,
|
|
203
|
+
capture_output=True,
|
|
204
|
+
)
|
|
205
|
+
except FileNotFoundError as error:
|
|
206
|
+
result = _workstation_failure_result(
|
|
207
|
+
image=exact_image,
|
|
208
|
+
failure_code="docker-cli-unavailable",
|
|
209
|
+
)
|
|
210
|
+
raise RegistryCapabilityError(
|
|
211
|
+
"Workstation registry capability check failed (docker-cli-unavailable).",
|
|
212
|
+
result,
|
|
213
|
+
) from error
|
|
214
|
+
|
|
215
|
+
if completed.returncode != 0:
|
|
216
|
+
result = _workstation_failure_result(
|
|
217
|
+
image=exact_image,
|
|
218
|
+
failure_code="manifest-access-failed",
|
|
219
|
+
)
|
|
220
|
+
raise RegistryCapabilityError(
|
|
221
|
+
"Workstation registry capability check failed (manifest-access-failed).",
|
|
222
|
+
result,
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
return {
|
|
226
|
+
"status": "verified",
|
|
227
|
+
"executionLocation": "workstation",
|
|
228
|
+
"credentialContext": "workstation-docker-client",
|
|
229
|
+
"dockerConfigSource": "explicit-environment" if os.environ.get("DOCKER_CONFIG") else "default",
|
|
230
|
+
"capabilityProved": "registry-manifest-read",
|
|
231
|
+
"image": exact_image,
|
|
232
|
+
"manifestAccess": "verified",
|
|
233
|
+
"doesNotProve": list(_WORKSTATION_DOES_NOT_PROVE),
|
|
234
|
+
"mutated": False,
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
__all__ = [
|
|
239
|
+
"RegistryCapabilityError",
|
|
240
|
+
"build_remote_script",
|
|
241
|
+
"normalize_registry_image",
|
|
242
|
+
"verify_registry_access",
|
|
243
|
+
"verify_workstation_registry_access",
|
|
244
|
+
]
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""CLI for read-only registry
|
|
1
|
+
"""CLI for read-only registry capability verification."""
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
@@ -8,7 +8,11 @@ import sys
|
|
|
8
8
|
from typing import Any
|
|
9
9
|
|
|
10
10
|
from .errors import DeploymentKitError
|
|
11
|
-
from .registry_access import
|
|
11
|
+
from .registry_access import (
|
|
12
|
+
RegistryCapabilityError,
|
|
13
|
+
verify_registry_access,
|
|
14
|
+
verify_workstation_registry_access,
|
|
15
|
+
)
|
|
12
16
|
|
|
13
17
|
|
|
14
18
|
def _print(value: Any, *, as_json: bool) -> None:
|
|
@@ -28,11 +32,24 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
28
32
|
|
|
29
33
|
verify = sub.add_parser(
|
|
30
34
|
"verify",
|
|
31
|
-
help=
|
|
35
|
+
help=(
|
|
36
|
+
"Verify exact-image registry manifest access on the named remote target without pulling "
|
|
37
|
+
"or modifying target state"
|
|
38
|
+
),
|
|
32
39
|
)
|
|
33
|
-
verify.add_argument("name")
|
|
40
|
+
verify.add_argument("name", help="Named remote target profile")
|
|
34
41
|
verify.add_argument("--image", required=True)
|
|
35
42
|
verify.add_argument("--json", action="store_true")
|
|
43
|
+
|
|
44
|
+
verify_workstation = sub.add_parser(
|
|
45
|
+
"verify-workstation",
|
|
46
|
+
help=(
|
|
47
|
+
"Verify exact-image registry manifest access from this workstation without pulling "
|
|
48
|
+
"or modifying target state"
|
|
49
|
+
),
|
|
50
|
+
)
|
|
51
|
+
verify_workstation.add_argument("--image", required=True)
|
|
52
|
+
verify_workstation.add_argument("--json", action="store_true")
|
|
36
53
|
return parser
|
|
37
54
|
|
|
38
55
|
|
|
@@ -45,6 +62,18 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
45
62
|
as_json=args.json,
|
|
46
63
|
)
|
|
47
64
|
return 0
|
|
65
|
+
if args.command == "verify-workstation":
|
|
66
|
+
_print(
|
|
67
|
+
verify_workstation_registry_access(args.image),
|
|
68
|
+
as_json=args.json,
|
|
69
|
+
)
|
|
70
|
+
return 0
|
|
71
|
+
except RegistryCapabilityError as error:
|
|
72
|
+
if getattr(args, "json", False):
|
|
73
|
+
_print(error.result, as_json=True)
|
|
74
|
+
else:
|
|
75
|
+
print(f"ERROR: {error}", file=sys.stderr)
|
|
76
|
+
return 2
|
|
48
77
|
except DeploymentKitError as error:
|
|
49
78
|
print(f"ERROR: {error}", file=sys.stderr)
|
|
50
79
|
return 2
|
|
@@ -180,27 +180,56 @@ def _posix_directory_chain(path: Path) -> list[Path]:
|
|
|
180
180
|
current = current.parent
|
|
181
181
|
|
|
182
182
|
|
|
183
|
-
def
|
|
183
|
+
def _posix_directory_issues(path: Path) -> list[str]:
|
|
184
|
+
issues: list[str] = []
|
|
185
|
+
current_uid = os.getuid()
|
|
184
186
|
for directory in _posix_directory_chain(path):
|
|
185
|
-
if directory.is_symlink()
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
187
|
+
if directory.is_symlink():
|
|
188
|
+
issues.append(f"symbolic link: {directory}")
|
|
189
|
+
continue
|
|
190
|
+
if not directory.exists():
|
|
191
|
+
continue
|
|
192
|
+
if not directory.is_dir():
|
|
193
|
+
issues.append(f"not a directory: {directory}")
|
|
194
|
+
continue
|
|
195
|
+
details = directory.stat()
|
|
196
|
+
if details.st_uid != current_uid:
|
|
197
|
+
issues.append(f"not owned by the current user: {directory}")
|
|
198
|
+
if details.st_mode & (stat.S_IRWXG | stat.S_IRWXO):
|
|
199
|
+
issues.append(f"permissions are too broad: {directory}")
|
|
200
|
+
return issues
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _check_posix_directories(path: Path) -> None:
|
|
204
|
+
issues = _posix_directory_issues(path)
|
|
205
|
+
if issues:
|
|
206
|
+
raise DeploymentKitError("SSH directory chain is unsafe: " + "; ".join(issues))
|
|
189
207
|
|
|
190
208
|
|
|
191
209
|
def prepare_ssh_directory(path: Path) -> None:
|
|
192
|
-
"""Create
|
|
210
|
+
"""Create missing owned SSH directories safely without silently repairing existing unsafe state."""
|
|
193
211
|
_ssh_directory_for(path)
|
|
194
|
-
if
|
|
195
|
-
if path.
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
path.chmod(0o700)
|
|
201
|
-
_check_posix_directories(path / "placeholder")
|
|
202
|
-
else:
|
|
212
|
+
if os.name == "nt":
|
|
213
|
+
if path.exists():
|
|
214
|
+
if path.is_symlink() or not path.is_dir():
|
|
215
|
+
raise DeploymentKitError(f"SSH material directory is not a real directory: {path}")
|
|
216
|
+
else:
|
|
217
|
+
path.mkdir(parents=True, mode=0o700)
|
|
203
218
|
_check_windows_acl(path)
|
|
219
|
+
return
|
|
220
|
+
|
|
221
|
+
probe = path / "placeholder"
|
|
222
|
+
issues = _posix_directory_issues(probe)
|
|
223
|
+
if issues:
|
|
224
|
+
raise DeploymentKitError("SSH directory chain is unsafe: " + "; ".join(issues))
|
|
225
|
+
|
|
226
|
+
for directory in reversed(_posix_directory_chain(probe)):
|
|
227
|
+
if directory.exists():
|
|
228
|
+
continue
|
|
229
|
+
directory.mkdir(mode=0o700)
|
|
230
|
+
directory.chmod(0o700)
|
|
231
|
+
|
|
232
|
+
_check_posix_directories(probe)
|
|
204
233
|
|
|
205
234
|
|
|
206
235
|
def check_identity_security(identity: Path) -> None:
|
package/package.json
CHANGED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
const MANIFEST_RELATIVE = 'assets/bootstrap-migrations/managed-baseline-verifier/manifest.json';
|
|
6
|
+
const BOOTSTRAP_MANIFEST = 'PROJECT_BOOTSTRAP.json';
|
|
7
|
+
|
|
8
|
+
function readJson(file) {
|
|
9
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function gitBlobSha(data) {
|
|
13
|
+
const raw = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
|
14
|
+
const buffer = Buffer.from(raw.toString('utf8').replaceAll('\r\n', '\n'), 'utf8');
|
|
15
|
+
const header = Buffer.from(`blob ${buffer.length}\0`, 'utf8');
|
|
16
|
+
return crypto.createHash('sha1').update(header).update(buffer).digest('hex');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function safeRelative(value, label) {
|
|
20
|
+
if (typeof value !== 'string' || !value || path.isAbsolute(value)) {
|
|
21
|
+
throw new Error(`Unsafe ${label}: ${value}`);
|
|
22
|
+
}
|
|
23
|
+
const normalized = value.replaceAll('\\', '/');
|
|
24
|
+
if (normalized.split('/').some((part) => !part || part === '.' || part === '..')) {
|
|
25
|
+
throw new Error(`Unsafe ${label}: ${value}`);
|
|
26
|
+
}
|
|
27
|
+
return normalized;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function safeConsumerPath(root, relative) {
|
|
31
|
+
const normalized = safeRelative(relative, 'Bootstrap migration destination');
|
|
32
|
+
const resolvedRoot = path.resolve(root);
|
|
33
|
+
const resolved = path.resolve(resolvedRoot, ...normalized.split('/'));
|
|
34
|
+
if (resolved !== resolvedRoot && !resolved.startsWith(`${resolvedRoot}${path.sep}`)) {
|
|
35
|
+
throw new Error(`Unsafe Bootstrap migration destination: ${relative}`);
|
|
36
|
+
}
|
|
37
|
+
return resolved;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function loadManifest(packageData) {
|
|
41
|
+
const file = path.join(packageData.root, ...MANIFEST_RELATIVE.split('/'));
|
|
42
|
+
const manifest = readJson(file);
|
|
43
|
+
if (manifest.schemaVersion !== 1 || manifest.owner !== 'bootstrap') {
|
|
44
|
+
throw new Error('Invalid Bootstrap migration manifest ownership/schema');
|
|
45
|
+
}
|
|
46
|
+
if (manifest.source?.component !== 'bootstrap') {
|
|
47
|
+
throw new Error('Invalid Bootstrap migration logical source component');
|
|
48
|
+
}
|
|
49
|
+
if (!/^[0-9a-f]{40}$/.test(manifest.source?.revision ?? '')) {
|
|
50
|
+
throw new Error('Invalid Bootstrap migration source revision');
|
|
51
|
+
}
|
|
52
|
+
safeRelative(manifest.source?.artifact, 'Bootstrap migration source artifact');
|
|
53
|
+
if (!/^[0-9a-f]{40}$/.test(manifest.targetGitBlob ?? '')) {
|
|
54
|
+
throw new Error('Invalid Bootstrap migration target Git blob');
|
|
55
|
+
}
|
|
56
|
+
if (!Array.isArray(manifest.supportedPredecessorGitBlobs) || !manifest.supportedPredecessorGitBlobs.every((value) => /^[0-9a-f]{40}$/.test(value))) {
|
|
57
|
+
throw new Error('Invalid Bootstrap migration predecessor identities');
|
|
58
|
+
}
|
|
59
|
+
const assetRelative = safeRelative(manifest.asset, 'Bootstrap migration bundled asset');
|
|
60
|
+
const asset = path.join(packageData.root, ...assetRelative.split('/'));
|
|
61
|
+
const bytes = fs.readFileSync(asset);
|
|
62
|
+
if (gitBlobSha(bytes) !== manifest.targetGitBlob) {
|
|
63
|
+
throw new Error('Bundled Bootstrap migration asset identity does not match its manifest');
|
|
64
|
+
}
|
|
65
|
+
return { manifest, bytes };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function planBootstrapMigrations(packageData, consumerRoot) {
|
|
69
|
+
const bootstrapManifest = path.join(consumerRoot, BOOTSTRAP_MANIFEST);
|
|
70
|
+
if (!fs.existsSync(bootstrapManifest)) return { rows: [], unsafe: [] };
|
|
71
|
+
|
|
72
|
+
const { manifest } = loadManifest(packageData);
|
|
73
|
+
const destination = safeConsumerPath(consumerRoot, manifest.destination);
|
|
74
|
+
if (!fs.existsSync(destination)) {
|
|
75
|
+
return {
|
|
76
|
+
rows: [],
|
|
77
|
+
unsafe: [{
|
|
78
|
+
owner: 'bootstrap',
|
|
79
|
+
state: 'BOOTSTRAP_MIGRATION_UNSUPPORTED',
|
|
80
|
+
path: manifest.destination,
|
|
81
|
+
detail: 'Bootstrap-generated verifier is missing; refusing package mutation before a supported migration is established.'
|
|
82
|
+
}]
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const stat = fs.lstatSync(destination);
|
|
87
|
+
if (!stat.isFile() || stat.isSymbolicLink()) {
|
|
88
|
+
return {
|
|
89
|
+
rows: [],
|
|
90
|
+
unsafe: [{
|
|
91
|
+
owner: 'bootstrap',
|
|
92
|
+
state: 'BOOTSTRAP_MIGRATION_UNSUPPORTED',
|
|
93
|
+
path: manifest.destination,
|
|
94
|
+
detail: 'Bootstrap-generated verifier is not a regular file; refusing package mutation.'
|
|
95
|
+
}]
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
const realRoot = fs.realpathSync(consumerRoot);
|
|
99
|
+
const realDestination = fs.realpathSync(destination);
|
|
100
|
+
if (realDestination !== realRoot && !realDestination.startsWith(`${realRoot}${path.sep}`)) {
|
|
101
|
+
return {
|
|
102
|
+
rows: [],
|
|
103
|
+
unsafe: [{
|
|
104
|
+
owner: 'bootstrap',
|
|
105
|
+
state: 'BOOTSTRAP_MIGRATION_UNSUPPORTED',
|
|
106
|
+
path: manifest.destination,
|
|
107
|
+
detail: 'Bootstrap-generated verifier resolves outside the Project root; refusing package mutation.'
|
|
108
|
+
}]
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const currentGitBlob = gitBlobSha(fs.readFileSync(destination));
|
|
113
|
+
if (currentGitBlob === manifest.targetGitBlob) return { rows: [], unsafe: [] };
|
|
114
|
+
if (!manifest.supportedPredecessorGitBlobs.includes(currentGitBlob)) {
|
|
115
|
+
return {
|
|
116
|
+
rows: [],
|
|
117
|
+
unsafe: [{
|
|
118
|
+
owner: 'bootstrap',
|
|
119
|
+
state: 'BOOTSTRAP_MIGRATION_UNSUPPORTED',
|
|
120
|
+
path: manifest.destination,
|
|
121
|
+
detail: `Unknown/custom Bootstrap verifier identity ${currentGitBlob}; refusing to overwrite it.`
|
|
122
|
+
}]
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
rows: [{
|
|
128
|
+
owner: 'bootstrap',
|
|
129
|
+
state: 'MIGRATE_BOOTSTRAP_VERIFIER',
|
|
130
|
+
path: manifest.destination,
|
|
131
|
+
fromGitBlob: currentGitBlob,
|
|
132
|
+
toGitBlob: manifest.targetGitBlob,
|
|
133
|
+
source: manifest.source
|
|
134
|
+
}],
|
|
135
|
+
unsafe: []
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function applyBootstrapMigrations(packageData, migrationPlan, consumerRoot) {
|
|
140
|
+
if (!migrationPlan?.rows?.length) return;
|
|
141
|
+
if (migrationPlan.unsafe?.length) throw new Error('Refusing unsafe Bootstrap migration plan');
|
|
142
|
+
const { manifest, bytes } = loadManifest(packageData);
|
|
143
|
+
for (const row of migrationPlan.rows) {
|
|
144
|
+
if (row.state !== 'MIGRATE_BOOTSTRAP_VERIFIER' || row.path !== manifest.destination) {
|
|
145
|
+
throw new Error(`Unsupported Bootstrap migration row: ${row.state ?? 'unknown'}`);
|
|
146
|
+
}
|
|
147
|
+
const destination = safeConsumerPath(consumerRoot, row.path);
|
|
148
|
+
if (!fs.existsSync(destination) || fs.lstatSync(destination).isSymbolicLink() || !fs.lstatSync(destination).isFile()) {
|
|
149
|
+
throw new Error(`Bootstrap verifier changed after preview: ${row.path}`);
|
|
150
|
+
}
|
|
151
|
+
const currentGitBlob = gitBlobSha(fs.readFileSync(destination));
|
|
152
|
+
if (currentGitBlob !== row.fromGitBlob || !manifest.supportedPredecessorGitBlobs.includes(currentGitBlob)) {
|
|
153
|
+
throw new Error(`Bootstrap verifier changed after preview: ${row.path}`);
|
|
154
|
+
}
|
|
155
|
+
fs.writeFileSync(destination, bytes);
|
|
156
|
+
if (gitBlobSha(fs.readFileSync(destination)) !== manifest.targetGitBlob) {
|
|
157
|
+
throw new Error(`Bootstrap verifier migration verification failed: ${row.path}`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|