hermes: isolate Atlas SCM write authority
This commit is contained in:
parent
b0812bbe9f
commit
86e9fbbfec
@ -0,0 +1,18 @@
|
|||||||
|
# clusters/atlas/flux-system/applications/hermes-observer-rbac/kustomization.yaml
|
||||||
|
apiVersion: kustomize.toolkit.fluxcd.io/v1
|
||||||
|
kind: Kustomization
|
||||||
|
metadata:
|
||||||
|
name: hermes-observer-rbac
|
||||||
|
namespace: flux-system
|
||||||
|
spec:
|
||||||
|
interval: 10m
|
||||||
|
path: ./services/hermes-observer-rbac
|
||||||
|
prune: true
|
||||||
|
sourceRef:
|
||||||
|
kind: GitRepository
|
||||||
|
name: flux-system
|
||||||
|
namespace: flux-system
|
||||||
|
wait: true
|
||||||
|
timeout: 5m
|
||||||
|
dependsOn:
|
||||||
|
- name: hermes
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
# clusters/atlas/flux-system/applications/hermes-scm-broker-code/kustomization.yaml
|
||||||
|
apiVersion: kustomize.toolkit.fluxcd.io/v1
|
||||||
|
kind: Kustomization
|
||||||
|
metadata:
|
||||||
|
name: hermes-scm-broker-code
|
||||||
|
namespace: flux-system
|
||||||
|
spec:
|
||||||
|
interval: 10m
|
||||||
|
path: ./services/hermes/scm-common
|
||||||
|
targetNamespace: hermes-scm
|
||||||
|
prune: true
|
||||||
|
sourceRef:
|
||||||
|
kind: GitRepository
|
||||||
|
name: flux-system
|
||||||
|
namespace: flux-system
|
||||||
|
wait: true
|
||||||
|
timeout: 5m
|
||||||
|
dependsOn:
|
||||||
|
- name: hermes-scm-namespace
|
||||||
@ -0,0 +1,27 @@
|
|||||||
|
# clusters/atlas/flux-system/applications/hermes-scm-broker/kustomization.yaml
|
||||||
|
apiVersion: kustomize.toolkit.fluxcd.io/v1
|
||||||
|
kind: Kustomization
|
||||||
|
metadata:
|
||||||
|
name: hermes-scm-broker
|
||||||
|
namespace: flux-system
|
||||||
|
spec:
|
||||||
|
interval: 10m
|
||||||
|
path: ./services/hermes-scm-broker
|
||||||
|
targetNamespace: hermes-scm
|
||||||
|
prune: true
|
||||||
|
sourceRef:
|
||||||
|
kind: GitRepository
|
||||||
|
name: flux-system
|
||||||
|
namespace: flux-system
|
||||||
|
wait: true
|
||||||
|
timeout: 10m
|
||||||
|
healthChecks:
|
||||||
|
- apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
name: hermes-scm-broker
|
||||||
|
namespace: hermes-scm
|
||||||
|
dependsOn:
|
||||||
|
- name: vault
|
||||||
|
- name: gitea
|
||||||
|
- name: hermes-scm-namespace
|
||||||
|
- name: hermes-scm-broker-code
|
||||||
@ -0,0 +1,16 @@
|
|||||||
|
# clusters/atlas/flux-system/applications/hermes-scm-namespace/kustomization.yaml
|
||||||
|
apiVersion: kustomize.toolkit.fluxcd.io/v1
|
||||||
|
kind: Kustomization
|
||||||
|
metadata:
|
||||||
|
name: hermes-scm-namespace
|
||||||
|
namespace: flux-system
|
||||||
|
spec:
|
||||||
|
interval: 10m
|
||||||
|
path: ./services/hermes-scm-namespace
|
||||||
|
prune: true
|
||||||
|
sourceRef:
|
||||||
|
kind: GitRepository
|
||||||
|
name: flux-system
|
||||||
|
namespace: flux-system
|
||||||
|
wait: true
|
||||||
|
timeout: 5m
|
||||||
@ -60,3 +60,4 @@ spec:
|
|||||||
- name: keycloak
|
- name: keycloak
|
||||||
- name: longhorn
|
- name: longhorn
|
||||||
- name: vault
|
- name: vault
|
||||||
|
- name: hermes-scm-broker
|
||||||
|
|||||||
@ -27,7 +27,11 @@ resources:
|
|||||||
- jenkins/kustomization.yaml
|
- jenkins/kustomization.yaml
|
||||||
- ai-llm/kustomization.yaml
|
- ai-llm/kustomization.yaml
|
||||||
- openclaw/kustomization.yaml
|
- openclaw/kustomization.yaml
|
||||||
|
- hermes-scm-namespace/kustomization.yaml
|
||||||
|
- hermes-scm-broker-code/kustomization.yaml
|
||||||
- hermes/kustomization.yaml
|
- hermes/kustomization.yaml
|
||||||
|
- hermes-observer-rbac/kustomization.yaml
|
||||||
|
- hermes-scm-broker/kustomization.yaml
|
||||||
- hermes-chat/kustomization.yaml
|
- hermes-chat/kustomization.yaml
|
||||||
- hermes-triage-demo/kustomization.yaml
|
- hermes-triage-demo/kustomization.yaml
|
||||||
- game-stream/kustomization.yaml
|
- game-stream/kustomization.yaml
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
apiVersion: batch/v1
|
apiVersion: batch/v1
|
||||||
kind: Job
|
kind: Job
|
||||||
metadata:
|
metadata:
|
||||||
name: gitea-atlas-identity-bootstrap-3
|
name: gitea-atlas-identity-bootstrap-4
|
||||||
namespace: gitea
|
namespace: gitea
|
||||||
labels:
|
labels:
|
||||||
app.kubernetes.io/name: gitea
|
app.kubernetes.io/name: gitea
|
||||||
|
|||||||
@ -19,6 +19,7 @@ source_owner="${GITEA_SOURCE_OWNER:-bstein}"
|
|||||||
managed_repositories="${GITEA_MANAGED_REPOSITORIES:-hermes-code-demo cassandra soteria pegasus metis ananke ariadne typhon titan-iac}"
|
managed_repositories="${GITEA_MANAGED_REPOSITORIES:-hermes-code-demo cassandra soteria pegasus metis ananke ariadne typhon titan-iac}"
|
||||||
contributor_team=Contributors
|
contributor_team=Contributors
|
||||||
contributor_units='["repo.actions","repo.packages","repo.code","repo.issues","repo.wiki","repo.pulls","repo.releases","repo.projects"]'
|
contributor_units='["repo.actions","repo.packages","repo.code","repo.issues","repo.wiki","repo.pulls","repo.releases","repo.projects"]'
|
||||||
|
protected_reviewer=bstein
|
||||||
|
|
||||||
die() {
|
die() {
|
||||||
echo "Gitea Atlas identity bootstrap failed: $*" >&2
|
echo "Gitea Atlas identity bootstrap failed: $*" >&2
|
||||||
@ -272,6 +273,63 @@ transfer_managed_repositories() {
|
|||||||
trap - EXIT HUP INT TERM
|
trap - EXIT HUP INT TERM
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ensure_default_branch_protection() {
|
||||||
|
repository=$1
|
||||||
|
repo_file=$(mktemp)
|
||||||
|
protections_file=$(mktemp)
|
||||||
|
protection_file=$(mktemp)
|
||||||
|
response_file=$(mktemp)
|
||||||
|
|
||||||
|
status=$(api_request GET "/repos/${organization}/${repository}" '' "${repo_file}")
|
||||||
|
expect_status "${status}" 200 "read ${repository} metadata for branch protection"
|
||||||
|
default_branch=$(sed -n 's/.*"default_branch":"\([^"]*\)".*/\1/p' "${repo_file}")
|
||||||
|
case "${default_branch}" in
|
||||||
|
main|master) ;;
|
||||||
|
*) die "${repository} has unsupported default branch ${default_branch:-missing}" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
status=$(api_request GET "/repos/${organization}/${repository}/branch_protections" '' "${protections_file}")
|
||||||
|
expect_status "${status}" 200 "list ${repository} branch protections"
|
||||||
|
tr -d '[:space:]' <"${protections_file}" | sed 's/},{/}\
|
||||||
|
{/g' | grep -F "\"rule_name\":\"${default_branch}\"" >"${protection_file}" || true
|
||||||
|
matching_rules=$(grep -c . "${protection_file}" || true)
|
||||||
|
[ "${matching_rules}" -le 1 ] || die "${repository} has duplicate ${default_branch} protection rules"
|
||||||
|
|
||||||
|
if [ "${matching_rules}" -eq 0 ]; then
|
||||||
|
payload="{\"rule_name\":\"${default_branch}\",\"enable_push\":true,\"enable_push_whitelist\":true,\"push_whitelist_usernames\":[\"${protected_reviewer}\"],\"push_whitelist_teams\":[],\"push_whitelist_deploy_keys\":false,\"enable_force_push\":false,\"enable_force_push_allowlist\":false,\"force_push_allowlist_usernames\":[],\"force_push_allowlist_teams\":[],\"force_push_allowlist_deploy_keys\":false,\"enable_merge_whitelist\":true,\"merge_whitelist_usernames\":[\"${protected_reviewer}\"],\"merge_whitelist_teams\":[],\"enable_approvals_whitelist\":true,\"approvals_whitelist_username\":[\"${protected_reviewer}\"],\"approvals_whitelist_teams\":[],\"required_approvals\":1,\"block_on_rejected_reviews\":true,\"block_on_outdated_branch\":true,\"dismiss_stale_approvals\":true,\"block_admin_merge_override\":true}"
|
||||||
|
status=$(api_request POST "/repos/${organization}/${repository}/branch_protections" "${payload}" "${response_file}")
|
||||||
|
expect_status "${status}" 201 "protect ${repository} ${default_branch}"
|
||||||
|
status=$(api_request GET "/repos/${organization}/${repository}/branch_protections" '' "${protections_file}")
|
||||||
|
expect_status "${status}" 200 "read back ${repository} branch protections"
|
||||||
|
tr -d '[:space:]' <"${protections_file}" | sed 's/},{/}\
|
||||||
|
{/g' | grep -F "\"rule_name\":\"${default_branch}\"" >"${protection_file}" || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
compact=$(tr -d '[:space:]' <"${protection_file}")
|
||||||
|
for required in \
|
||||||
|
"\"rule_name\":\"${default_branch}\"" \
|
||||||
|
'"enable_push":true' \
|
||||||
|
'"enable_push_whitelist":true' \
|
||||||
|
"\"push_whitelist_usernames\":[\"${protected_reviewer}\"]" \
|
||||||
|
'"push_whitelist_deploy_keys":false' \
|
||||||
|
'"enable_force_push":false' \
|
||||||
|
'"enable_merge_whitelist":true' \
|
||||||
|
"\"merge_whitelist_usernames\":[\"${protected_reviewer}\"]" \
|
||||||
|
'"enable_approvals_whitelist":true' \
|
||||||
|
"\"approvals_whitelist_username\":[\"${protected_reviewer}\"]" \
|
||||||
|
'"required_approvals":1' \
|
||||||
|
'"block_on_rejected_reviews":true' \
|
||||||
|
'"block_on_outdated_branch":true' \
|
||||||
|
'"dismiss_stale_approvals":true' \
|
||||||
|
'"block_admin_merge_override":true'
|
||||||
|
do
|
||||||
|
printf '%s' "${compact}" | grep -Fq "${required}" || \
|
||||||
|
die "${repository} ${default_branch} protection differs from the human-review policy"
|
||||||
|
done
|
||||||
|
|
||||||
|
rm -f "${repo_file}" "${protections_file}" "${protection_file}" "${response_file}"
|
||||||
|
}
|
||||||
|
|
||||||
wait_for_gitea
|
wait_for_gitea
|
||||||
ensure_user "${reconciler_user}" "${reconciler_email}" true
|
ensure_user "${reconciler_user}" "${reconciler_email}" true
|
||||||
ensure_user "${hermes_user}" "${hermes_email}" false
|
ensure_user "${hermes_user}" "${hermes_email}" false
|
||||||
@ -318,6 +376,10 @@ expect_status "${status}" 204 "add Hermes to Atlas contributors"
|
|||||||
|
|
||||||
transfer_managed_repositories
|
transfer_managed_repositories
|
||||||
|
|
||||||
|
for repository in ${managed_repositories}; do
|
||||||
|
ensure_default_branch_protection "${repository}"
|
||||||
|
done
|
||||||
|
|
||||||
vault_write gitea/atlas-reconciler "{\"data\":{\"username\":\"${reconciler_user}\",\"token\":\"${reconciler_token}\",\"base_url\":\"${public_url}\"}}"
|
vault_write gitea/atlas-reconciler "{\"data\":{\"username\":\"${reconciler_user}\",\"token\":\"${reconciler_token}\",\"base_url\":\"${public_url}\"}}"
|
||||||
vault_write hermes/developer-gitea "{\"data\":{\"username\":\"${hermes_user}\",\"email\":\"${hermes_email}\",\"token\":\"${hermes_token}\",\"base_url\":\"${public_url}\"}}"
|
vault_write hermes/developer-gitea "{\"data\":{\"username\":\"${hermes_user}\",\"email\":\"${hermes_email}\",\"token\":\"${hermes_token}\",\"base_url\":\"${public_url}\"}}"
|
||||||
|
|
||||||
|
|||||||
6
services/hermes-observer-rbac/kustomization.yaml
Normal file
6
services/hermes-observer-rbac/kustomization.yaml
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
# services/hermes-observer-rbac/kustomization.yaml
|
||||||
|
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||||
|
kind: Kustomization
|
||||||
|
resources:
|
||||||
|
- rbac.yaml
|
||||||
|
- rolebindings.yaml
|
||||||
112
services/hermes-observer-rbac/rbac.yaml
Normal file
112
services/hermes-observer-rbac/rbac.yaml
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
# services/hermes-observer-rbac/rbac.yaml
|
||||||
|
apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: ClusterRole
|
||||||
|
metadata:
|
||||||
|
name: hermes-agent-cluster-observer-v2
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: hermes-agent
|
||||||
|
app.kubernetes.io/part-of: hermes
|
||||||
|
rules:
|
||||||
|
- apiGroups: [""]
|
||||||
|
resources:
|
||||||
|
- namespaces
|
||||||
|
- nodes
|
||||||
|
- persistentvolumes
|
||||||
|
verbs: ["get", "list", "watch"]
|
||||||
|
- apiGroups: ["networking.k8s.io"]
|
||||||
|
resources: ["ingressclasses"]
|
||||||
|
verbs: ["get", "list", "watch"]
|
||||||
|
- apiGroups: ["storage.k8s.io"]
|
||||||
|
resources:
|
||||||
|
- csidrivers
|
||||||
|
- csinodes
|
||||||
|
- storageclasses
|
||||||
|
- volumeattachments
|
||||||
|
verbs: ["get", "list", "watch"]
|
||||||
|
- apiGroups: ["metrics.k8s.io"]
|
||||||
|
resources: ["nodes"]
|
||||||
|
verbs: ["get", "list"]
|
||||||
|
---
|
||||||
|
apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: ClusterRole
|
||||||
|
metadata:
|
||||||
|
name: hermes-agent-namespaced-observer-v2
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: hermes-agent
|
||||||
|
app.kubernetes.io/part-of: hermes
|
||||||
|
rules:
|
||||||
|
- apiGroups: [""]
|
||||||
|
resources:
|
||||||
|
- configmaps
|
||||||
|
- endpoints
|
||||||
|
- events
|
||||||
|
- persistentvolumeclaims
|
||||||
|
- pods
|
||||||
|
- pods/log
|
||||||
|
- replicationcontrollers
|
||||||
|
- resourcequotas
|
||||||
|
- services
|
||||||
|
verbs: ["get", "list", "watch"]
|
||||||
|
- apiGroups: ["apps"]
|
||||||
|
resources:
|
||||||
|
- controllerrevisions
|
||||||
|
- daemonsets
|
||||||
|
- deployments
|
||||||
|
- replicasets
|
||||||
|
- statefulsets
|
||||||
|
verbs: ["get", "list", "watch"]
|
||||||
|
- apiGroups: ["batch"]
|
||||||
|
resources: ["cronjobs", "jobs"]
|
||||||
|
verbs: ["get", "list", "watch"]
|
||||||
|
- apiGroups: ["autoscaling"]
|
||||||
|
resources: ["horizontalpodautoscalers"]
|
||||||
|
verbs: ["get", "list", "watch"]
|
||||||
|
- apiGroups: ["networking.k8s.io"]
|
||||||
|
resources: ["ingresses", "networkpolicies"]
|
||||||
|
verbs: ["get", "list", "watch"]
|
||||||
|
- apiGroups: ["discovery.k8s.io"]
|
||||||
|
resources: ["endpointslices"]
|
||||||
|
verbs: ["get", "list", "watch"]
|
||||||
|
- apiGroups: ["policy"]
|
||||||
|
resources: ["poddisruptionbudgets"]
|
||||||
|
verbs: ["get", "list", "watch"]
|
||||||
|
- apiGroups: ["storage.k8s.io"]
|
||||||
|
resources: ["csistoragecapacities"]
|
||||||
|
verbs: ["get", "list", "watch"]
|
||||||
|
- apiGroups: ["metrics.k8s.io"]
|
||||||
|
resources: ["pods"]
|
||||||
|
verbs: ["get", "list"]
|
||||||
|
- apiGroups:
|
||||||
|
- helm.toolkit.fluxcd.io
|
||||||
|
- image.toolkit.fluxcd.io
|
||||||
|
- kustomize.toolkit.fluxcd.io
|
||||||
|
- notification.toolkit.fluxcd.io
|
||||||
|
- source.toolkit.fluxcd.io
|
||||||
|
resources: ["*"]
|
||||||
|
verbs: ["get", "list", "watch"]
|
||||||
|
- apiGroups: ["longhorn.io"]
|
||||||
|
resources:
|
||||||
|
- backingimages
|
||||||
|
- engines
|
||||||
|
- instancemanagers
|
||||||
|
- nodes
|
||||||
|
- replicas
|
||||||
|
- settings
|
||||||
|
- volumes
|
||||||
|
verbs: ["get", "list", "watch"]
|
||||||
|
---
|
||||||
|
apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: ClusterRoleBinding
|
||||||
|
metadata:
|
||||||
|
name: hermes-agent-cluster-observer-v2
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: hermes-agent
|
||||||
|
app.kubernetes.io/part-of: hermes
|
||||||
|
subjects:
|
||||||
|
- kind: ServiceAccount
|
||||||
|
name: hermes-agent
|
||||||
|
namespace: hermes
|
||||||
|
roleRef:
|
||||||
|
apiGroup: rbac.authorization.k8s.io
|
||||||
|
kind: ClusterRole
|
||||||
|
name: hermes-agent-cluster-observer-v2
|
||||||
204
services/hermes-observer-rbac/rolebindings.yaml
Normal file
204
services/hermes-observer-rbac/rolebindings.yaml
Normal file
@ -0,0 +1,204 @@
|
|||||||
|
# services/hermes-observer-rbac/rolebindings.yaml
|
||||||
|
apiVersion: v1
|
||||||
|
kind: List
|
||||||
|
items:
|
||||||
|
- apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: RoleBinding
|
||||||
|
metadata: {name: hermes-agent-observer, namespace: ai}
|
||||||
|
subjects: [{kind: ServiceAccount, name: hermes-agent, namespace: hermes}]
|
||||||
|
roleRef: {apiGroup: rbac.authorization.k8s.io, kind: ClusterRole, name: hermes-agent-namespaced-observer-v2}
|
||||||
|
- apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: RoleBinding
|
||||||
|
metadata: {name: hermes-agent-observer, namespace: bstein-dev-home}
|
||||||
|
subjects: [{kind: ServiceAccount, name: hermes-agent, namespace: hermes}]
|
||||||
|
roleRef: {apiGroup: rbac.authorization.k8s.io, kind: ClusterRole, name: hermes-agent-namespaced-observer-v2}
|
||||||
|
- apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: RoleBinding
|
||||||
|
metadata: {name: hermes-agent-observer, namespace: cassandra}
|
||||||
|
subjects: [{kind: ServiceAccount, name: hermes-agent, namespace: hermes}]
|
||||||
|
roleRef: {apiGroup: rbac.authorization.k8s.io, kind: ClusterRole, name: hermes-agent-namespaced-observer-v2}
|
||||||
|
- apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: RoleBinding
|
||||||
|
metadata: {name: hermes-agent-observer, namespace: cert-manager}
|
||||||
|
subjects: [{kind: ServiceAccount, name: hermes-agent, namespace: hermes}]
|
||||||
|
roleRef: {apiGroup: rbac.authorization.k8s.io, kind: ClusterRole, name: hermes-agent-namespaced-observer-v2}
|
||||||
|
- apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: RoleBinding
|
||||||
|
metadata: {name: hermes-agent-observer, namespace: climate}
|
||||||
|
subjects: [{kind: ServiceAccount, name: hermes-agent, namespace: hermes}]
|
||||||
|
roleRef: {apiGroup: rbac.authorization.k8s.io, kind: ClusterRole, name: hermes-agent-namespaced-observer-v2}
|
||||||
|
- apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: RoleBinding
|
||||||
|
metadata: {name: hermes-agent-observer, namespace: comms}
|
||||||
|
subjects: [{kind: ServiceAccount, name: hermes-agent, namespace: hermes}]
|
||||||
|
roleRef: {apiGroup: rbac.authorization.k8s.io, kind: ClusterRole, name: hermes-agent-namespaced-observer-v2}
|
||||||
|
- apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: RoleBinding
|
||||||
|
metadata: {name: hermes-agent-observer, namespace: crypto}
|
||||||
|
subjects: [{kind: ServiceAccount, name: hermes-agent, namespace: hermes}]
|
||||||
|
roleRef: {apiGroup: rbac.authorization.k8s.io, kind: ClusterRole, name: hermes-agent-namespaced-observer-v2}
|
||||||
|
- apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: RoleBinding
|
||||||
|
metadata: {name: hermes-agent-observer, namespace: default}
|
||||||
|
subjects: [{kind: ServiceAccount, name: hermes-agent, namespace: hermes}]
|
||||||
|
roleRef: {apiGroup: rbac.authorization.k8s.io, kind: ClusterRole, name: hermes-agent-namespaced-observer-v2}
|
||||||
|
- apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: RoleBinding
|
||||||
|
metadata: {name: hermes-agent-observer, namespace: finance}
|
||||||
|
subjects: [{kind: ServiceAccount, name: hermes-agent, namespace: hermes}]
|
||||||
|
roleRef: {apiGroup: rbac.authorization.k8s.io, kind: ClusterRole, name: hermes-agent-namespaced-observer-v2}
|
||||||
|
- apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: RoleBinding
|
||||||
|
metadata: {name: hermes-agent-observer, namespace: flux-system}
|
||||||
|
subjects: [{kind: ServiceAccount, name: hermes-agent, namespace: hermes}]
|
||||||
|
roleRef: {apiGroup: rbac.authorization.k8s.io, kind: ClusterRole, name: hermes-agent-namespaced-observer-v2}
|
||||||
|
- apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: RoleBinding
|
||||||
|
metadata: {name: hermes-agent-observer, namespace: game-stream}
|
||||||
|
subjects: [{kind: ServiceAccount, name: hermes-agent, namespace: hermes}]
|
||||||
|
roleRef: {apiGroup: rbac.authorization.k8s.io, kind: ClusterRole, name: hermes-agent-namespaced-observer-v2}
|
||||||
|
- apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: RoleBinding
|
||||||
|
metadata: {name: hermes-agent-observer, namespace: gitea}
|
||||||
|
subjects: [{kind: ServiceAccount, name: hermes-agent, namespace: hermes}]
|
||||||
|
roleRef: {apiGroup: rbac.authorization.k8s.io, kind: ClusterRole, name: hermes-agent-namespaced-observer-v2}
|
||||||
|
- apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: RoleBinding
|
||||||
|
metadata: {name: hermes-agent-observer, namespace: harbor}
|
||||||
|
subjects: [{kind: ServiceAccount, name: hermes-agent, namespace: hermes}]
|
||||||
|
roleRef: {apiGroup: rbac.authorization.k8s.io, kind: ClusterRole, name: hermes-agent-namespaced-observer-v2}
|
||||||
|
- apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: RoleBinding
|
||||||
|
metadata: {name: hermes-agent-observer, namespace: health}
|
||||||
|
subjects: [{kind: ServiceAccount, name: hermes-agent, namespace: hermes}]
|
||||||
|
roleRef: {apiGroup: rbac.authorization.k8s.io, kind: ClusterRole, name: hermes-agent-namespaced-observer-v2}
|
||||||
|
- apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: RoleBinding
|
||||||
|
metadata: {name: hermes-agent-observer, namespace: hermes}
|
||||||
|
subjects: [{kind: ServiceAccount, name: hermes-agent, namespace: hermes}]
|
||||||
|
roleRef: {apiGroup: rbac.authorization.k8s.io, kind: ClusterRole, name: hermes-agent-namespaced-observer-v2}
|
||||||
|
- apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: RoleBinding
|
||||||
|
metadata: {name: hermes-agent-observer, namespace: hermes-chat}
|
||||||
|
subjects: [{kind: ServiceAccount, name: hermes-agent, namespace: hermes}]
|
||||||
|
roleRef: {apiGroup: rbac.authorization.k8s.io, kind: ClusterRole, name: hermes-agent-namespaced-observer-v2}
|
||||||
|
- apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: RoleBinding
|
||||||
|
metadata: {name: hermes-agent-observer, namespace: hermes-triage-demo}
|
||||||
|
subjects: [{kind: ServiceAccount, name: hermes-agent, namespace: hermes}]
|
||||||
|
roleRef: {apiGroup: rbac.authorization.k8s.io, kind: ClusterRole, name: hermes-agent-namespaced-observer-v2}
|
||||||
|
- apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: RoleBinding
|
||||||
|
metadata: {name: hermes-agent-observer, namespace: jellyfin}
|
||||||
|
subjects: [{kind: ServiceAccount, name: hermes-agent, namespace: hermes}]
|
||||||
|
roleRef: {apiGroup: rbac.authorization.k8s.io, kind: ClusterRole, name: hermes-agent-namespaced-observer-v2}
|
||||||
|
- apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: RoleBinding
|
||||||
|
metadata: {name: hermes-agent-observer, namespace: jenkins}
|
||||||
|
subjects: [{kind: ServiceAccount, name: hermes-agent, namespace: hermes}]
|
||||||
|
roleRef: {apiGroup: rbac.authorization.k8s.io, kind: ClusterRole, name: hermes-agent-namespaced-observer-v2}
|
||||||
|
- apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: RoleBinding
|
||||||
|
metadata: {name: hermes-agent-observer, namespace: kube-node-lease}
|
||||||
|
subjects: [{kind: ServiceAccount, name: hermes-agent, namespace: hermes}]
|
||||||
|
roleRef: {apiGroup: rbac.authorization.k8s.io, kind: ClusterRole, name: hermes-agent-namespaced-observer-v2}
|
||||||
|
- apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: RoleBinding
|
||||||
|
metadata: {name: hermes-agent-observer, namespace: kube-public}
|
||||||
|
subjects: [{kind: ServiceAccount, name: hermes-agent, namespace: hermes}]
|
||||||
|
roleRef: {apiGroup: rbac.authorization.k8s.io, kind: ClusterRole, name: hermes-agent-namespaced-observer-v2}
|
||||||
|
- apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: RoleBinding
|
||||||
|
metadata: {name: hermes-agent-observer, namespace: kube-system}
|
||||||
|
subjects: [{kind: ServiceAccount, name: hermes-agent, namespace: hermes}]
|
||||||
|
roleRef: {apiGroup: rbac.authorization.k8s.io, kind: ClusterRole, name: hermes-agent-namespaced-observer-v2}
|
||||||
|
- apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: RoleBinding
|
||||||
|
metadata: {name: hermes-agent-observer, namespace: logging}
|
||||||
|
subjects: [{kind: ServiceAccount, name: hermes-agent, namespace: hermes}]
|
||||||
|
roleRef: {apiGroup: rbac.authorization.k8s.io, kind: ClusterRole, name: hermes-agent-namespaced-observer-v2}
|
||||||
|
- apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: RoleBinding
|
||||||
|
metadata: {name: hermes-agent-observer, namespace: longhorn-system}
|
||||||
|
subjects: [{kind: ServiceAccount, name: hermes-agent, namespace: hermes}]
|
||||||
|
roleRef: {apiGroup: rbac.authorization.k8s.io, kind: ClusterRole, name: hermes-agent-namespaced-observer-v2}
|
||||||
|
- apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: RoleBinding
|
||||||
|
metadata: {name: hermes-agent-observer, namespace: mailu-mailserver}
|
||||||
|
subjects: [{kind: ServiceAccount, name: hermes-agent, namespace: hermes}]
|
||||||
|
roleRef: {apiGroup: rbac.authorization.k8s.io, kind: ClusterRole, name: hermes-agent-namespaced-observer-v2}
|
||||||
|
- apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: RoleBinding
|
||||||
|
metadata: {name: hermes-agent-observer, namespace: maintenance}
|
||||||
|
subjects: [{kind: ServiceAccount, name: hermes-agent, namespace: hermes}]
|
||||||
|
roleRef: {apiGroup: rbac.authorization.k8s.io, kind: ClusterRole, name: hermes-agent-namespaced-observer-v2}
|
||||||
|
- apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: RoleBinding
|
||||||
|
metadata: {name: hermes-agent-observer, namespace: metallb-system}
|
||||||
|
subjects: [{kind: ServiceAccount, name: hermes-agent, namespace: hermes}]
|
||||||
|
roleRef: {apiGroup: rbac.authorization.k8s.io, kind: ClusterRole, name: hermes-agent-namespaced-observer-v2}
|
||||||
|
- apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: RoleBinding
|
||||||
|
metadata: {name: hermes-agent-observer, namespace: monitoring}
|
||||||
|
subjects: [{kind: ServiceAccount, name: hermes-agent, namespace: hermes}]
|
||||||
|
roleRef: {apiGroup: rbac.authorization.k8s.io, kind: ClusterRole, name: hermes-agent-namespaced-observer-v2}
|
||||||
|
- apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: RoleBinding
|
||||||
|
metadata: {name: hermes-agent-observer, namespace: nextcloud}
|
||||||
|
subjects: [{kind: ServiceAccount, name: hermes-agent, namespace: hermes}]
|
||||||
|
roleRef: {apiGroup: rbac.authorization.k8s.io, kind: ClusterRole, name: hermes-agent-namespaced-observer-v2}
|
||||||
|
- apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: RoleBinding
|
||||||
|
metadata: {name: hermes-agent-observer, namespace: openclaw}
|
||||||
|
subjects: [{kind: ServiceAccount, name: hermes-agent, namespace: hermes}]
|
||||||
|
roleRef: {apiGroup: rbac.authorization.k8s.io, kind: ClusterRole, name: hermes-agent-namespaced-observer-v2}
|
||||||
|
- apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: RoleBinding
|
||||||
|
metadata: {name: hermes-agent-observer, namespace: outline}
|
||||||
|
subjects: [{kind: ServiceAccount, name: hermes-agent, namespace: hermes}]
|
||||||
|
roleRef: {apiGroup: rbac.authorization.k8s.io, kind: ClusterRole, name: hermes-agent-namespaced-observer-v2}
|
||||||
|
- apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: RoleBinding
|
||||||
|
metadata: {name: hermes-agent-observer, namespace: planka}
|
||||||
|
subjects: [{kind: ServiceAccount, name: hermes-agent, namespace: hermes}]
|
||||||
|
roleRef: {apiGroup: rbac.authorization.k8s.io, kind: ClusterRole, name: hermes-agent-namespaced-observer-v2}
|
||||||
|
- apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: RoleBinding
|
||||||
|
metadata: {name: hermes-agent-observer, namespace: postgres}
|
||||||
|
subjects: [{kind: ServiceAccount, name: hermes-agent, namespace: hermes}]
|
||||||
|
roleRef: {apiGroup: rbac.authorization.k8s.io, kind: ClusterRole, name: hermes-agent-namespaced-observer-v2}
|
||||||
|
- apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: RoleBinding
|
||||||
|
metadata: {name: hermes-agent-observer, namespace: quality}
|
||||||
|
subjects: [{kind: ServiceAccount, name: hermes-agent, namespace: hermes}]
|
||||||
|
roleRef: {apiGroup: rbac.authorization.k8s.io, kind: ClusterRole, name: hermes-agent-namespaced-observer-v2}
|
||||||
|
- apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: RoleBinding
|
||||||
|
metadata: {name: hermes-agent-observer, namespace: sso}
|
||||||
|
subjects: [{kind: ServiceAccount, name: hermes-agent, namespace: hermes}]
|
||||||
|
roleRef: {apiGroup: rbac.authorization.k8s.io, kind: ClusterRole, name: hermes-agent-namespaced-observer-v2}
|
||||||
|
- apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: RoleBinding
|
||||||
|
metadata: {name: hermes-agent-observer, namespace: sui-metrics}
|
||||||
|
subjects: [{kind: ServiceAccount, name: hermes-agent, namespace: hermes}]
|
||||||
|
roleRef: {apiGroup: rbac.authorization.k8s.io, kind: ClusterRole, name: hermes-agent-namespaced-observer-v2}
|
||||||
|
- apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: RoleBinding
|
||||||
|
metadata: {name: hermes-agent-observer, namespace: traefik}
|
||||||
|
subjects: [{kind: ServiceAccount, name: hermes-agent, namespace: hermes}]
|
||||||
|
roleRef: {apiGroup: rbac.authorization.k8s.io, kind: ClusterRole, name: hermes-agent-namespaced-observer-v2}
|
||||||
|
- apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: RoleBinding
|
||||||
|
metadata: {name: hermes-agent-observer, namespace: vault}
|
||||||
|
subjects: [{kind: ServiceAccount, name: hermes-agent, namespace: hermes}]
|
||||||
|
roleRef: {apiGroup: rbac.authorization.k8s.io, kind: ClusterRole, name: hermes-agent-namespaced-observer-v2}
|
||||||
|
- apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: RoleBinding
|
||||||
|
metadata: {name: hermes-agent-observer, namespace: vaultwarden}
|
||||||
|
subjects: [{kind: ServiceAccount, name: hermes-agent, namespace: hermes}]
|
||||||
|
roleRef: {apiGroup: rbac.authorization.k8s.io, kind: ClusterRole, name: hermes-agent-namespaced-observer-v2}
|
||||||
|
- apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: RoleBinding
|
||||||
|
metadata: {name: hermes-agent-observer, namespace: veles}
|
||||||
|
subjects: [{kind: ServiceAccount, name: hermes-agent, namespace: hermes}]
|
||||||
|
roleRef: {apiGroup: rbac.authorization.k8s.io, kind: ClusterRole, name: hermes-agent-namespaced-observer-v2}
|
||||||
97
services/hermes-scm-broker/deployment.yaml
Normal file
97
services/hermes-scm-broker/deployment.yaml
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
# services/hermes-scm-broker/deployment.yaml
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: hermes-scm-broker
|
||||||
|
namespace: hermes-scm
|
||||||
|
labels:
|
||||||
|
app: hermes-scm-broker
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
revisionHistoryLimit: 2
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: hermes-scm-broker
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: hermes-scm-broker
|
||||||
|
annotations:
|
||||||
|
vault.hashicorp.com/agent-inject: "true"
|
||||||
|
vault.hashicorp.com/role: hermes-scm-broker
|
||||||
|
vault.hashicorp.com/agent-inject-secret-gitea-token: kv/data/atlas/hermes/developer-gitea
|
||||||
|
vault.hashicorp.com/agent-inject-template-gitea-token: |
|
||||||
|
{{- with secret "kv/data/atlas/hermes/developer-gitea" -}}
|
||||||
|
{{ .Data.data.token }}
|
||||||
|
{{- end }}
|
||||||
|
vault.hashicorp.com/agent-pre-populate-only: "true"
|
||||||
|
vault.hashicorp.com/agent-init-first: "true"
|
||||||
|
vault.hashicorp.com/agent-requests-cpu: 10m
|
||||||
|
vault.hashicorp.com/agent-requests-mem: 32Mi
|
||||||
|
vault.hashicorp.com/agent-limits-cpu: 50m
|
||||||
|
vault.hashicorp.com/agent-limits-mem: 64Mi
|
||||||
|
spec:
|
||||||
|
serviceAccountName: hermes-scm-broker
|
||||||
|
automountServiceAccountToken: true
|
||||||
|
securityContext:
|
||||||
|
fsGroup: 10000
|
||||||
|
fsGroupChangePolicy: OnRootMismatch
|
||||||
|
seccompProfile:
|
||||||
|
type: RuntimeDefault
|
||||||
|
affinity:
|
||||||
|
nodeAffinity:
|
||||||
|
requiredDuringSchedulingIgnoredDuringExecution:
|
||||||
|
nodeSelectorTerms:
|
||||||
|
- matchExpressions:
|
||||||
|
- key: kubernetes.io/arch
|
||||||
|
operator: In
|
||||||
|
values: [arm64]
|
||||||
|
- key: node-role.kubernetes.io/worker
|
||||||
|
operator: In
|
||||||
|
values: ["true"]
|
||||||
|
preferredDuringSchedulingIgnoredDuringExecution:
|
||||||
|
- weight: 100
|
||||||
|
preference:
|
||||||
|
matchExpressions:
|
||||||
|
- key: hardware
|
||||||
|
operator: In
|
||||||
|
values: [rpi5]
|
||||||
|
containers:
|
||||||
|
- name: broker
|
||||||
|
image: registry.bstein.dev/bstein/hermes-agent
|
||||||
|
imagePullPolicy: IfNotPresent
|
||||||
|
command:
|
||||||
|
- /opt/hermes/.venv/bin/python
|
||||||
|
- /opt/broker/scm_broker.py
|
||||||
|
ports:
|
||||||
|
- {name: http, containerPort: 9081, protocol: TCP}
|
||||||
|
readinessProbe:
|
||||||
|
httpGet: {path: /healthz, port: http}
|
||||||
|
initialDelaySeconds: 3
|
||||||
|
periodSeconds: 10
|
||||||
|
livenessProbe:
|
||||||
|
httpGet: {path: /healthz, port: http}
|
||||||
|
initialDelaySeconds: 15
|
||||||
|
periodSeconds: 30
|
||||||
|
securityContext:
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
capabilities:
|
||||||
|
drop: [ALL]
|
||||||
|
readOnlyRootFilesystem: true
|
||||||
|
runAsNonRoot: true
|
||||||
|
runAsUser: 10000
|
||||||
|
runAsGroup: 10000
|
||||||
|
volumeMounts:
|
||||||
|
- {name: broker-code, mountPath: /opt/broker, readOnly: true}
|
||||||
|
- {name: tmp, mountPath: /tmp}
|
||||||
|
resources:
|
||||||
|
requests: {cpu: 50m, memory: 128Mi}
|
||||||
|
limits: {cpu: "1", memory: 768Mi}
|
||||||
|
volumes:
|
||||||
|
- name: broker-code
|
||||||
|
configMap:
|
||||||
|
name: hermes-scm-boundary
|
||||||
|
defaultMode: 0555
|
||||||
|
- name: tmp
|
||||||
|
emptyDir:
|
||||||
|
sizeLimit: 1Gi
|
||||||
11
services/hermes-scm-broker/kustomization.yaml
Normal file
11
services/hermes-scm-broker/kustomization.yaml
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
# services/hermes-scm-broker/kustomization.yaml
|
||||||
|
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||||
|
kind: Kustomization
|
||||||
|
namespace: hermes-scm
|
||||||
|
resources:
|
||||||
|
- service.yaml
|
||||||
|
- deployment.yaml
|
||||||
|
- networkpolicy.yaml
|
||||||
|
images:
|
||||||
|
- name: registry.bstein.dev/bstein/hermes-agent
|
||||||
|
digest: sha256:37ebf720c783ae908a602916ffccf88d43d205a157957f5dc4b487867aee45e7
|
||||||
46
services/hermes-scm-broker/networkpolicy.yaml
Normal file
46
services/hermes-scm-broker/networkpolicy.yaml
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
# services/hermes-scm-broker/networkpolicy.yaml
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: NetworkPolicy
|
||||||
|
metadata:
|
||||||
|
name: hermes-scm-broker-isolation
|
||||||
|
namespace: hermes-scm
|
||||||
|
spec:
|
||||||
|
podSelector:
|
||||||
|
matchLabels:
|
||||||
|
app: hermes-scm-broker
|
||||||
|
policyTypes: [Ingress, Egress]
|
||||||
|
ingress:
|
||||||
|
- from:
|
||||||
|
- namespaceSelector:
|
||||||
|
matchLabels:
|
||||||
|
kubernetes.io/metadata.name: hermes
|
||||||
|
podSelector:
|
||||||
|
matchLabels:
|
||||||
|
app: hermes-agent
|
||||||
|
ports:
|
||||||
|
- {protocol: TCP, port: 9081}
|
||||||
|
egress:
|
||||||
|
- to:
|
||||||
|
- namespaceSelector:
|
||||||
|
matchLabels:
|
||||||
|
kubernetes.io/metadata.name: kube-system
|
||||||
|
podSelector:
|
||||||
|
matchLabels:
|
||||||
|
k8s-app: kube-dns
|
||||||
|
ports:
|
||||||
|
- {protocol: UDP, port: 53}
|
||||||
|
- {protocol: TCP, port: 53}
|
||||||
|
- to:
|
||||||
|
- namespaceSelector:
|
||||||
|
matchLabels:
|
||||||
|
kubernetes.io/metadata.name: vault
|
||||||
|
podSelector:
|
||||||
|
matchLabels:
|
||||||
|
app: vault
|
||||||
|
ports:
|
||||||
|
- {protocol: TCP, port: 8200}
|
||||||
|
- to:
|
||||||
|
- ipBlock:
|
||||||
|
cidr: 192.168.22.9/32
|
||||||
|
ports:
|
||||||
|
- {protocol: TCP, port: 443}
|
||||||
13
services/hermes-scm-broker/service.yaml
Normal file
13
services/hermes-scm-broker/service.yaml
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
# services/hermes-scm-broker/service.yaml
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: hermes-scm-broker
|
||||||
|
namespace: hermes-scm
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: hermes-scm-broker
|
||||||
|
ports:
|
||||||
|
- name: http
|
||||||
|
port: 9081
|
||||||
|
targetPort: http
|
||||||
5
services/hermes-scm-namespace/kustomization.yaml
Normal file
5
services/hermes-scm-namespace/kustomization.yaml
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
# services/hermes-scm-namespace/kustomization.yaml
|
||||||
|
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||||
|
kind: Kustomization
|
||||||
|
resources:
|
||||||
|
- namespace.yaml
|
||||||
14
services/hermes-scm-namespace/namespace.yaml
Normal file
14
services/hermes-scm-namespace/namespace.yaml
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
# services/hermes-scm-namespace/namespace.yaml
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Namespace
|
||||||
|
metadata:
|
||||||
|
name: hermes-scm
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: hermes-scm-broker
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: ServiceAccount
|
||||||
|
metadata:
|
||||||
|
name: hermes-scm-broker
|
||||||
|
namespace: hermes-scm
|
||||||
|
automountServiceAccountToken: true
|
||||||
@ -189,9 +189,9 @@ data:
|
|||||||
not replace these coordinator-wide rules. Never call `kanban_show` without
|
not replace these coordinator-wide rules. Never call `kanban_show` without
|
||||||
a known, non-empty task ID. Ad-hoc inspection and acceptance checks do not
|
a known, non-empty task ID. Ad-hoc inspection and acceptance checks do not
|
||||||
need a synthetic Kanban lookup, and must load a skill only when its workflow
|
need a synthetic Kanban lookup, and must load a skill only when its workflow
|
||||||
materially applies. Atlas HTTPS Git authentication is already supplied by
|
materially applies. Atlas Git access is supplied by the isolated SCM
|
||||||
the runtime-only `GIT_ASKPASS`; use it without reading or exposing the
|
broker; no repository token is present in this pod. Use only configured
|
||||||
credential. Coordinator guidance lives at
|
broker remotes and clients. Coordinator guidance lives at
|
||||||
`/opt/data/workspace/AGENTS.md` when more detail is needed.
|
`/opt/data/workspace/AGENTS.md` when more detail is needed.
|
||||||
|
|
||||||
The Jetson classifier is mandatory for AUTO selection. Switchyard may use
|
The Jetson classifier is mandatory for AUTO selection. Switchyard may use
|
||||||
@ -295,21 +295,21 @@ data:
|
|||||||
objective's difficulty warrants it; otherwise synthesize at the original
|
objective's difficulty warrants it; otherwise synthesize at the original
|
||||||
effort.
|
effort.
|
||||||
|
|
||||||
This owner-only pod has cluster-admin access across Atlas, including logs,
|
This owner-only pod has scoped read-only Kubernetes diagnostics across
|
||||||
Secrets, exec, port-forwarding, rollout operations, and Flux reconciliation.
|
Atlas, including resource status, events, logs, and Flux/Helm evidence. It
|
||||||
Prefer the titan-iac Git/Flux workflow for every durable cluster change;
|
cannot read Secrets, exec or attach to pods, create service-account tokens,
|
||||||
direct operations are available for explicit operator requests, incident
|
mutate workloads or RBAC, or reconcile Flux. Put every durable cluster
|
||||||
recovery, and verification, and must be followed by a matching source-of-
|
change on a reviewed titan-iac branch. Never expose credentials in chat or
|
||||||
truth change when they alter desired state. Never expose credentials in
|
logs. Triage belongs at triage.hermes.bstein.dev.
|
||||||
chat or logs. Triage belongs at triage.hermes.bstein.dev.
|
|
||||||
|
|
||||||
## Atlas engineering access
|
## Atlas engineering access
|
||||||
|
|
||||||
The Atlas organization has private visibility. Repository visibility is
|
The Atlas organization has private visibility. Repository visibility is
|
||||||
preserved per project and may be public or private; do not infer a
|
preserved per project and may be public or private; do not infer a
|
||||||
repository's visibility from the organization setting. Repositories are
|
repository's visibility from the organization setting. Repositories are
|
||||||
canonical at `https://scm.bstein.dev/atlas/<repo>.git`. HTTPS Git
|
canonical at `https://scm.bstein.dev/atlas/<repo>.git`; worker Git traffic
|
||||||
authentication is already supplied through `GIT_ASKPASS`. Verify the remote
|
uses `http://hermes-scm-broker.hermes-scm.svc.cluster.local:9081/git/atlas/<repo>.git`.
|
||||||
|
Verify the broker remote
|
||||||
and cleanly separate pre-existing changes, create a task branch, run the
|
and cleanly separate pre-existing changes, create a task branch, run the
|
||||||
repository's tests, use `git push --dry-run` when proving access, and push a
|
repository's tests, use `git push --dry-run` when proving access, and push a
|
||||||
real branch only when the requested implementation is review-ready. Never
|
real branch only when the requested implementation is review-ready. Never
|
||||||
@ -318,11 +318,13 @@ data:
|
|||||||
`scm.bstein.dev` is Forgejo/Gitea, not GitHub. Never load or follow a
|
`scm.bstein.dev` is Forgejo/Gitea, not GitHub. Never load or follow a
|
||||||
GitHub/`gh` skill for an Atlas remote, and do not interpret an
|
GitHub/`gh` skill for an Atlas remote, and do not interpret an
|
||||||
unauthenticated Gitea HTTP 404 as a missing private repository. Use
|
unauthenticated Gitea HTTP 404 as a missing private repository. Use
|
||||||
authenticated Git for clone, fetch, and non-force push. For bounded
|
brokered Git for clone, fetch, and creation of a new namespaced feature
|
||||||
|
branch. Existing-ref updates, protected refs, deletion, and force-push are
|
||||||
|
rejected. For bounded
|
||||||
repository/pull-request evidence or to create a review-ready draft PR, load
|
repository/pull-request evidence or to create a review-ready draft PR, load
|
||||||
`$manage-atlas-pull-requests` and use `/opt/coordinator/gitea_api.py`. The
|
`$manage-atlas-pull-requests` and use `/opt/scm/gitea_api.py`. The
|
||||||
client injects the runtime Vault token without exposing it to the command
|
client carries no repository credential; a separate least-authority
|
||||||
line, environment, output, or transcript. It permits only Atlas-scoped
|
workload performs the allowed operation. It permits only Atlas-scoped
|
||||||
metadata reads and verified same-repository draft creation. Updates, merge,
|
metadata reads and verified same-repository draft creation. Updates, merge,
|
||||||
approve, close, delete, comments, repository administration, and force-push
|
approve, close, delete, comments, repository administration, and force-push
|
||||||
are unavailable; never bypass the client with raw HTTP. Leave every PR
|
are unavailable; never bypass the client with raw HTTP. Leave every PR
|
||||||
@ -330,14 +332,10 @@ data:
|
|||||||
verify the exact base/head ancestry and diff, run the relevant tests, and
|
verify the exact base/head ancestry and diff, run the relevant tests, and
|
||||||
confirm the candidate Jenkins result before presenting a handoff. Prefer
|
confirm the candidate Jenkins result before presenting a handoff. Prefer
|
||||||
the internal endpoint in
|
the internal endpoint in
|
||||||
`JENKINS_BASE_URL`; when Jenkins API authorization prevents a read, use the
|
`JENKINS_BASE_URL`; when Jenkins API authorization prevents a read, use
|
||||||
existing cluster access to inspect the controller's job/build files and
|
Kubernetes pod logs and status evidence without exec or secret access.
|
||||||
logs rather than guessing a public hostname. The preferred read-only path
|
Do not bypass the boundary to inspect controller files. If Brad separately
|
||||||
is `/opt/coordinator/jenkins_build_evidence.py JOB [--branch BRANCH]
|
reports that he merged the PR, observe
|
||||||
[--commit SHA] --wait`; it distinguishes a genuinely terminal build from
|
|
||||||
nested Jenkins execution metadata and returns bounded JSON/log evidence.
|
|
||||||
With a branch, the helper also tries the conventional `JOB-branches`
|
|
||||||
multibranch name. If Brad separately reports that he merged the PR, observe
|
|
||||||
the default-branch build to a terminal result and report exact commit,
|
the default-branch build to a terminal result and report exact commit,
|
||||||
build, and test evidence.
|
build, and test evidence.
|
||||||
|
|
||||||
@ -349,19 +347,21 @@ data:
|
|||||||
|
|
||||||
The terminal PATH contains the pinned operator tools. Start cluster work
|
The terminal PATH contains the pinned operator tools. Start cluster work
|
||||||
with `kubectl config current-context`, read-only status/events/logs, and the
|
with `kubectl config current-context`, read-only status/events/logs, and the
|
||||||
relevant `titan-iac` manifests. Put durable desired-state changes on a
|
relevant `titan-iac` manifests. The broker namespace is deliberately
|
||||||
|
excluded from agent RBAC. Kubernetes cannot express a deny on one namespace
|
||||||
|
inside an all-namespace list, so enumerate namespaces and run namespaced
|
||||||
|
diagnostics instead of relying on `kubectl ... --all-namespaces`. Put
|
||||||
|
durable desired-state changes on a
|
||||||
reviewable `titan-iac` branch and validate Kustomize and client dry-run.
|
reviewable `titan-iac` branch and validate Kustomize and client dry-run.
|
||||||
Brad owns merge and routine Flux reconciliation; perform either only under
|
Brad owns merge and Flux reconciliation. Direct `kubectl` mutation is not
|
||||||
a separate explicit operator request. Direct `kubectl` mutations are for
|
available to the agent; hand an explicit incident mutation back to Brad.
|
||||||
explicit incident recovery or ephemeral verification, not ordinary
|
|
||||||
delivery.
|
|
||||||
|
|
||||||
Node SSH uses a dedicated audited Hermes identity: `ssh titan-04` (or any
|
Node SSH uses the dedicated locked-password `hermes-agent` OS account:
|
||||||
current Kubernetes node name). Host keys are pinned and password fallback
|
`ssh titan-04` (or any current Kubernetes node name). Host keys are pinned,
|
||||||
is disabled. Use SSH only for host-level evidence or repairs that cannot be
|
password fallback is disabled, and the account has no sudo, disk, runtime,
|
||||||
performed through Kubernetes. Read first, identify the exact node and
|
or Kubernetes-storage authority. Use it for unprivileged host evidence;
|
||||||
impact, avoid fleet-wide destructive commands, and reflect persistent host
|
hand privileged host repair back to Brad and reflect persistent changes in
|
||||||
configuration in the appropriate tracked provisioning source.
|
the tracked provisioning source.
|
||||||
|
|
||||||
Treat every credential, SSH identity, host-trust record, and
|
Treat every credential, SSH identity, host-trust record, and
|
||||||
credential-bearing access-client state as runtime-only Vault data. Keep
|
credential-bearing access-client state as runtime-only Vault data. Keep
|
||||||
@ -398,7 +398,7 @@ data:
|
|||||||
<low|medium|high|xhigh> [model]` for a persistent override. The first native
|
<low|medium|high|xhigh> [model]` for a persistent override. The first native
|
||||||
Codex worker requires one device-code login; refreshed provider credentials
|
Codex worker requires one device-code login; refreshed provider credentials
|
||||||
persist through Vault while client caches remain disposable. The owner
|
persist through Vault while client caches remain disposable. The owner
|
||||||
workspace includes cluster-admin Kubernetes
|
workspace includes scoped read-only Kubernetes diagnostics plus `kubectl`,
|
||||||
access plus `kubectl`, `flux`, `helm`, `kustomize`, `vault`, `sops`, `age`,
|
`flux`, `helm`, `kustomize`, `vault`, `sops`, `age`,
|
||||||
`terraform`, `k9s`, `jq`, `yq`, `gh`, Git, SSH, Python, Node, the
|
`terraform`, `k9s`, `jq`, `yq`, `gh`, Git, SSH, Python, Node, the
|
||||||
browser/computer tools, and the native provider CLIs.
|
browser/computer tools, and the native provider CLIs.
|
||||||
|
|||||||
@ -46,16 +46,6 @@ spec:
|
|||||||
{{- with secret "kv/data/atlas/hermes/agent-tokens" -}}
|
{{- with secret "kv/data/atlas/hermes/agent-tokens" -}}
|
||||||
{{ .Data.data.codex_auth_json }}
|
{{ .Data.data.codex_auth_json }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
vault.hashicorp.com/agent-inject-secret-gitea-token: kv/data/atlas/hermes/developer-gitea
|
|
||||||
vault.hashicorp.com/agent-inject-template-gitea-token: |
|
|
||||||
{{- with secret "kv/data/atlas/hermes/developer-gitea" -}}
|
|
||||||
{{ .Data.data.token }}
|
|
||||||
{{- end }}
|
|
||||||
vault.hashicorp.com/agent-inject-secret-gitea-username: kv/data/atlas/hermes/developer-gitea
|
|
||||||
vault.hashicorp.com/agent-inject-template-gitea-username: |
|
|
||||||
{{- with secret "kv/data/atlas/hermes/developer-gitea" -}}
|
|
||||||
{{ .Data.data.username }}
|
|
||||||
{{- end }}
|
|
||||||
vault.hashicorp.com/agent-inject-secret-node-ssh-private-key: kv/data/atlas/hermes/developer-ssh
|
vault.hashicorp.com/agent-inject-secret-node-ssh-private-key: kv/data/atlas/hermes/developer-ssh
|
||||||
vault.hashicorp.com/agent-inject-template-node-ssh-private-key: |
|
vault.hashicorp.com/agent-inject-template-node-ssh-private-key: |
|
||||||
{{- with secret "kv/data/atlas/hermes/developer-ssh" -}}
|
{{- with secret "kv/data/atlas/hermes/developer-ssh" -}}
|
||||||
@ -185,9 +175,8 @@ spec:
|
|||||||
chmod 0600 "${profile_env}"
|
chmod 0600 "${profile_env}"
|
||||||
chown 10000:10000 "${profile_env}"
|
chown 10000:10000 "${profile_env}"
|
||||||
done
|
done
|
||||||
upsert_env GIT_ASKPASS /opt/coordinator/gitea_askpass.sh
|
|
||||||
upsert_env GIT_TERMINAL_PROMPT 0
|
upsert_env GIT_TERMINAL_PROMPT 0
|
||||||
upsert_env GITEA_BASE_URL https://scm.bstein.dev
|
upsert_env HERMES_SCM_BROKER_URL http://hermes-scm-broker.hermes-scm.svc.cluster.local:9081
|
||||||
upsert_env JENKINS_BASE_URL http://jenkins.jenkins.svc.cluster.local:8080
|
upsert_env JENKINS_BASE_URL http://jenkins.jenkins.svc.cluster.local:8080
|
||||||
upsert_env ARIADNE_BASE_URL http://ariadne.maintenance.svc.cluster.local
|
upsert_env ARIADNE_BASE_URL http://ariadne.maintenance.svc.cluster.local
|
||||||
upsert_env VICTORIA_METRICS_URL http://victoria-metrics-single-server.monitoring.svc.cluster.local:8428
|
upsert_env VICTORIA_METRICS_URL http://victoria-metrics-single-server.monitoring.svc.cluster.local:8428
|
||||||
@ -663,6 +652,7 @@ spec:
|
|||||||
- {name: kubeconfig, mountPath: /opt/data/home/.kube/config, subPath: config, readOnly: true}
|
- {name: kubeconfig, mountPath: /opt/data/home/.kube/config, subPath: config, readOnly: true}
|
||||||
- {name: auto-router-plugin, mountPath: /opt/data/plugins/auto-router, readOnly: true}
|
- {name: auto-router-plugin, mountPath: /opt/data/plugins/auto-router, readOnly: true}
|
||||||
- {name: atlas-pr-skill, mountPath: /opt/data/workspace/skills/manage-atlas-pull-requests, readOnly: true}
|
- {name: atlas-pr-skill, mountPath: /opt/data/workspace/skills/manage-atlas-pull-requests, readOnly: true}
|
||||||
|
- {name: scm-boundary, mountPath: /opt/scm, readOnly: true}
|
||||||
- {name: routing-catalog, mountPath: /routing-catalog, readOnly: true}
|
- {name: routing-catalog, mountPath: /routing-catalog, readOnly: true}
|
||||||
- {name: tmp, mountPath: /tmp}
|
- {name: tmp, mountPath: /tmp}
|
||||||
startupProbe:
|
startupProbe:
|
||||||
@ -827,6 +817,7 @@ spec:
|
|||||||
- {name: kubeconfig, mountPath: /opt/data/home/.kube/config, subPath: config, readOnly: true}
|
- {name: kubeconfig, mountPath: /opt/data/home/.kube/config, subPath: config, readOnly: true}
|
||||||
- {name: auto-router-plugin, mountPath: /opt/data/plugins/auto-router, readOnly: true}
|
- {name: auto-router-plugin, mountPath: /opt/data/plugins/auto-router, readOnly: true}
|
||||||
- {name: atlas-pr-skill, mountPath: /opt/data/workspace/skills/manage-atlas-pull-requests, readOnly: true}
|
- {name: atlas-pr-skill, mountPath: /opt/data/workspace/skills/manage-atlas-pull-requests, readOnly: true}
|
||||||
|
- {name: scm-boundary, mountPath: /opt/scm, readOnly: true}
|
||||||
- {name: routing-catalog, mountPath: /routing-catalog, readOnly: true}
|
- {name: routing-catalog, mountPath: /routing-catalog, readOnly: true}
|
||||||
- {name: tmp, mountPath: /tmp}
|
- {name: tmp, mountPath: /tmp}
|
||||||
- {name: ttyd-index, mountPath: /ttyd-index, readOnly: true}
|
- {name: ttyd-index, mountPath: /ttyd-index, readOnly: true}
|
||||||
@ -896,6 +887,7 @@ spec:
|
|||||||
- {name: kubeconfig, mountPath: /opt/data/home/.kube/config, subPath: config, readOnly: true}
|
- {name: kubeconfig, mountPath: /opt/data/home/.kube/config, subPath: config, readOnly: true}
|
||||||
- {name: auto-router-plugin, mountPath: /opt/data/plugins/auto-router, readOnly: true}
|
- {name: auto-router-plugin, mountPath: /opt/data/plugins/auto-router, readOnly: true}
|
||||||
- {name: atlas-pr-skill, mountPath: /opt/data/workspace/skills/manage-atlas-pull-requests, readOnly: true}
|
- {name: atlas-pr-skill, mountPath: /opt/data/workspace/skills/manage-atlas-pull-requests, readOnly: true}
|
||||||
|
- {name: scm-boundary, mountPath: /opt/scm, readOnly: true}
|
||||||
- {name: tmp, mountPath: /tmp}
|
- {name: tmp, mountPath: /tmp}
|
||||||
resources:
|
resources:
|
||||||
requests: {cpu: 100m, memory: 256Mi}
|
requests: {cpu: 100m, memory: 256Mi}
|
||||||
@ -1196,6 +1188,10 @@ spec:
|
|||||||
items:
|
items:
|
||||||
- {key: SKILL.md, path: SKILL.md}
|
- {key: SKILL.md, path: SKILL.md}
|
||||||
- {key: openai.yaml, path: agents/openai.yaml}
|
- {key: openai.yaml, path: agents/openai.yaml}
|
||||||
|
- name: scm-boundary
|
||||||
|
configMap:
|
||||||
|
name: hermes-scm-boundary
|
||||||
|
defaultMode: 0555
|
||||||
- name: image-policy
|
- name: image-policy
|
||||||
configMap:
|
configMap:
|
||||||
name: hermes-image-policy
|
name: hermes-image-policy
|
||||||
|
|||||||
@ -11,9 +11,9 @@ users:
|
|||||||
user:
|
user:
|
||||||
tokenFile: /var/run/secrets/kubernetes.io/serviceaccount/token
|
tokenFile: /var/run/secrets/kubernetes.io/serviceaccount/token
|
||||||
contexts:
|
contexts:
|
||||||
- name: atlas-owner
|
- name: atlas-observer
|
||||||
context:
|
context:
|
||||||
cluster: atlas
|
cluster: atlas
|
||||||
user: hermes-agent
|
user: hermes-agent
|
||||||
namespace: default
|
namespace: default
|
||||||
current-context: atlas-owner
|
current-context: atlas-observer
|
||||||
|
|||||||
@ -1,16 +0,0 @@
|
|||||||
# services/hermes/agent-rbac.yaml
|
|
||||||
apiVersion: rbac.authorization.k8s.io/v1
|
|
||||||
kind: ClusterRoleBinding
|
|
||||||
metadata:
|
|
||||||
name: hermes-agent-cluster-admin
|
|
||||||
labels:
|
|
||||||
app.kubernetes.io/name: hermes-agent
|
|
||||||
app.kubernetes.io/part-of: hermes
|
|
||||||
subjects:
|
|
||||||
- kind: ServiceAccount
|
|
||||||
name: hermes-agent
|
|
||||||
namespace: hermes
|
|
||||||
roleRef:
|
|
||||||
apiGroup: rbac.authorization.k8s.io
|
|
||||||
kind: ClusterRole
|
|
||||||
name: cluster-admin
|
|
||||||
@ -7,13 +7,13 @@ images:
|
|||||||
digest: sha256:37ebf720c783ae908a602916ffccf88d43d205a157957f5dc4b487867aee45e7
|
digest: sha256:37ebf720c783ae908a602916ffccf88d43d205a157957f5dc4b487867aee45e7
|
||||||
resources:
|
resources:
|
||||||
- namespace.yaml
|
- namespace.yaml
|
||||||
|
- scm-common
|
||||||
- vault-serviceaccount.yaml
|
- vault-serviceaccount.yaml
|
||||||
- configmap.yaml
|
- configmap.yaml
|
||||||
- agent-configmap.yaml
|
- agent-configmap.yaml
|
||||||
- chat-configmap.yaml
|
- chat-configmap.yaml
|
||||||
- switchyard-configmap.yaml
|
- switchyard-configmap.yaml
|
||||||
- rbac.yaml
|
- rbac.yaml
|
||||||
- agent-rbac.yaml
|
|
||||||
- node-ssh-access.yaml
|
- node-ssh-access.yaml
|
||||||
- pvc.yaml
|
- pvc.yaml
|
||||||
- switchyard-pvc.yaml
|
- switchyard-pvc.yaml
|
||||||
@ -69,8 +69,6 @@ configMapGenerator:
|
|||||||
- classifier_broker.py=scripts/classifier_broker.py
|
- classifier_broker.py=scripts/classifier_broker.py
|
||||||
- claude_oauth_broker.py=scripts/claude_oauth_broker.py
|
- claude_oauth_broker.py=scripts/claude_oauth_broker.py
|
||||||
- worker_route_broker.py=scripts/worker_route_broker.py
|
- worker_route_broker.py=scripts/worker_route_broker.py
|
||||||
- gitea_api.py=scripts/gitea_api.py
|
|
||||||
- gitea_askpass.sh=scripts/gitea_askpass.sh
|
|
||||||
- hermes_coordinator.py=scripts/hermes_coordinator.py
|
- hermes_coordinator.py=scripts/hermes_coordinator.py
|
||||||
- hermes_model_routing.py=scripts/hermes_model_routing.py
|
- hermes_model_routing.py=scripts/hermes_model_routing.py
|
||||||
- hermes_stt_client.py=scripts/hermes_stt_client.py
|
- hermes_stt_client.py=scripts/hermes_stt_client.py
|
||||||
@ -104,6 +102,12 @@ configMapGenerator:
|
|||||||
- config=agent-kubeconfig.yaml
|
- config=agent-kubeconfig.yaml
|
||||||
options:
|
options:
|
||||||
disableNameSuffixHash: true
|
disableNameSuffixHash: true
|
||||||
|
- name: hermes-node-account-hardener
|
||||||
|
namespace: hermes
|
||||||
|
files:
|
||||||
|
- node_account_hardening.py=scripts/node_account_hardening.py
|
||||||
|
options:
|
||||||
|
disableNameSuffixHash: true
|
||||||
- name: hermes-auto-router-plugin
|
- name: hermes-auto-router-plugin
|
||||||
namespace: hermes
|
namespace: hermes
|
||||||
files:
|
files:
|
||||||
|
|||||||
@ -119,11 +119,65 @@ spec:
|
|||||||
app: server
|
app: server
|
||||||
ports:
|
ports:
|
||||||
- {protocol: TCP, port: 9010}
|
- {protocol: TCP, port: 9010}
|
||||||
# agent.hermes.bstein.dev is an owner-only engineering workstation. The
|
|
||||||
# browser boundary remains OAuth-protected, while its workers need to reach
|
|
||||||
# every cluster namespace, Atlas LAN service, and hosted provider endpoint.
|
|
||||||
egress:
|
egress:
|
||||||
- {}
|
- to:
|
||||||
|
- namespaceSelector:
|
||||||
|
matchLabels:
|
||||||
|
kubernetes.io/metadata.name: kube-system
|
||||||
|
podSelector:
|
||||||
|
matchLabels:
|
||||||
|
k8s-app: kube-dns
|
||||||
|
ports:
|
||||||
|
- {protocol: UDP, port: 53}
|
||||||
|
- {protocol: TCP, port: 53}
|
||||||
|
- to:
|
||||||
|
- namespaceSelector:
|
||||||
|
matchExpressions:
|
||||||
|
- key: kubernetes.io/metadata.name
|
||||||
|
operator: NotIn
|
||||||
|
values: [gitea, hermes-scm]
|
||||||
|
- to:
|
||||||
|
- namespaceSelector:
|
||||||
|
matchLabels:
|
||||||
|
kubernetes.io/metadata.name: hermes-scm
|
||||||
|
podSelector:
|
||||||
|
matchLabels:
|
||||||
|
app: hermes-scm-broker
|
||||||
|
ports:
|
||||||
|
- {protocol: TCP, port: 9081}
|
||||||
|
- to:
|
||||||
|
- ipBlock:
|
||||||
|
cidr: 10.43.0.1/32
|
||||||
|
ports:
|
||||||
|
- {protocol: TCP, port: 443}
|
||||||
|
- to:
|
||||||
|
- ipBlock:
|
||||||
|
cidr: 192.168.0.0/16
|
||||||
|
except:
|
||||||
|
- 192.168.22.9/32
|
||||||
|
- to:
|
||||||
|
- ipBlock:
|
||||||
|
cidr: 0.0.0.0/0
|
||||||
|
except:
|
||||||
|
- 10.0.0.0/8
|
||||||
|
- 100.64.0.0/10
|
||||||
|
- 127.0.0.0/8
|
||||||
|
- 169.254.0.0/16
|
||||||
|
- 172.16.0.0/12
|
||||||
|
- 192.168.0.0/16
|
||||||
|
---
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: NetworkPolicy
|
||||||
|
metadata:
|
||||||
|
name: hermes-node-ssh-access-isolation
|
||||||
|
namespace: hermes
|
||||||
|
spec:
|
||||||
|
podSelector:
|
||||||
|
matchLabels:
|
||||||
|
app: hermes-node-ssh-access
|
||||||
|
policyTypes: [Ingress, Egress]
|
||||||
|
ingress: []
|
||||||
|
egress: []
|
||||||
---
|
---
|
||||||
apiVersion: networking.k8s.io/v1
|
apiVersion: networking.k8s.io/v1
|
||||||
kind: NetworkPolicy
|
kind: NetworkPolicy
|
||||||
|
|||||||
@ -45,32 +45,15 @@ spec:
|
|||||||
- operator: Exists
|
- operator: Exists
|
||||||
containers:
|
containers:
|
||||||
- name: key-reconciler
|
- name: key-reconciler
|
||||||
image: busybox:1.37
|
image: registry.bstein.dev/bstein/hermes-agent
|
||||||
imagePullPolicy: IfNotPresent
|
imagePullPolicy: IfNotPresent
|
||||||
command: [/bin/sh, -ec]
|
command: [/bin/sh, -ec]
|
||||||
args:
|
args:
|
||||||
- |
|
- |
|
||||||
reconcile() {
|
|
||||||
key="$(cat /vault/secrets/node-ssh-public-key)"
|
|
||||||
found=0
|
|
||||||
for user in atlas oceanus; do
|
|
||||||
home="/host-home/${user}"
|
|
||||||
[ -d "${home}" ] || continue
|
|
||||||
found=1
|
|
||||||
identity="$(awk -F: -v name="${user}" '$1 == name {print $3 ":" $4; exit}' /host-etc/passwd)"
|
|
||||||
[ -n "${identity}" ] || identity="$(stat -c %u:%g "${home}")"
|
|
||||||
uid="${identity%%:*}"
|
|
||||||
gid="${identity##*:}"
|
|
||||||
install -d -m 0700 -o "${uid}" -g "${gid}" "${home}/.ssh"
|
|
||||||
touch "${home}/.ssh/authorized_keys"
|
|
||||||
grep -qxF "${key}" "${home}/.ssh/authorized_keys" || printf '%s\n' "${key}" >> "${home}/.ssh/authorized_keys"
|
|
||||||
chown "${uid}:${gid}" "${home}/.ssh/authorized_keys"
|
|
||||||
chmod 0600 "${home}/.ssh/authorized_keys"
|
|
||||||
done
|
|
||||||
[ "${found}" = 1 ] || { echo "no supported node SSH account found" >&2; return 1; }
|
|
||||||
}
|
|
||||||
while true; do
|
while true; do
|
||||||
reconcile
|
/opt/hermes/.venv/bin/python \
|
||||||
|
/opt/node-hardener/node_account_hardening.py \
|
||||||
|
--public-key-file /vault/secrets/node-ssh-public-key
|
||||||
sleep 300
|
sleep 300
|
||||||
done
|
done
|
||||||
securityContext:
|
securityContext:
|
||||||
@ -83,8 +66,18 @@ spec:
|
|||||||
volumeMounts:
|
volumeMounts:
|
||||||
- name: host-home
|
- name: host-home
|
||||||
mountPath: /host-home
|
mountPath: /host-home
|
||||||
- name: host-passwd
|
- name: host-etc
|
||||||
mountPath: /host-etc/passwd
|
mountPath: /host-etc
|
||||||
|
- name: host-k3s
|
||||||
|
mountPath: /host-k3s
|
||||||
|
- name: host-kubelet
|
||||||
|
mountPath: /host-kubelet
|
||||||
|
- name: host-run-k3s
|
||||||
|
mountPath: /host-run-k3s
|
||||||
|
- name: host-run-containerd
|
||||||
|
mountPath: /host-run-containerd
|
||||||
|
- name: coordinator
|
||||||
|
mountPath: /opt/node-hardener
|
||||||
readOnly: true
|
readOnly: true
|
||||||
- name: vault-secrets
|
- name: vault-secrets
|
||||||
mountPath: /vault/secrets
|
mountPath: /vault/secrets
|
||||||
@ -94,19 +87,39 @@ spec:
|
|||||||
resources:
|
resources:
|
||||||
requests:
|
requests:
|
||||||
cpu: 5m
|
cpu: 5m
|
||||||
memory: 8Mi
|
memory: 32Mi
|
||||||
limits:
|
limits:
|
||||||
cpu: 50m
|
cpu: 50m
|
||||||
memory: 32Mi
|
memory: 128Mi
|
||||||
volumes:
|
volumes:
|
||||||
- name: host-home
|
- name: host-home
|
||||||
hostPath:
|
hostPath:
|
||||||
path: /home
|
path: /home
|
||||||
type: Directory
|
type: Directory
|
||||||
- name: host-passwd
|
- name: host-etc
|
||||||
hostPath:
|
hostPath:
|
||||||
path: /etc/passwd
|
path: /etc
|
||||||
type: File
|
type: Directory
|
||||||
|
- name: host-k3s
|
||||||
|
hostPath:
|
||||||
|
path: /var/lib/rancher/k3s
|
||||||
|
type: Directory
|
||||||
|
- name: host-kubelet
|
||||||
|
hostPath:
|
||||||
|
path: /var/lib/kubelet
|
||||||
|
type: Directory
|
||||||
|
- name: host-run-k3s
|
||||||
|
hostPath:
|
||||||
|
path: /run/k3s
|
||||||
|
type: Directory
|
||||||
|
- name: host-run-containerd
|
||||||
|
hostPath:
|
||||||
|
path: /run/containerd
|
||||||
|
type: Directory
|
||||||
|
- name: coordinator
|
||||||
|
configMap:
|
||||||
|
name: hermes-node-account-hardener
|
||||||
|
defaultMode: 0555
|
||||||
- name: vault-secrets
|
- name: vault-secrets
|
||||||
csi:
|
csi:
|
||||||
driver: secrets-store.csi.k8s.io
|
driver: secrets-store.csi.k8s.io
|
||||||
|
|||||||
12
services/hermes/scm-common/kustomization.yaml
Normal file
12
services/hermes/scm-common/kustomization.yaml
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
# services/hermes/scm-common/kustomization.yaml
|
||||||
|
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||||
|
kind: Kustomization
|
||||||
|
configMapGenerator:
|
||||||
|
- name: hermes-scm-boundary
|
||||||
|
files:
|
||||||
|
- gitea_api.py=scripts/gitea_api.py
|
||||||
|
- gitea_api_policy.py=scripts/gitea_api_policy.py
|
||||||
|
- scm_broker.py=scripts/scm_broker.py
|
||||||
|
- scm_broker_client.py=scripts/scm_broker_client.py
|
||||||
|
options:
|
||||||
|
disableNameSuffixHash: true
|
||||||
284
services/hermes/scripts/gitea_api.py → services/hermes/scm-common/scripts/gitea_api.py
Executable file → Normal file
284
services/hermes/scripts/gitea_api.py → services/hermes/scm-common/scripts/gitea_api.py
Executable file → Normal file
@ -4,10 +4,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import base64
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import subprocess
|
|
||||||
import sys
|
import sys
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
@ -15,28 +15,29 @@ import urllib.request
|
|||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from gitea_api_policy import (
|
||||||
|
PolicyError,
|
||||||
|
_draft_title,
|
||||||
|
_reject_forbidden,
|
||||||
|
_validate_body,
|
||||||
|
_validate_pr_number,
|
||||||
|
_validate_pr_number_segment,
|
||||||
|
_validate_query,
|
||||||
|
_validate_ref,
|
||||||
|
_validate_ref_bounds,
|
||||||
|
_validate_repo,
|
||||||
|
_validate_sha,
|
||||||
|
)
|
||||||
|
|
||||||
CANONICAL_BASE_URL = "https://scm.bstein.dev"
|
CANONICAL_BASE_URL = "https://scm.bstein.dev"
|
||||||
ALLOWED_OWNER = "atlas"
|
ALLOWED_OWNER = "atlas"
|
||||||
DEFAULT_TOKEN_FILE = Path("/runtime-access/gitea-token")
|
DEFAULT_TOKEN_FILE = Path("/vault/secrets/gitea-token")
|
||||||
REPO_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,99}\Z")
|
|
||||||
SHA_RE = re.compile(r"[0-9a-fA-F]{40}\Z")
|
|
||||||
CREATE_PATH_RE = re.compile(r"/api/v1/repos/atlas/([^/]+)/pulls\Z")
|
CREATE_PATH_RE = re.compile(r"/api/v1/repos/atlas/([^/]+)/pulls\Z")
|
||||||
MAX_ERROR_BYTES = 8192
|
MAX_ERROR_BYTES = 8192
|
||||||
MAX_RESPONSE_BYTES = 2 * 1024 * 1024
|
MAX_RESPONSE_BYTES = 2 * 1024 * 1024
|
||||||
DRAFT_TITLE_PREFIX = "WIP: "
|
MAX_API_TARGET_LENGTH = 768
|
||||||
GIT_BIN = "/usr/bin/git"
|
MAX_API_PATH_LENGTH = 512
|
||||||
SENSITIVE_BODY_PATTERNS = (
|
RAW_API_TARGET_RE = re.compile(r"[A-Za-z0-9/_.?&=-]+\Z")
|
||||||
re.compile(r"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----"),
|
|
||||||
re.compile(r"(?i)\b(?:password|passwd|secret|token|api[_-]?key)\s*[:=]"),
|
|
||||||
re.compile(r"(?i)\bauthorization\s*:\s*(?:bearer|token|basic)\s+\S+"),
|
|
||||||
re.compile(r"\b(?:gh[pousr]_[A-Za-z0-9]{20,}|sk-[A-Za-z0-9]{20,})\b"),
|
|
||||||
re.compile(r"\b(?:AKIA[0-9A-Z]{16}|AIza[0-9A-Za-z_-]{35})\b"),
|
|
||||||
re.compile(r"\beyJ[A-Za-z0-9_-]{12,}\.[A-Za-z0-9_-]{12,}\.[A-Za-z0-9_-]{8,}\b"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class PolicyError(ValueError):
|
|
||||||
"""Raised when a requested Forgejo operation exceeds the safe boundary."""
|
|
||||||
|
|
||||||
|
|
||||||
class RejectRedirectHandler(urllib.request.HTTPRedirectHandler):
|
class RejectRedirectHandler(urllib.request.HTTPRedirectHandler):
|
||||||
@ -64,114 +65,48 @@ def read_token(path: Path = DEFAULT_TOKEN_FILE) -> str:
|
|||||||
|
|
||||||
def configured_base_url() -> str:
|
def configured_base_url() -> str:
|
||||||
"""Reject attempts to redirect credentials away from the canonical origin."""
|
"""Reject attempts to redirect credentials away from the canonical origin."""
|
||||||
configured = os.environ.get("GITEA_BASE_URL", CANONICAL_BASE_URL).rstrip("/")
|
configured = os.environ.get("GITEA_BASE_URL", CANONICAL_BASE_URL)
|
||||||
if configured != CANONICAL_BASE_URL:
|
if configured != CANONICAL_BASE_URL:
|
||||||
raise PolicyError("Forgejo origin is fixed to the private Atlas SCM service")
|
raise PolicyError("Forgejo origin is fixed to the private Atlas SCM service")
|
||||||
return configured
|
return configured
|
||||||
|
|
||||||
|
|
||||||
def _validate_repo(repo: str) -> str:
|
|
||||||
if not REPO_RE.fullmatch(repo) or repo in {".", ".."}:
|
|
||||||
raise PolicyError("repository name is outside the Atlas allowlist")
|
|
||||||
return repo
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_ref(value: object, name: str) -> str:
|
|
||||||
"""Validate the complete Git ref grammar using Git itself."""
|
|
||||||
if not isinstance(value, str) or not value or value.startswith("-"):
|
|
||||||
raise PolicyError(f"{name} must be a same-repository branch name")
|
|
||||||
if any(ord(character) < 32 or ord(character) == 127 for character in value):
|
|
||||||
raise PolicyError(f"{name} must be a safe same-repository branch name")
|
|
||||||
result = subprocess.run(
|
|
||||||
[GIT_BIN, "check-ref-format", f"refs/heads/{value}"],
|
|
||||||
stdin=subprocess.DEVNULL,
|
|
||||||
stdout=subprocess.DEVNULL,
|
|
||||||
stderr=subprocess.DEVNULL,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
raise PolicyError(f"{name} must be a safe same-repository branch name")
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_sha(value: object, name: str = "head SHA") -> str:
|
|
||||||
if not isinstance(value, str) or not SHA_RE.fullmatch(value):
|
|
||||||
raise PolicyError(f"{name} must be a full Git SHA-1")
|
|
||||||
return value.lower()
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_text(value: object, name: str, maximum: int, *, required: bool) -> str:
|
|
||||||
if not isinstance(value, str):
|
|
||||||
raise PolicyError(f"{name} must be text")
|
|
||||||
if required and not value.strip():
|
|
||||||
raise PolicyError(f"{name} must not be empty")
|
|
||||||
if len(value) > maximum or "\x00" in value:
|
|
||||||
raise PolicyError(f"{name} exceeds the safe request limit")
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_body(value: object, *, forbidden: tuple[str, ...] = ()) -> str:
|
|
||||||
body = _validate_text(value, "body", 16384, required=False)
|
|
||||||
if any(secret and secret in body for secret in forbidden):
|
|
||||||
raise PolicyError("pull-request body contains runtime credential material")
|
|
||||||
if any(pattern.search(body) for pattern in SENSITIVE_BODY_PATTERNS):
|
|
||||||
raise PolicyError("pull-request body resembles credential material")
|
|
||||||
return body
|
|
||||||
|
|
||||||
|
|
||||||
def _draft_title(value: object) -> str:
|
|
||||||
"""Return a bounded title using Gitea's configured default draft prefix."""
|
|
||||||
title = _validate_text(value, "title", 251, required=True).strip()
|
|
||||||
for prefix in ("WIP:", "[WIP]"):
|
|
||||||
if title.upper().startswith(prefix):
|
|
||||||
title = title[len(prefix) :].lstrip()
|
|
||||||
break
|
|
||||||
if not title:
|
|
||||||
raise PolicyError("title must contain text after the draft prefix")
|
|
||||||
return DRAFT_TITLE_PREFIX + title
|
|
||||||
|
|
||||||
|
|
||||||
def _split_api_path(path: str) -> urllib.parse.SplitResult:
|
def _split_api_path(path: str) -> urllib.parse.SplitResult:
|
||||||
|
if not isinstance(path, str) or len(path) > MAX_API_TARGET_LENGTH:
|
||||||
|
raise PolicyError("API target exceeds the safe size limit")
|
||||||
|
if (
|
||||||
|
not path.isascii()
|
||||||
|
or any(ord(character) < 32 or ord(character) == 127 for character in path)
|
||||||
|
or "\\" in path
|
||||||
|
or not RAW_API_TARGET_RE.fullmatch(path)
|
||||||
|
):
|
||||||
|
raise PolicyError("API target contains non-canonical raw characters")
|
||||||
target = urllib.parse.urlsplit(path)
|
target = urllib.parse.urlsplit(path)
|
||||||
|
canonical = urllib.parse.urlunsplit(("", "", target.path, target.query, ""))
|
||||||
|
if canonical != path:
|
||||||
|
raise PolicyError("API target is not in exact canonical form")
|
||||||
if target.scheme or target.netloc or target.fragment:
|
if target.scheme or target.netloc or target.fragment:
|
||||||
raise PolicyError("API path must be relative to the Atlas SCM origin")
|
raise PolicyError("API path must be relative to the Atlas SCM origin")
|
||||||
if not target.path.startswith("/api/v1/"):
|
if not target.path.startswith("/api/v1/"):
|
||||||
raise PolicyError("API path must start with /api/v1/")
|
raise PolicyError("API path must start with /api/v1/")
|
||||||
if "%" in target.path or "//" in target.path or "/../" in f"{target.path}/":
|
if len(target.path) > MAX_API_PATH_LENGTH:
|
||||||
|
raise PolicyError("API path exceeds the safe size limit")
|
||||||
|
segments = target.path.split("/")
|
||||||
|
if "//" in target.path or any(segment in {".", ".."} for segment in segments):
|
||||||
raise PolicyError("encoded or non-canonical API paths are not allowed")
|
raise PolicyError("encoded or non-canonical API paths are not allowed")
|
||||||
return target
|
return target
|
||||||
|
|
||||||
|
|
||||||
def _validate_query(target: urllib.parse.SplitResult, allowed: set[str]) -> None:
|
def _authorize_read(
|
||||||
try:
|
target: urllib.parse.SplitResult, *, forbidden: tuple[str, ...] = ()
|
||||||
pairs = urllib.parse.parse_qsl(
|
) -> str:
|
||||||
target.query, keep_blank_values=True, strict_parsing=True
|
|
||||||
)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise PolicyError("invalid API query") from exc
|
|
||||||
if len({key for key, _ in pairs}) != len(pairs):
|
|
||||||
raise PolicyError("duplicate API query parameters are not allowed")
|
|
||||||
if any(key not in allowed for key, _ in pairs):
|
|
||||||
raise PolicyError("API query parameter is outside the read allowlist")
|
|
||||||
values = dict(pairs)
|
|
||||||
for name in ("page", "limit"):
|
|
||||||
if name not in values:
|
|
||||||
continue
|
|
||||||
if not values[name].isdigit() or int(values[name]) < 1:
|
|
||||||
raise PolicyError(f"{name} must be a positive integer")
|
|
||||||
if "limit" in values and int(values["limit"]) > 50:
|
|
||||||
raise PolicyError("read limit cannot exceed 50")
|
|
||||||
if "state" in values and values["state"] not in {"open", "closed", "all"}:
|
|
||||||
raise PolicyError("pull-request state is invalid")
|
|
||||||
|
|
||||||
|
|
||||||
def _authorize_read(target: urllib.parse.SplitResult) -> str:
|
|
||||||
"""Allow only repository, PR, branch, commit, and status metadata reads."""
|
"""Allow only repository, PR, branch, commit, and status metadata reads."""
|
||||||
prefix = "/api/v1/repos/atlas/"
|
prefix = "/api/v1/repos/atlas/"
|
||||||
remainder = target.path.removeprefix(prefix)
|
remainder = target.path.removeprefix(prefix)
|
||||||
if remainder == target.path:
|
if remainder == target.path:
|
||||||
raise PolicyError("reads are limited to explicit Atlas repository metadata")
|
raise PolicyError("reads are limited to explicit Atlas repository metadata")
|
||||||
repo, separator, suffix = remainder.partition("/")
|
repo, separator, suffix = remainder.partition("/")
|
||||||
|
_reject_forbidden(repo, "repository", forbidden)
|
||||||
_validate_repo(repo)
|
_validate_repo(repo)
|
||||||
|
|
||||||
if not separator:
|
if not separator:
|
||||||
@ -180,10 +115,18 @@ def _authorize_read(target: urllib.parse.SplitResult) -> str:
|
|||||||
if suffix == "pulls":
|
if suffix == "pulls":
|
||||||
_validate_query(target, {"page", "limit", "state"})
|
_validate_query(target, {"page", "limit", "state"})
|
||||||
return "pull-list"
|
return "pull-list"
|
||||||
if re.fullmatch(r"pulls/[1-9][0-9]*", suffix):
|
content_match = re.fullmatch(r"pulls/([^/]+)\.(?:patch|diff)", suffix)
|
||||||
|
if content_match:
|
||||||
|
_validate_pr_number_segment(content_match.group(1))
|
||||||
|
raise PolicyError("repository API route is outside the metadata read allowlist")
|
||||||
|
pull_match = re.fullmatch(r"pulls/([^/]+)", suffix)
|
||||||
|
if pull_match:
|
||||||
|
_validate_pr_number_segment(pull_match.group(1))
|
||||||
_validate_query(target, set())
|
_validate_query(target, set())
|
||||||
return "pull"
|
return "pull"
|
||||||
if re.fullmatch(r"pulls/[1-9][0-9]*/(?:commits|files)", suffix):
|
evidence_match = re.fullmatch(r"pulls/([^/]+)/(?:commits|files)", suffix)
|
||||||
|
if evidence_match:
|
||||||
|
_validate_pr_number_segment(evidence_match.group(1))
|
||||||
_validate_query(target, {"page", "limit"})
|
_validate_query(target, {"page", "limit"})
|
||||||
return "pull-evidence"
|
return "pull-evidence"
|
||||||
if suffix == "branches":
|
if suffix == "branches":
|
||||||
@ -211,7 +154,7 @@ def _authorize_read(target: urllib.parse.SplitResult) -> str:
|
|||||||
|
|
||||||
def api_url(base_url: str, path: str) -> str:
|
def api_url(base_url: str, path: str) -> str:
|
||||||
"""Return a canonical same-origin URL without forwarding credentials."""
|
"""Return a canonical same-origin URL without forwarding credentials."""
|
||||||
if base_url.rstrip("/") != CANONICAL_BASE_URL:
|
if base_url != CANONICAL_BASE_URL:
|
||||||
raise PolicyError("Forgejo origin is fixed to the private Atlas SCM service")
|
raise PolicyError("Forgejo origin is fixed to the private Atlas SCM service")
|
||||||
target = _split_api_path(path)
|
target = _split_api_path(path)
|
||||||
return urllib.parse.urlunsplit(
|
return urllib.parse.urlunsplit(
|
||||||
@ -219,14 +162,20 @@ def api_url(base_url: str, path: str) -> str:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def authorize_request(method: str, path: str, data: object | None) -> str:
|
def authorize_request(
|
||||||
|
method: str,
|
||||||
|
path: str,
|
||||||
|
data: object | None,
|
||||||
|
*,
|
||||||
|
forbidden: tuple[str, ...] = (),
|
||||||
|
) -> str:
|
||||||
"""Validate one request and return its bounded operation name."""
|
"""Validate one request and return its bounded operation name."""
|
||||||
normalized_method = method.upper()
|
normalized_method = method.upper()
|
||||||
target = _split_api_path(path)
|
target = _split_api_path(path)
|
||||||
if normalized_method == "GET":
|
if normalized_method == "GET":
|
||||||
if data is not None:
|
if data is not None:
|
||||||
raise PolicyError("read operations cannot include a request body")
|
raise PolicyError("read operations cannot include a request body")
|
||||||
return _authorize_read(target)
|
return _authorize_read(target, forbidden=forbidden)
|
||||||
if target.query:
|
if target.query:
|
||||||
raise PolicyError("mutating operations cannot include query parameters")
|
raise PolicyError("mutating operations cannot include query parameters")
|
||||||
if normalized_method != "POST":
|
if normalized_method != "POST":
|
||||||
@ -234,14 +183,17 @@ def authorize_request(method: str, path: str, data: object | None) -> str:
|
|||||||
match = CREATE_PATH_RE.fullmatch(target.path)
|
match = CREATE_PATH_RE.fullmatch(target.path)
|
||||||
if not match:
|
if not match:
|
||||||
raise PolicyError("POST is limited to creating an Atlas draft pull request")
|
raise PolicyError("POST is limited to creating an Atlas draft pull request")
|
||||||
|
_reject_forbidden(match.group(1), "repository", forbidden)
|
||||||
_validate_repo(match.group(1))
|
_validate_repo(match.group(1))
|
||||||
if not isinstance(data, dict) or set(data) != {"base", "body", "head", "title"}:
|
if not isinstance(data, dict) or set(data) != {"base", "body", "head", "title"}:
|
||||||
raise PolicyError("draft creation accepts only base, body, head, and title")
|
raise PolicyError("draft creation accepts only base, body, head, and title")
|
||||||
_validate_ref(data["base"], "base")
|
if data["title"] != _draft_title(data["title"], forbidden=forbidden):
|
||||||
_validate_ref(data["head"], "head")
|
|
||||||
if data["title"] != _draft_title(data["title"]):
|
|
||||||
raise PolicyError("new pull requests must use the Gitea draft-title prefix")
|
raise PolicyError("new pull requests must use the Gitea draft-title prefix")
|
||||||
_validate_body(data["body"])
|
_validate_body(data["body"], forbidden=forbidden)
|
||||||
|
_validate_ref_bounds(data["base"], "base", forbidden=forbidden)
|
||||||
|
_validate_ref_bounds(data["head"], "head", forbidden=forbidden)
|
||||||
|
_validate_ref(data["base"], "base", forbidden=forbidden)
|
||||||
|
_validate_ref(data["head"], "head", forbidden=forbidden)
|
||||||
return "create-draft"
|
return "create-draft"
|
||||||
|
|
||||||
|
|
||||||
@ -254,7 +206,19 @@ def build_request(
|
|||||||
data: object | None = None,
|
data: object | None = None,
|
||||||
) -> urllib.request.Request:
|
) -> urllib.request.Request:
|
||||||
"""Build an authorized request without putting the token in its URL or body."""
|
"""Build an authorized request without putting the token in its URL or body."""
|
||||||
authorize_request(method, path, data)
|
authorize_request(method, path, data, forbidden=(token,))
|
||||||
|
return _build_request(method, path, base_url=base_url, token=token, data=data)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_request(
|
||||||
|
method: str,
|
||||||
|
path: str,
|
||||||
|
*,
|
||||||
|
base_url: str,
|
||||||
|
token: str,
|
||||||
|
data: object | None = None,
|
||||||
|
) -> urllib.request.Request:
|
||||||
|
"""Build one already-authorized upstream request."""
|
||||||
payload = None
|
payload = None
|
||||||
if data is not None:
|
if data is not None:
|
||||||
payload = json.dumps(data, separators=(",", ":")).encode("utf-8")
|
payload = json.dumps(data, separators=(",", ":")).encode("utf-8")
|
||||||
@ -281,6 +245,19 @@ def redact_bytes(value: bytes, token: str) -> bytes:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _reject_response_credential(value: bytes, token: str) -> None:
|
||||||
|
"""Fail closed if an upstream body reflects any ordinary token encoding."""
|
||||||
|
raw = token.encode("utf-8")
|
||||||
|
forms = {
|
||||||
|
raw,
|
||||||
|
base64.b64encode(raw),
|
||||||
|
base64.b64encode(b"hermes-automation:" + raw),
|
||||||
|
urllib.parse.quote(token, safe="").encode("ascii"),
|
||||||
|
}
|
||||||
|
if any(form and form in value for form in forms):
|
||||||
|
raise PolicyError("Forgejo response contains runtime credential material")
|
||||||
|
|
||||||
|
|
||||||
def _request(
|
def _request(
|
||||||
method: str,
|
method: str,
|
||||||
path: str,
|
path: str,
|
||||||
@ -288,14 +265,23 @@ def _request(
|
|||||||
*,
|
*,
|
||||||
token: str,
|
token: str,
|
||||||
opener: Callable[..., object] = _safe_urlopen,
|
opener: Callable[..., object] = _safe_urlopen,
|
||||||
|
authorized: bool = False,
|
||||||
|
expected_status: int,
|
||||||
) -> bytes:
|
) -> bytes:
|
||||||
request = build_request(
|
builder = _build_request if authorized else build_request
|
||||||
method, path, base_url=configured_base_url(), token=token, data=data
|
request = builder(method, path, base_url=configured_base_url(), token=token, data=data)
|
||||||
)
|
|
||||||
with opener(request, timeout=30) as response: # type: ignore[attr-defined]
|
with opener(request, timeout=30) as response: # type: ignore[attr-defined]
|
||||||
|
status = getattr(response, "status", None)
|
||||||
|
if status is None and hasattr(response, "getcode"):
|
||||||
|
status = response.getcode() # type: ignore[attr-defined]
|
||||||
|
if status != expected_status:
|
||||||
|
raise PolicyError("Forgejo returned an unexpected HTTP status")
|
||||||
|
if response.headers.get_content_type() != "application/json": # type: ignore[attr-defined]
|
||||||
|
raise PolicyError("Forgejo returned an unexpected response type")
|
||||||
body = response.read(MAX_RESPONSE_BYTES + 1) # type: ignore[attr-defined]
|
body = response.read(MAX_RESPONSE_BYTES + 1) # type: ignore[attr-defined]
|
||||||
if len(body) > MAX_RESPONSE_BYTES:
|
if len(body) > MAX_RESPONSE_BYTES:
|
||||||
raise PolicyError("Forgejo response exceeds the safe size limit")
|
raise PolicyError("Forgejo response exceeds the safe size limit")
|
||||||
|
_reject_response_credential(body, token)
|
||||||
return redact_bytes(body, token)
|
return redact_bytes(body, token)
|
||||||
|
|
||||||
|
|
||||||
@ -303,7 +289,9 @@ def read(
|
|||||||
path: str, *, token: str, opener: Callable[..., object] = _safe_urlopen
|
path: str, *, token: str, opener: Callable[..., object] = _safe_urlopen
|
||||||
) -> bytes:
|
) -> bytes:
|
||||||
"""Read one explicitly allowed Atlas repository metadata resource."""
|
"""Read one explicitly allowed Atlas repository metadata resource."""
|
||||||
return _request("GET", path, None, token=token, opener=opener)
|
return _request(
|
||||||
|
"GET", path, None, token=token, opener=opener, expected_status=200
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _nested(document: dict[str, object], *keys: str) -> object:
|
def _nested(document: dict[str, object], *keys: str) -> object:
|
||||||
@ -332,9 +320,7 @@ def _require_create_response(
|
|||||||
raise PolicyError("Forgejo returned invalid pull-request metadata") from exc
|
raise PolicyError("Forgejo returned invalid pull-request metadata") from exc
|
||||||
if not isinstance(document, dict):
|
if not isinstance(document, dict):
|
||||||
raise PolicyError("Forgejo returned invalid pull-request metadata")
|
raise PolicyError("Forgejo returned invalid pull-request metadata")
|
||||||
number = document.get("number")
|
number = _validate_pr_number(document.get("number"))
|
||||||
if not isinstance(number, int) or isinstance(number, bool) or number < 1:
|
|
||||||
raise PolicyError("Forgejo omitted a valid pull-request number")
|
|
||||||
full_name = f"{ALLOWED_OWNER}/{repo}"
|
full_name = f"{ALLOWED_OWNER}/{repo}"
|
||||||
expected_html = f"{CANONICAL_BASE_URL}/{full_name}/pulls/{number}"
|
expected_html = f"{CANONICAL_BASE_URL}/{full_name}/pulls/{number}"
|
||||||
checks = (
|
checks = (
|
||||||
@ -368,12 +354,15 @@ def create_draft(
|
|||||||
opener: Callable[..., object] = _safe_urlopen,
|
opener: Callable[..., object] = _safe_urlopen,
|
||||||
) -> bytes:
|
) -> bytes:
|
||||||
"""Create and verify a same-repository draft pull request for human review."""
|
"""Create and verify a same-repository draft pull request for human review."""
|
||||||
|
_reject_forbidden(repo, "repository", (token,))
|
||||||
repo = _validate_repo(repo)
|
repo = _validate_repo(repo)
|
||||||
base = _validate_ref(base, "base")
|
title = _draft_title(title, forbidden=(token,))
|
||||||
head = _validate_ref(head, "head")
|
|
||||||
head_sha = _validate_sha(head_sha)
|
|
||||||
title = _draft_title(title)
|
|
||||||
body = _validate_body(body, forbidden=(token,))
|
body = _validate_body(body, forbidden=(token,))
|
||||||
|
head_sha = _validate_sha(head_sha)
|
||||||
|
_validate_ref_bounds(base, "base", forbidden=(token,))
|
||||||
|
_validate_ref_bounds(head, "head", forbidden=(token,))
|
||||||
|
base = _validate_ref(base, "base", forbidden=(token,))
|
||||||
|
head = _validate_ref(head, "head", forbidden=(token,))
|
||||||
data = {"base": base, "body": body, "head": head, "title": title}
|
data = {"base": base, "body": body, "head": head, "title": title}
|
||||||
result = _request(
|
result = _request(
|
||||||
"POST",
|
"POST",
|
||||||
@ -381,6 +370,8 @@ def create_draft(
|
|||||||
data,
|
data,
|
||||||
token=token,
|
token=token,
|
||||||
opener=opener,
|
opener=opener,
|
||||||
|
authorized=True,
|
||||||
|
expected_status=201,
|
||||||
)
|
)
|
||||||
_require_create_response(
|
_require_create_response(
|
||||||
result,
|
result,
|
||||||
@ -417,16 +408,24 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
|||||||
return parser.parse_args(argv)
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
def _operation(args: argparse.Namespace) -> tuple[str, str, object | None]:
|
def _operation(
|
||||||
|
args: argparse.Namespace, *, validate_refs: bool = True
|
||||||
|
) -> tuple[str, str, object | None]:
|
||||||
if args.operation == "read":
|
if args.operation == "read":
|
||||||
return "GET", args.path, None
|
return "GET", args.path, None
|
||||||
repo = _validate_repo(args.repo)
|
repo = _validate_repo(args.repo)
|
||||||
_validate_sha(args.head_sha)
|
_validate_sha(args.head_sha)
|
||||||
|
body = _validate_body(args.body)
|
||||||
|
title = _draft_title(args.title)
|
||||||
|
_validate_ref_bounds(args.base, "base")
|
||||||
|
_validate_ref_bounds(args.head, "head")
|
||||||
|
base = _validate_ref(args.base, "base") if validate_refs else args.base
|
||||||
|
head = _validate_ref(args.head, "head") if validate_refs else args.head
|
||||||
data = {
|
data = {
|
||||||
"base": _validate_ref(args.base, "base"),
|
"base": base,
|
||||||
"body": _validate_body(args.body),
|
"body": body,
|
||||||
"head": _validate_ref(args.head, "head"),
|
"head": head,
|
||||||
"title": _draft_title(args.title),
|
"title": title,
|
||||||
}
|
}
|
||||||
return "POST", f"/api/v1/repos/{ALLOWED_OWNER}/{repo}/pulls", data
|
return "POST", f"/api/v1/repos/{ALLOWED_OWNER}/{repo}/pulls", data
|
||||||
|
|
||||||
@ -440,12 +439,11 @@ def _write_body(body: bytes) -> None:
|
|||||||
|
|
||||||
def main(argv: list[str] | None = None) -> int:
|
def main(argv: list[str] | None = None) -> int:
|
||||||
"""Validate, optionally describe, and execute one safe Forgejo operation."""
|
"""Validate, optionally describe, and execute one safe Forgejo operation."""
|
||||||
token = ""
|
|
||||||
try:
|
try:
|
||||||
args = parse_args(argv)
|
args = parse_args(argv)
|
||||||
method, path, data = _operation(args)
|
|
||||||
operation = authorize_request(method, path, data)
|
|
||||||
if args.dry_run:
|
if args.dry_run:
|
||||||
|
method, path, data = _operation(args, validate_refs=False)
|
||||||
|
operation = authorize_request(method, path, data)
|
||||||
print(
|
print(
|
||||||
json.dumps(
|
json.dumps(
|
||||||
{
|
{
|
||||||
@ -462,29 +460,27 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
return 0
|
return 0
|
||||||
token = read_token()
|
method, path, data = _operation(args, validate_refs=False)
|
||||||
|
from scm_broker_client import create_draft as broker_create_draft
|
||||||
|
from scm_broker_client import read as broker_read
|
||||||
|
|
||||||
if args.operation == "read":
|
if args.operation == "read":
|
||||||
body = read(path, token=token)
|
body = broker_read(path)
|
||||||
else:
|
else:
|
||||||
assert isinstance(data, dict)
|
assert isinstance(data, dict)
|
||||||
body = create_draft(
|
body = broker_create_draft(
|
||||||
args.repo,
|
args.repo,
|
||||||
base=str(data["base"]),
|
base=str(data["base"]),
|
||||||
head=str(data["head"]),
|
head=str(data["head"]),
|
||||||
head_sha=args.head_sha,
|
head_sha=args.head_sha,
|
||||||
title=str(data["title"]),
|
title=str(data["title"]),
|
||||||
body=str(data["body"]),
|
body=str(data["body"]),
|
||||||
token=token,
|
|
||||||
)
|
)
|
||||||
_write_body(body)
|
_write_body(body)
|
||||||
return 0
|
return 0
|
||||||
except urllib.error.HTTPError as exc:
|
except urllib.error.HTTPError as exc:
|
||||||
body = redact_bytes(exc.read(MAX_ERROR_BYTES), token) if token else b""
|
exc.read(MAX_ERROR_BYTES)
|
||||||
print(f"Forgejo request failed with HTTP {exc.code}", file=sys.stderr)
|
print(f"SCM broker request failed with HTTP {exc.code}", file=sys.stderr)
|
||||||
if body:
|
|
||||||
sys.stderr.buffer.write(body)
|
|
||||||
if not body.endswith(b"\n"):
|
|
||||||
sys.stderr.buffer.write(b"\n")
|
|
||||||
return 1
|
return 1
|
||||||
except (OSError, PolicyError, ValueError, json.JSONDecodeError):
|
except (OSError, PolicyError, ValueError, json.JSONDecodeError):
|
||||||
print(
|
print(
|
||||||
490
services/hermes/scm-common/scripts/gitea_api_policy.py
Normal file
490
services/hermes/scm-common/scripts/gitea_api_policy.py
Normal file
@ -0,0 +1,490 @@
|
|||||||
|
"""Validation policy for Hermes' least-authority Atlas Forgejo client.
|
||||||
|
|
||||||
|
The screening catches structured credential assignments, known token formats,
|
||||||
|
and long high-entropy values. It is a fail-closed accident barrier, not proof
|
||||||
|
that text is secret-free: a short or multiword secret under an innocuous key is
|
||||||
|
not reliably distinguishable from prose and must never be supplied by callers.
|
||||||
|
JSON depth/nodes/documents and multiline gaps are deliberately bounded; large
|
||||||
|
or unusual configuration blobs are not a supported pull-request field format.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# ruff: noqa: SIM905
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import urllib.parse
|
||||||
|
from collections import Counter
|
||||||
|
from math import log2
|
||||||
|
|
||||||
|
REPO_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,99}\Z")
|
||||||
|
SHA_RE = re.compile(r"[0-9a-fA-F]{40}\Z")
|
||||||
|
DRAFT_TITLE_PREFIX = "WIP: "
|
||||||
|
GIT_BIN = "/usr/bin/git"
|
||||||
|
MAX_QUERY_LENGTH = 128
|
||||||
|
MAX_QUERY_ITEMS = 3
|
||||||
|
MAX_QUERY_KEY_LENGTH = 16
|
||||||
|
MAX_QUERY_VALUE_LENGTH = 16
|
||||||
|
MAX_PAGE = 10_000
|
||||||
|
MAX_LIMIT = 50
|
||||||
|
MAX_PR_NUMBER = 2_147_483_647
|
||||||
|
MAX_PR_NUMBER_DIGITS = 10
|
||||||
|
MAX_REF_CHARACTERS = 200
|
||||||
|
MAX_REF_UTF8_BYTES = 255
|
||||||
|
MAX_TITLE_UTF8_BYTES = 512
|
||||||
|
MAX_BODY_UTF8_BYTES = 32_768
|
||||||
|
MAX_JSON_DOCUMENTS = 32
|
||||||
|
MAX_JSON_NODES = 2_048
|
||||||
|
MAX_JSON_DEPTH = 32
|
||||||
|
CANONICAL_QUERY_RE = re.compile(r"[A-Za-z0-9_=&-]*\Z")
|
||||||
|
ASSIGNMENT_RE = re.compile(
|
||||||
|
r"""(?mx)
|
||||||
|
(?<![A-Za-z0-9_.-])
|
||||||
|
(?P<key_quote>[\"']?)
|
||||||
|
(?P<key>\.?[A-Za-z][A-Za-z0-9_.-]{0,127})
|
||||||
|
(?P=key_quote)
|
||||||
|
[ \t]*(?P<separator>[:=])[ \t]*
|
||||||
|
(?P<value>
|
||||||
|
\"(?:\\.|[^\"\\\r\n])*\" |
|
||||||
|
'(?:\\.|[^'\\\r\n])*' |
|
||||||
|
[^\r\n,;}&]{0,2048}
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
JSON_KEY_RE = re.compile(r'"(?P<key>[^"]{1,512})"(?P<spacing>[\x00-\x20\x7f]{0,256}):')
|
||||||
|
MULTILINE_ASSIGNMENT_RE = re.compile(
|
||||||
|
r"""(?mx)
|
||||||
|
(?<![A-Za-z0-9_.-])
|
||||||
|
(?P<key_quote>["']?)
|
||||||
|
(?P<key>\.?[A-Za-z][A-Za-z0-9_.-]{0,127})
|
||||||
|
(?P=key_quote)
|
||||||
|
[ \t]{0,64}(?:\r?\n[ \t]{0,64}){0,4}
|
||||||
|
(?P<separator>[:=])
|
||||||
|
[ \t]{0,64}(?:\r?\n[ \t]{0,64}){1,4}
|
||||||
|
(?P<value>[^\r\n,;}&]{1,2048})
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
CAMEL_BOUNDARY_RE = re.compile(r"(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])")
|
||||||
|
CREDENTIAL_SCHEME_RE = re.compile(
|
||||||
|
r"(?i)\b(?:bearer|basic|token)\s+"
|
||||||
|
r"(?=[A-Za-z0-9+/_=.:-]{8,}\b)(?=[A-Za-z0-9+/_=.:-]*[0-9+/_=.-])"
|
||||||
|
r"[A-Za-z0-9+/_=.:-]{8,}"
|
||||||
|
)
|
||||||
|
STANDALONE_SECRET_PATTERNS = tuple(
|
||||||
|
re.compile(pattern, re.IGNORECASE)
|
||||||
|
for pattern in (
|
||||||
|
r"(?<![A-Za-z0-9])gh[pousr]_[A-Za-z0-9]{20,}(?![A-Za-z0-9])",
|
||||||
|
r"(?<![A-Za-z0-9])github_pat_[A-Za-z0-9_]{20,}(?![A-Za-z0-9])",
|
||||||
|
r"(?<![A-Za-z0-9])glpat-[A-Za-z0-9_-]{20,}(?![A-Za-z0-9])",
|
||||||
|
r"(?<![A-Za-z0-9])xox[baprs]-[A-Za-z0-9-]{20,}(?![A-Za-z0-9])",
|
||||||
|
r"(?<![A-Za-z0-9])sk-(?:ant-|proj-)?[A-Za-z0-9_-]{20,}(?![A-Za-z0-9])",
|
||||||
|
r"(?<![A-Za-z0-9])[rs]k_(?:test|live)_[A-Za-z0-9]{20,}(?![A-Za-z0-9])",
|
||||||
|
r"(?<![A-Za-z0-9])whsec_[A-Za-z0-9]{20,}(?![A-Za-z0-9])",
|
||||||
|
r"(?<![A-Za-z0-9])npm_[A-Za-z0-9]{20,}(?![A-Za-z0-9])",
|
||||||
|
r"(?<![A-Za-z0-9])pypi-[A-Za-z0-9_-]{30,}(?![A-Za-z0-9])",
|
||||||
|
r"(?<![A-Za-z0-9])hf_[A-Za-z0-9]{20,}(?![A-Za-z0-9])",
|
||||||
|
r"(?<![A-Za-z0-9])(?:gta|gto|gitea|forgejo)_[A-Za-z0-9_-]{20,}(?![A-Za-z0-9])",
|
||||||
|
r"(?<![A-Za-z0-9])ya29\.[A-Za-z0-9_-]{20,}(?![A-Za-z0-9])",
|
||||||
|
r"(?<![A-Za-z0-9])oy2[A-Za-z0-9]{40,}(?![A-Za-z0-9])",
|
||||||
|
r"(?<![A-Za-z0-9])SG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{20,}(?![A-Za-z0-9])",
|
||||||
|
r"(?<![A-Za-z0-9])SK[0-9a-f]{32}(?![A-Za-z0-9])",
|
||||||
|
r"(?<![A-Za-z0-9])(?:AKIA|ASIA)[0-9A-Z]{16}(?![A-Za-z0-9])",
|
||||||
|
r"(?<![A-Za-z0-9])AIza[0-9A-Za-z_-]{35}(?![A-Za-z0-9])",
|
||||||
|
r"(?<![A-Za-z0-9])eyJ[A-Za-z0-9_-]{12,}\.[A-Za-z0-9_-]{12,}\.[A-Za-z0-9_-]{8,}(?![A-Za-z0-9])",
|
||||||
|
r"-----BEGIN\s+[A-Z0-9 ][A-Z0-9 -]{1,62}-----",
|
||||||
|
r"\bssh-(?:rsa|ed25519|dss|ecdsa-[A-Za-z0-9-]+)\s+[A-Za-z0-9+/]{16,}={0,3}",
|
||||||
|
r"https://hooks\.slack\.com/services/[A-Za-z0-9/_-]{20,}",
|
||||||
|
r"https://(?:discord(?:app)?\.com)/api/webhooks/[0-9]+/[A-Za-z0-9._-]{20,}",
|
||||||
|
r"https://[^\s/]*webhook\.office\.com/[^\s]{20,}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
HIGH_ENTROPY_TOKEN_RE = re.compile(
|
||||||
|
r"(?<![A-Za-z0-9+/_=-])[A-Za-z0-9+/_=-]{48,16384}(?![A-Za-z0-9+/_=-])"
|
||||||
|
)
|
||||||
|
PROSE_LEAD_WORDS = frozenset(
|
||||||
|
"add allow change check describe document ensure explain fix handle keep preserve "
|
||||||
|
"prevent reject remove review support test validate".split()
|
||||||
|
)
|
||||||
|
SENSITIVE_KEY_WORDS = frozenset(
|
||||||
|
"auth authentication authorization auths basic bearer credential credentials "
|
||||||
|
"passwd password pat private sas secret secrets sig signature signing token tokens "
|
||||||
|
"webhook".split()
|
||||||
|
)
|
||||||
|
KEY_MODIFIER_WORDS = frozenset(
|
||||||
|
"access account api auth client encryption identity private registry secret service "
|
||||||
|
"session signing ssh".split()
|
||||||
|
)
|
||||||
|
SENSITIVE_COMPACT_KEYS = frozenset(
|
||||||
|
"accessid accesskeyid accountkey clientemail clientid connectionstring "
|
||||||
|
"dockerconfigjson privatekeyid serviceaccountkey sharedaccesssignature".split()
|
||||||
|
)
|
||||||
|
SENSITIVE_COMPACT_SUFFIXES = tuple(
|
||||||
|
"accesskey accesskeyid accountkey apikey authkey clientemail clientid clientkey "
|
||||||
|
"connectionstring credential credentials encryptionkey identitykey password passwd "
|
||||||
|
"privatekeyid privatekey secret secretkey servicekey sessionkey signature signingkey "
|
||||||
|
"sshkey token webhook".split()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PolicyError(ValueError):
|
||||||
|
"""Raised when a requested Forgejo operation exceeds the safe boundary."""
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_repo(repo: str) -> str:
|
||||||
|
if not REPO_RE.fullmatch(repo) or repo in {".", ".."}:
|
||||||
|
raise PolicyError("repository name is outside the Atlas allowlist")
|
||||||
|
return repo
|
||||||
|
|
||||||
|
|
||||||
|
def _reject_forbidden(value: object, name: str, forbidden: tuple[str, ...]) -> None:
|
||||||
|
"""Reject exact runtime credentials before invoking helpers or the network."""
|
||||||
|
if not isinstance(value, str) or any(secret and secret in value for secret in forbidden):
|
||||||
|
raise PolicyError(f"{name} contains runtime credential material")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_ref_bounds(
|
||||||
|
value: object, name: str, *, forbidden: tuple[str, ...] = ()
|
||||||
|
) -> str:
|
||||||
|
"""Reject oversized or non-encodable refs before invoking Git."""
|
||||||
|
_reject_forbidden(value, name, forbidden)
|
||||||
|
if not value or len(value) > MAX_REF_CHARACTERS:
|
||||||
|
raise PolicyError(f"{name} exceeds the safe branch-name limit")
|
||||||
|
try:
|
||||||
|
encoded = value.encode("utf-8", errors="strict")
|
||||||
|
except UnicodeEncodeError as exc:
|
||||||
|
raise PolicyError(f"{name} is not valid UTF-8 text") from exc
|
||||||
|
if len(encoded) > MAX_REF_UTF8_BYTES:
|
||||||
|
raise PolicyError(f"{name} exceeds the safe UTF-8 branch-name limit")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_ref(
|
||||||
|
value: object, name: str, *, forbidden: tuple[str, ...] = ()
|
||||||
|
) -> str:
|
||||||
|
"""Validate the complete Git ref grammar using Git itself."""
|
||||||
|
value = _validate_ref_bounds(value, name, forbidden=forbidden)
|
||||||
|
if value.startswith("-"):
|
||||||
|
raise PolicyError(f"{name} must be a same-repository branch name")
|
||||||
|
if any(ord(character) < 32 or ord(character) == 127 for character in value):
|
||||||
|
raise PolicyError(f"{name} must be a safe same-repository branch name")
|
||||||
|
result = subprocess.run(
|
||||||
|
[GIT_BIN, "check-ref-format", f"refs/heads/{value}"],
|
||||||
|
stdin=subprocess.DEVNULL,
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise PolicyError(f"{name} must be a safe same-repository branch name")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_sha(value: object, name: str = "head SHA") -> str:
|
||||||
|
if not isinstance(value, str) or not SHA_RE.fullmatch(value):
|
||||||
|
raise PolicyError(f"{name} must be a full Git SHA-1")
|
||||||
|
return value.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_pr_number_segment(value: object) -> int:
|
||||||
|
"""Validate a canonical bounded pull-request number from an API path."""
|
||||||
|
if (
|
||||||
|
not isinstance(value, str)
|
||||||
|
or len(value) > MAX_PR_NUMBER_DIGITS
|
||||||
|
or not re.fullmatch(r"[1-9][0-9]*", value)
|
||||||
|
):
|
||||||
|
raise PolicyError("pull-request number must use bounded canonical ASCII digits")
|
||||||
|
number = int(value)
|
||||||
|
if number > MAX_PR_NUMBER:
|
||||||
|
raise PolicyError("pull-request number is outside its allowed range")
|
||||||
|
return number
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_pr_number(value: object) -> int:
|
||||||
|
"""Validate a bounded pull-request number returned by Forgejo."""
|
||||||
|
if (
|
||||||
|
not isinstance(value, int)
|
||||||
|
or isinstance(value, bool)
|
||||||
|
or not 1 <= value <= MAX_PR_NUMBER
|
||||||
|
):
|
||||||
|
raise PolicyError("Forgejo omitted a valid pull-request number")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_text(
|
||||||
|
value: object,
|
||||||
|
name: str,
|
||||||
|
maximum: int,
|
||||||
|
maximum_bytes: int,
|
||||||
|
*,
|
||||||
|
required: bool,
|
||||||
|
) -> str:
|
||||||
|
if not isinstance(value, str):
|
||||||
|
raise PolicyError(f"{name} must be text")
|
||||||
|
if required and not value.strip():
|
||||||
|
raise PolicyError(f"{name} must not be empty")
|
||||||
|
try:
|
||||||
|
encoded = value.encode("utf-8", errors="strict")
|
||||||
|
except UnicodeEncodeError as exc:
|
||||||
|
raise PolicyError(f"{name} is not valid UTF-8 text") from exc
|
||||||
|
if len(value) > maximum or len(encoded) > maximum_bytes or "\x00" in value:
|
||||||
|
raise PolicyError(f"{name} exceeds the safe request limit")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _normalized_key(value: str) -> tuple[tuple[str, ...], str]:
|
||||||
|
"""Split camelCase and separator-based assignment keys into semantics."""
|
||||||
|
separated = CAMEL_BOUNDARY_RE.sub(" ", value.strip(".\"'"))
|
||||||
|
words = tuple(re.findall(r"[A-Za-z0-9]+", separated.lower()))
|
||||||
|
return words, "".join(words)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_sensitive_key(value: str) -> bool:
|
||||||
|
words, compact = _normalized_key(value)
|
||||||
|
word_set = set(words)
|
||||||
|
if word_set & SENSITIVE_KEY_WORDS:
|
||||||
|
return True
|
||||||
|
if compact in SENSITIVE_COMPACT_KEYS:
|
||||||
|
return True
|
||||||
|
if compact.endswith(SENSITIVE_COMPACT_SUFFIXES):
|
||||||
|
return True
|
||||||
|
if "key" in word_set and word_set & KEY_MODIFIER_WORDS:
|
||||||
|
return True
|
||||||
|
if {"client", "email"} <= word_set or {"client", "id"} <= word_set:
|
||||||
|
return True
|
||||||
|
if {"access", "id"} <= word_set or {"connection", "string"} <= word_set:
|
||||||
|
return True
|
||||||
|
return compact.endswith("configjson")
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_assignment_value(value: str) -> tuple[str, bool]:
|
||||||
|
stripped = value.strip()
|
||||||
|
quoted = (
|
||||||
|
len(stripped) >= 2 and stripped[0] in {'"', "'"} and stripped[-1] == stripped[0]
|
||||||
|
)
|
||||||
|
if quoted:
|
||||||
|
stripped = stripped[1:-1].strip()
|
||||||
|
return stripped, quoted
|
||||||
|
|
||||||
|
|
||||||
|
def _looks_like_prose(value: str) -> bool:
|
||||||
|
words = re.findall(r"[A-Za-z]+(?:[-'][A-Za-z]+)?", value)
|
||||||
|
if len(words) < 2:
|
||||||
|
return False
|
||||||
|
if re.search(r"[$`{}\[\]\\@/:=+_]", value):
|
||||||
|
return False
|
||||||
|
return words[0].lower() in PROSE_LEAD_WORDS
|
||||||
|
|
||||||
|
|
||||||
|
def _looks_sensitive_assignment_value(value: str) -> bool:
|
||||||
|
stripped, quoted = _strip_assignment_value(value)
|
||||||
|
if not stripped:
|
||||||
|
return False
|
||||||
|
if quoted or stripped.startswith(("{", "[", "|", ">", "!!", "&", "*")):
|
||||||
|
return True
|
||||||
|
if CREDENTIAL_SCHEME_RE.search(stripped):
|
||||||
|
return True
|
||||||
|
if any(pattern.search(stripped) for pattern in STANDALONE_SECRET_PATTERNS):
|
||||||
|
return True
|
||||||
|
if re.search(r"(?:https?://|[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+)", stripped):
|
||||||
|
return True
|
||||||
|
if re.search(r"(?:\$\{|\$\(|\\[nrt]|[A-Za-z0-9+/]{16,}={0,3}\Z)", stripped):
|
||||||
|
return True
|
||||||
|
return not _looks_like_prose(stripped)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_structural_credential_assignment(key: str, value: str) -> bool:
|
||||||
|
words, compact_key = _normalized_key(key)
|
||||||
|
stripped, _quoted = _strip_assignment_value(value)
|
||||||
|
_value_words, compact_value = _normalized_key(stripped)
|
||||||
|
return (
|
||||||
|
compact_key == "type" and compact_value in {"serviceaccount", "credential"}
|
||||||
|
) or ("service" in words and "account" in words and bool(stripped))
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_json_key(raw: str) -> tuple[str, bool]:
|
||||||
|
"""Decode one bounded JSON key without silently accepting bad escapes."""
|
||||||
|
try:
|
||||||
|
decoded = json.loads(f'"{raw}"')
|
||||||
|
except (json.JSONDecodeError, UnicodeError):
|
||||||
|
approximate = re.sub(r"[^A-Za-z0-9]+", "_", raw.replace("\\", ""))
|
||||||
|
return approximate, False
|
||||||
|
return decoded if isinstance(decoded, str) else "", True
|
||||||
|
|
||||||
|
|
||||||
|
def _json_value_has_sensitive_assignment(
|
||||||
|
value: object, *, depth: int = 0, nodes: list[int] | None = None
|
||||||
|
) -> bool:
|
||||||
|
"""Walk one decoded JSON value with explicit depth and node ceilings."""
|
||||||
|
if nodes is None:
|
||||||
|
nodes = [0]
|
||||||
|
nodes[0] += 1
|
||||||
|
if nodes[0] > MAX_JSON_NODES or depth > MAX_JSON_DEPTH:
|
||||||
|
raise PolicyError("structured pull-request text exceeds the scan limit")
|
||||||
|
if isinstance(value, dict):
|
||||||
|
for key, assigned in value.items():
|
||||||
|
if not isinstance(key, str):
|
||||||
|
continue
|
||||||
|
if _is_sensitive_key(key):
|
||||||
|
return True
|
||||||
|
assigned_text = assigned if isinstance(assigned, str) else ""
|
||||||
|
if _is_structural_credential_assignment(key, assigned_text):
|
||||||
|
return True
|
||||||
|
if _json_value_has_sensitive_assignment(
|
||||||
|
assigned, depth=depth + 1, nodes=nodes
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
elif isinstance(value, list):
|
||||||
|
return any(
|
||||||
|
_json_value_has_sensitive_assignment(item, depth=depth + 1, nodes=nodes)
|
||||||
|
for item in value
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _decoded_json_documents(value: str):
|
||||||
|
"""Yield bounded embedded JSON object or array fragments."""
|
||||||
|
decoder = json.JSONDecoder()
|
||||||
|
index = 0
|
||||||
|
attempts = 0
|
||||||
|
while attempts < MAX_JSON_DOCUMENTS:
|
||||||
|
positions = [
|
||||||
|
position for token in "{[" if (position := value.find(token, index)) >= 0
|
||||||
|
]
|
||||||
|
if not positions:
|
||||||
|
return
|
||||||
|
start = min(positions)
|
||||||
|
attempts += 1
|
||||||
|
try:
|
||||||
|
document, end = decoder.raw_decode(value, start)
|
||||||
|
except (json.JSONDecodeError, RecursionError, ValueError):
|
||||||
|
index = start + 1
|
||||||
|
continue
|
||||||
|
yield document
|
||||||
|
index = max(end, start + 1)
|
||||||
|
if "{" in value[index:] or "[" in value[index:]:
|
||||||
|
raise PolicyError("structured pull-request text exceeds the scan limit")
|
||||||
|
|
||||||
|
|
||||||
|
def _has_structured_sensitive_assignment(value: str) -> bool:
|
||||||
|
"""Detect bounded JSON and multiline YAML/env credential assignments."""
|
||||||
|
for candidate in JSON_KEY_RE.finditer(value):
|
||||||
|
decoded, valid = _decode_json_key(candidate.group("key"))
|
||||||
|
if _is_sensitive_key(decoded):
|
||||||
|
return True
|
||||||
|
if not valid and _is_sensitive_key(candidate.group("key").replace("\\", "")):
|
||||||
|
return True
|
||||||
|
for candidate in MULTILINE_ASSIGNMENT_RE.finditer(value):
|
||||||
|
key = candidate.group("key")
|
||||||
|
assigned = candidate.group("value")
|
||||||
|
if _is_structural_credential_assignment(key, assigned) or (
|
||||||
|
_is_sensitive_key(key) and _looks_sensitive_assignment_value(assigned)
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
return any(
|
||||||
|
_json_value_has_sensitive_assignment(item)
|
||||||
|
for item in _decoded_json_documents(value)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _has_high_entropy_token(value: str) -> bool:
|
||||||
|
for match in HIGH_ENTROPY_TOKEN_RE.finditer(value):
|
||||||
|
token = match.group(0)
|
||||||
|
if len(token) > 256:
|
||||||
|
return True
|
||||||
|
groups = sum(
|
||||||
|
bool(re.search(pattern, token))
|
||||||
|
for pattern in (r"[a-z]", r"[A-Z]", r"[0-9]", r"[+/_=-]")
|
||||||
|
)
|
||||||
|
counts = Counter(token)
|
||||||
|
entropy = -sum(
|
||||||
|
(count / len(token)) * log2(count / len(token)) for count in counts.values()
|
||||||
|
)
|
||||||
|
if groups >= 3 and entropy >= 4.0:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _reject_sensitive(value: str, name: str, forbidden: tuple[str, ...]) -> None:
|
||||||
|
if any(secret and secret in value for secret in forbidden):
|
||||||
|
raise PolicyError(f"pull-request {name} contains runtime credential material")
|
||||||
|
if CREDENTIAL_SCHEME_RE.search(value):
|
||||||
|
raise PolicyError(f"pull-request {name} resembles credential material")
|
||||||
|
if any(pattern.search(value) for pattern in STANDALONE_SECRET_PATTERNS):
|
||||||
|
raise PolicyError(f"pull-request {name} resembles credential material")
|
||||||
|
if _has_structured_sensitive_assignment(value):
|
||||||
|
raise PolicyError(f"pull-request {name} resembles credential material")
|
||||||
|
for candidate in ASSIGNMENT_RE.finditer(value):
|
||||||
|
key = candidate.group("key")
|
||||||
|
assigned = candidate.group("value")
|
||||||
|
if _is_structural_credential_assignment(key, assigned) or (
|
||||||
|
_is_sensitive_key(key) and _looks_sensitive_assignment_value(assigned)
|
||||||
|
):
|
||||||
|
raise PolicyError(f"pull-request {name} resembles credential material")
|
||||||
|
if _has_high_entropy_token(value):
|
||||||
|
raise PolicyError(f"pull-request {name} resembles credential material")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_body(value: object, *, forbidden: tuple[str, ...] = ()) -> str:
|
||||||
|
body = _validate_text(value, "body", 16384, MAX_BODY_UTF8_BYTES, required=False)
|
||||||
|
_reject_sensitive(body, "body", forbidden)
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
def _draft_title(value: object, *, forbidden: tuple[str, ...] = ()) -> str:
|
||||||
|
"""Return a bounded, secret-screened Gitea draft title."""
|
||||||
|
title = _validate_text(
|
||||||
|
value, "title", 251, MAX_TITLE_UTF8_BYTES, required=True
|
||||||
|
).strip()
|
||||||
|
_reject_sensitive(title, "title", forbidden)
|
||||||
|
for prefix in ("WIP:", "[WIP]"):
|
||||||
|
if title.upper().startswith(prefix):
|
||||||
|
title = title[len(prefix) :].lstrip()
|
||||||
|
break
|
||||||
|
if not title:
|
||||||
|
raise PolicyError("title must contain text after the draft prefix")
|
||||||
|
return DRAFT_TITLE_PREFIX + title
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_query(target: urllib.parse.SplitResult, allowed: set[str]) -> None:
|
||||||
|
"""Accept only short canonical ASCII query strings with bounded pagination."""
|
||||||
|
raw = target.query
|
||||||
|
if (
|
||||||
|
len(raw) > MAX_QUERY_LENGTH
|
||||||
|
or not raw.isascii()
|
||||||
|
or not CANONICAL_QUERY_RE.fullmatch(raw)
|
||||||
|
or "%" in raw
|
||||||
|
):
|
||||||
|
raise PolicyError("API query must use short canonical ASCII form")
|
||||||
|
try:
|
||||||
|
pairs = urllib.parse.parse_qsl(raw, keep_blank_values=True, strict_parsing=True)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise PolicyError("invalid API query") from exc
|
||||||
|
if len(pairs) > MAX_QUERY_ITEMS:
|
||||||
|
raise PolicyError("too many API query parameters")
|
||||||
|
if len({key for key, _ in pairs}) != len(pairs):
|
||||||
|
raise PolicyError("duplicate API query parameters are not allowed")
|
||||||
|
if any(
|
||||||
|
not key
|
||||||
|
or len(key) > MAX_QUERY_KEY_LENGTH
|
||||||
|
or len(value) > MAX_QUERY_VALUE_LENGTH
|
||||||
|
for key, value in pairs
|
||||||
|
):
|
||||||
|
raise PolicyError("API query key or value exceeds its safe limit")
|
||||||
|
if any(key not in allowed for key, _ in pairs):
|
||||||
|
raise PolicyError("API query parameter is outside the read allowlist")
|
||||||
|
values = dict(pairs)
|
||||||
|
for name in ("page", "limit"):
|
||||||
|
if name not in values:
|
||||||
|
continue
|
||||||
|
if not re.fullmatch(r"[0-9]+", values[name]):
|
||||||
|
raise PolicyError(f"{name} must use ASCII decimal digits")
|
||||||
|
number = int(values[name])
|
||||||
|
if values[name] != str(number):
|
||||||
|
raise PolicyError(f"{name} must use canonical ASCII decimal form")
|
||||||
|
ceiling = MAX_PAGE if name == "page" else MAX_LIMIT
|
||||||
|
if not 1 <= number <= ceiling:
|
||||||
|
raise PolicyError(f"{name} is outside its allowed range")
|
||||||
|
if "state" in values and values["state"] not in {"open", "closed", "all"}:
|
||||||
|
raise PolicyError("pull-request state is invalid")
|
||||||
317
services/hermes/scm-common/scripts/scm_broker.py
Normal file
317
services/hermes/scm-common/scripts/scm_broker.py
Normal file
@ -0,0 +1,317 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Serve the credential-isolated Atlas API and Git smart-HTTP boundary."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
|
||||||
|
from gitea_api import (
|
||||||
|
CANONICAL_BASE_URL,
|
||||||
|
PolicyError,
|
||||||
|
create_draft,
|
||||||
|
read,
|
||||||
|
read_token,
|
||||||
|
)
|
||||||
|
from gitea_api_policy import _reject_forbidden, _validate_ref, _validate_repo
|
||||||
|
|
||||||
|
BROKER_PORT = 9081
|
||||||
|
GIT_USER = "hermes-automation"
|
||||||
|
MAX_CONTROL_BODY = 64 * 1024
|
||||||
|
MAX_GIT_REQUEST = 128 * 1024 * 1024
|
||||||
|
MAX_GIT_RESPONSE = 128 * 1024 * 1024
|
||||||
|
MAX_PUSH_COMMANDS = 16
|
||||||
|
ZERO_SHA = b"0" * 40
|
||||||
|
GIT_PATH_RE = re.compile(
|
||||||
|
r"/git/atlas/(?P<repo>[A-Za-z0-9][A-Za-z0-9._-]{0,99})\.git/"
|
||||||
|
r"(?P<operation>info/refs|git-upload-pack|git-receive-pack)\Z"
|
||||||
|
)
|
||||||
|
FEATURE_REF_RE = re.compile(
|
||||||
|
r"refs/heads/(?:(?:feature|fix|hermes|handoff)/[A-Za-z0-9][A-Za-z0-9._/-]{0,190})\Z"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RejectRedirect(urllib.request.HTTPRedirectHandler):
|
||||||
|
"""Reject every upstream redirect before credentials can be forwarded."""
|
||||||
|
|
||||||
|
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||||
|
raise PolicyError("SCM upstream redirects are not allowed")
|
||||||
|
|
||||||
|
|
||||||
|
UPSTREAM_OPENER = urllib.request.build_opener(RejectRedirect())
|
||||||
|
|
||||||
|
|
||||||
|
def _status(response: object) -> int | None:
|
||||||
|
value = getattr(response, "status", None)
|
||||||
|
if value is None and hasattr(response, "getcode"):
|
||||||
|
value = response.getcode() # type: ignore[attr-defined]
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _read_bounded(stream, maximum: int, length: int | None = None) -> bytes:
|
||||||
|
if length is not None and not 0 <= length <= maximum:
|
||||||
|
raise PolicyError("SCM request exceeds the safe size limit")
|
||||||
|
value = stream.read(maximum + 1 if length is None else length)
|
||||||
|
if len(value) > maximum or (length is not None and len(value) != length):
|
||||||
|
raise PolicyError("SCM request exceeds the safe size limit")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _load_json(handler: BaseHTTPRequestHandler) -> dict[str, object]:
|
||||||
|
if handler.headers.get("Transfer-Encoding"):
|
||||||
|
raise PolicyError("chunked broker control requests are not allowed")
|
||||||
|
if handler.headers.get_content_type() != "application/json":
|
||||||
|
raise PolicyError("broker control request must be JSON")
|
||||||
|
length = _content_length(handler.headers, MAX_CONTROL_BODY)
|
||||||
|
value = json.loads(_read_bounded(handler.rfile, MAX_CONTROL_BODY, length))
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise PolicyError("broker control request must be an object")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _git_target(raw: str) -> tuple[str, str, str]:
|
||||||
|
if (
|
||||||
|
not raw.isascii()
|
||||||
|
or any(ord(character) < 32 or ord(character) == 127 for character in raw)
|
||||||
|
or "%" in raw
|
||||||
|
or "\\" in raw
|
||||||
|
or len(raw) > 512
|
||||||
|
):
|
||||||
|
raise PolicyError("Git target is not canonical ASCII")
|
||||||
|
target = urllib.parse.urlsplit(raw)
|
||||||
|
canonical = urllib.parse.urlunsplit(("", "", target.path, target.query, ""))
|
||||||
|
if canonical != raw:
|
||||||
|
raise PolicyError("Git target is not in exact canonical form")
|
||||||
|
if target.scheme or target.netloc or target.fragment:
|
||||||
|
raise PolicyError("Git target must be relative")
|
||||||
|
match = GIT_PATH_RE.fullmatch(target.path)
|
||||||
|
if not match:
|
||||||
|
raise PolicyError("Git target is outside the Atlas allowlist")
|
||||||
|
repo = _validate_repo(match.group("repo"))
|
||||||
|
operation = match.group("operation")
|
||||||
|
query = target.query
|
||||||
|
if operation == "info/refs":
|
||||||
|
if query not in {"service=git-upload-pack", "service=git-receive-pack"}:
|
||||||
|
raise PolicyError("Git discovery service is outside the allowlist")
|
||||||
|
service = query.removeprefix("service=")
|
||||||
|
elif query:
|
||||||
|
raise PolicyError("Git RPC query parameters are not allowed")
|
||||||
|
else:
|
||||||
|
service = operation
|
||||||
|
return repo, operation, service
|
||||||
|
|
||||||
|
|
||||||
|
def _content_length(headers: object, maximum: int) -> int:
|
||||||
|
"""Parse one short canonical bounded Content-Length header."""
|
||||||
|
raw = headers.get("Content-Length", "") # type: ignore[attr-defined]
|
||||||
|
if (
|
||||||
|
not isinstance(raw, str)
|
||||||
|
or not raw.isascii()
|
||||||
|
or len(raw) > 10
|
||||||
|
or not raw.isdigit()
|
||||||
|
):
|
||||||
|
raise PolicyError("SCM request length is invalid")
|
||||||
|
length = int(raw)
|
||||||
|
if raw != str(length) or not 0 <= length <= maximum:
|
||||||
|
raise PolicyError("SCM request length is invalid")
|
||||||
|
return length
|
||||||
|
|
||||||
|
|
||||||
|
def _credential_forms(token: str) -> tuple[bytes, ...]:
|
||||||
|
basic = base64.b64encode(f"{GIT_USER}:{token}".encode("utf-8"))
|
||||||
|
return token.encode("utf-8"), basic, b"Basic " + basic
|
||||||
|
|
||||||
|
|
||||||
|
def _reject_credential_bytes(value: bytes, token: str, context: str) -> None:
|
||||||
|
if any(form in value for form in _credential_forms(token)):
|
||||||
|
raise PolicyError(f"{context} contains runtime credential material")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_receive_pack(body: bytes, token: str) -> None:
|
||||||
|
"""Permit only creation of new, namespaced feature branches."""
|
||||||
|
_reject_credential_bytes(body, token, "Git request")
|
||||||
|
position = 0
|
||||||
|
commands = 0
|
||||||
|
while position + 4 <= len(body):
|
||||||
|
header = body[position : position + 4]
|
||||||
|
if not re.fullmatch(rb"[0-9a-f]{4}", header):
|
||||||
|
raise PolicyError("Git receive-pack command framing is invalid")
|
||||||
|
size = int(header, 16)
|
||||||
|
if size == 0:
|
||||||
|
if commands == 0:
|
||||||
|
raise PolicyError("Git receive-pack request has no ref command")
|
||||||
|
return
|
||||||
|
if size < 4 or position + size > len(body):
|
||||||
|
raise PolicyError("Git receive-pack command framing is invalid")
|
||||||
|
command = body[position + 4 : position + size].rstrip(b"\n")
|
||||||
|
command = command.split(b"\x00", 1)[0]
|
||||||
|
fields = command.split(b" ")
|
||||||
|
if len(fields) != 3 or not all(re.fullmatch(rb"[0-9a-f]{40}", item) for item in fields[:2]):
|
||||||
|
raise PolicyError("Git receive-pack ref command is invalid")
|
||||||
|
old_sha, new_sha, raw_ref = fields
|
||||||
|
if old_sha != ZERO_SHA or new_sha == ZERO_SHA:
|
||||||
|
raise PolicyError("Git broker permits only new feature-branch creation")
|
||||||
|
try:
|
||||||
|
ref = raw_ref.decode("ascii")
|
||||||
|
except UnicodeDecodeError as exc:
|
||||||
|
raise PolicyError("Git ref must be canonical ASCII") from exc
|
||||||
|
if not FEATURE_REF_RE.fullmatch(ref):
|
||||||
|
raise PolicyError("Git push is limited to namespaced feature branches")
|
||||||
|
_validate_ref(ref.removeprefix("refs/heads/"), "head", forbidden=(token,))
|
||||||
|
commands += 1
|
||||||
|
if commands > MAX_PUSH_COMMANDS:
|
||||||
|
raise PolicyError("Git push contains too many ref commands")
|
||||||
|
position += size
|
||||||
|
raise PolicyError("Git receive-pack request omitted its command terminator")
|
||||||
|
|
||||||
|
|
||||||
|
def _upstream_git_request(
|
||||||
|
target: str,
|
||||||
|
*,
|
||||||
|
method: str,
|
||||||
|
body: bytes | None,
|
||||||
|
content_type: str | None,
|
||||||
|
expected_type: str,
|
||||||
|
token: str,
|
||||||
|
opener=UPSTREAM_OPENER.open,
|
||||||
|
) -> bytes:
|
||||||
|
credentials = base64.b64encode(f"{GIT_USER}:{token}".encode()).decode("ascii")
|
||||||
|
headers = {"Accept": expected_type, "Authorization": f"Basic {credentials}"}
|
||||||
|
if content_type:
|
||||||
|
headers["Content-Type"] = content_type
|
||||||
|
request = urllib.request.Request(
|
||||||
|
CANONICAL_BASE_URL + target,
|
||||||
|
data=body,
|
||||||
|
method=method,
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
with opener(request, timeout=120) as response:
|
||||||
|
if _status(response) != 200:
|
||||||
|
raise PolicyError("Git upstream returned an unexpected HTTP status")
|
||||||
|
if response.headers.get_content_type() != expected_type:
|
||||||
|
raise PolicyError("Git upstream returned an unexpected response type")
|
||||||
|
result = _read_bounded(response, MAX_GIT_RESPONSE)
|
||||||
|
_reject_credential_bytes(result, token, "Git upstream response")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
class BrokerHandler(BaseHTTPRequestHandler):
|
||||||
|
"""Expose only bounded metadata, draft creation, and smart-HTTP Git."""
|
||||||
|
|
||||||
|
server_version = "HermesSCMBroker/1"
|
||||||
|
sys_version = ""
|
||||||
|
|
||||||
|
def log_message(self, _format: str, *_args: object) -> None:
|
||||||
|
return
|
||||||
|
|
||||||
|
def _json(self, status: int, body: bytes) -> None:
|
||||||
|
self.send_response(status)
|
||||||
|
self.send_header("Content-Type", "application/json")
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.send_header("Cache-Control", "no-store")
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def _reject(self, status: int = 400) -> None:
|
||||||
|
self._json(status, b'{"error":"request rejected"}')
|
||||||
|
|
||||||
|
def do_GET(self) -> None:
|
||||||
|
try:
|
||||||
|
if self.path == "/healthz":
|
||||||
|
self._json(200, b'{"status":"ok"}')
|
||||||
|
return
|
||||||
|
repo, operation, service = _git_target(self.path)
|
||||||
|
if operation != "info/refs":
|
||||||
|
raise PolicyError("Git RPC requires POST")
|
||||||
|
token = read_token()
|
||||||
|
_reject_forbidden(repo, "repository", (token,))
|
||||||
|
expected = f"application/x-{service}-advertisement"
|
||||||
|
body = _upstream_git_request(
|
||||||
|
f"/atlas/{repo}.git/info/refs?service={service}",
|
||||||
|
method="GET",
|
||||||
|
body=None,
|
||||||
|
content_type=None,
|
||||||
|
expected_type=expected,
|
||||||
|
token=token,
|
||||||
|
)
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", expected)
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
except (OSError, PolicyError, urllib.error.URLError, ValueError):
|
||||||
|
self._reject()
|
||||||
|
|
||||||
|
def do_POST(self) -> None:
|
||||||
|
try:
|
||||||
|
if self.path in {"/v1/metadata", "/v1/drafts"}:
|
||||||
|
self._control()
|
||||||
|
else:
|
||||||
|
self._git_rpc()
|
||||||
|
except (OSError, PolicyError, urllib.error.URLError, ValueError, json.JSONDecodeError):
|
||||||
|
self._reject()
|
||||||
|
|
||||||
|
def _control(self) -> None:
|
||||||
|
data = _load_json(self)
|
||||||
|
token = read_token()
|
||||||
|
if self.path == "/v1/metadata":
|
||||||
|
if set(data) != {"path"} or not isinstance(data["path"], str):
|
||||||
|
raise PolicyError("metadata request fields are invalid")
|
||||||
|
result = read(data["path"], token=token)
|
||||||
|
else:
|
||||||
|
expected = {"base", "body", "head", "head_sha", "repo", "title"}
|
||||||
|
if set(data) != expected or not all(isinstance(data[key], str) for key in expected):
|
||||||
|
raise PolicyError("draft request fields are invalid")
|
||||||
|
result = create_draft(token=token, **data) # type: ignore[arg-type]
|
||||||
|
if token.encode("utf-8") in result:
|
||||||
|
raise PolicyError("SCM upstream reflected credential material")
|
||||||
|
self._json(200, result)
|
||||||
|
|
||||||
|
def _git_rpc(self) -> None:
|
||||||
|
repo, operation, service = _git_target(self.path)
|
||||||
|
if operation not in {"git-upload-pack", "git-receive-pack"} or service != operation:
|
||||||
|
raise PolicyError("Git RPC operation is outside the allowlist")
|
||||||
|
expected_request = f"application/x-{service}-request"
|
||||||
|
if self.headers.get_content_type() != expected_request or self.headers.get("Transfer-Encoding"):
|
||||||
|
raise PolicyError("Git RPC request type is invalid")
|
||||||
|
length = _content_length(self.headers, MAX_GIT_REQUEST)
|
||||||
|
body = _read_bounded(self.rfile, MAX_GIT_REQUEST, length)
|
||||||
|
token = read_token()
|
||||||
|
_reject_forbidden(repo, "repository", (token,))
|
||||||
|
if service == "git-receive-pack":
|
||||||
|
_validate_receive_pack(body, token)
|
||||||
|
else:
|
||||||
|
_reject_credential_bytes(body, token, "Git request")
|
||||||
|
expected = f"application/x-{service}-result"
|
||||||
|
result = _upstream_git_request(
|
||||||
|
f"/atlas/{repo}.git/{service}",
|
||||||
|
method="POST",
|
||||||
|
body=body,
|
||||||
|
content_type=expected_request,
|
||||||
|
expected_type=expected,
|
||||||
|
token=token,
|
||||||
|
)
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", expected)
|
||||||
|
self.send_header("Content-Length", str(len(result)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(result)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--listen", default="0.0.0.0")
|
||||||
|
parser.add_argument("--port", type=int, default=BROKER_PORT)
|
||||||
|
args = parser.parse_args()
|
||||||
|
ThreadingHTTPServer((args.listen, args.port), BrokerHandler).serve_forever()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
95
services/hermes/scm-common/scripts/scm_broker_client.py
Normal file
95
services/hermes/scm-common/scripts/scm_broker_client.py
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Call the credential-isolated Hermes SCM broker over its fixed cluster origin."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import urllib.request
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
|
from gitea_api_policy import PolicyError
|
||||||
|
|
||||||
|
BROKER_ORIGIN = "http://hermes-scm-broker.hermes-scm.svc.cluster.local:9081"
|
||||||
|
MAX_RESPONSE_BYTES = 2 * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
class RejectRedirectHandler(urllib.request.HTTPRedirectHandler):
|
||||||
|
"""Keep every broker request on its fixed in-cluster origin."""
|
||||||
|
|
||||||
|
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||||
|
raise PolicyError("SCM broker redirects are not allowed")
|
||||||
|
|
||||||
|
|
||||||
|
_OPENER = urllib.request.build_opener(RejectRedirectHandler())
|
||||||
|
|
||||||
|
|
||||||
|
def _open(request: urllib.request.Request, timeout: int):
|
||||||
|
return _OPENER.open(request, timeout=timeout)
|
||||||
|
|
||||||
|
|
||||||
|
def request(
|
||||||
|
endpoint: str,
|
||||||
|
payload: dict[str, object],
|
||||||
|
*,
|
||||||
|
opener: Callable[..., object] = _open,
|
||||||
|
) -> bytes:
|
||||||
|
"""Send one bounded broker operation without any repository credential."""
|
||||||
|
if endpoint not in {"/v1/metadata", "/v1/drafts"}:
|
||||||
|
raise PolicyError("SCM broker operation is outside the client allowlist")
|
||||||
|
body = json.dumps(payload, separators=(",", ":")).encode("utf-8")
|
||||||
|
if len(body) > 64 * 1024:
|
||||||
|
raise PolicyError("SCM broker request exceeds the safe size limit")
|
||||||
|
outgoing = urllib.request.Request(
|
||||||
|
BROKER_ORIGIN + endpoint,
|
||||||
|
data=body,
|
||||||
|
method="POST",
|
||||||
|
headers={
|
||||||
|
"Accept": "application/json",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"User-Agent": "hermes-scm-broker-client/1",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
with opener(outgoing, timeout=30) as response: # type: ignore[attr-defined]
|
||||||
|
status = getattr(response, "status", None)
|
||||||
|
if status is None and hasattr(response, "getcode"):
|
||||||
|
status = response.getcode() # type: ignore[attr-defined]
|
||||||
|
if status != 200:
|
||||||
|
raise PolicyError("SCM broker returned an unexpected HTTP status")
|
||||||
|
content_type = response.headers.get_content_type() # type: ignore[attr-defined]
|
||||||
|
if content_type != "application/json":
|
||||||
|
raise PolicyError("SCM broker returned an unexpected response type")
|
||||||
|
result = response.read(MAX_RESPONSE_BYTES + 1) # type: ignore[attr-defined]
|
||||||
|
if len(result) > MAX_RESPONSE_BYTES:
|
||||||
|
raise PolicyError("SCM broker response exceeds the safe size limit")
|
||||||
|
json.loads(result)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def read(path: str, *, opener: Callable[..., object] = _open) -> bytes:
|
||||||
|
"""Read explicitly allowed Atlas metadata through the broker."""
|
||||||
|
return request("/v1/metadata", {"path": path}, opener=opener)
|
||||||
|
|
||||||
|
|
||||||
|
def create_draft(
|
||||||
|
repo: str,
|
||||||
|
*,
|
||||||
|
base: str,
|
||||||
|
head: str,
|
||||||
|
head_sha: str,
|
||||||
|
title: str,
|
||||||
|
body: str,
|
||||||
|
opener: Callable[..., object] = _open,
|
||||||
|
) -> bytes:
|
||||||
|
"""Create one verified draft through the broker for human review."""
|
||||||
|
return request(
|
||||||
|
"/v1/drafts",
|
||||||
|
{
|
||||||
|
"base": base,
|
||||||
|
"body": body,
|
||||||
|
"head": head,
|
||||||
|
"head_sha": head_sha,
|
||||||
|
"repo": repo,
|
||||||
|
"title": title,
|
||||||
|
},
|
||||||
|
opener=opener,
|
||||||
|
)
|
||||||
@ -1,19 +0,0 @@
|
|||||||
#!/usr/bin/env sh
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
case "${1:-}" in
|
|
||||||
*Username*)
|
|
||||||
if [ -s /runtime-access/gitea-username ]; then
|
|
||||||
tr -d '\r\n' </runtime-access/gitea-username
|
|
||||||
else
|
|
||||||
printf '%s' hermes-automation
|
|
||||||
fi
|
|
||||||
printf '\n'
|
|
||||||
;;
|
|
||||||
*Password*)
|
|
||||||
test -s /runtime-access/gitea-token
|
|
||||||
tr -d '\r\n' </runtime-access/gitea-token
|
|
||||||
printf '\n'
|
|
||||||
;;
|
|
||||||
*) exit 1 ;;
|
|
||||||
esac
|
|
||||||
@ -24,8 +24,10 @@ from hermes_model_routing import (
|
|||||||
|
|
||||||
|
|
||||||
CASSANDRA_BASE_PATH = Path("/opt/data/workspace/projects/cassandra")
|
CASSANDRA_BASE_PATH = Path("/opt/data/workspace/projects/cassandra")
|
||||||
CASSANDRA_REMOTE = "https://scm.bstein.dev/atlas/cassandra.git"
|
CASSANDRA_REMOTE = (
|
||||||
DEFAULT_GITEA_TOKEN_PATH = Path("/runtime-access/gitea-token")
|
"http://hermes-scm-broker.hermes-scm.svc.cluster.local:9081/"
|
||||||
|
"git/atlas/cassandra.git"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def cassandra_workspace() -> Path:
|
def cassandra_workspace() -> Path:
|
||||||
@ -139,24 +141,8 @@ def bootstrap_cassandra_state(root: Path) -> dict[str, str]:
|
|||||||
return {"state": "ready"}
|
return {"state": "ready"}
|
||||||
|
|
||||||
|
|
||||||
def _gitea_token_path() -> Path:
|
|
||||||
"""Return the runtime-only Gitea credential path without reading it."""
|
|
||||||
configured = os.environ.get("HERMES_GITEA_TOKEN_FILE", "").strip()
|
|
||||||
return Path(configured) if configured else DEFAULT_GITEA_TOKEN_PATH
|
|
||||||
|
|
||||||
|
|
||||||
def _gitea_token_available() -> bool:
|
|
||||||
"""Check credential readiness without loading it into process memory."""
|
|
||||||
try:
|
|
||||||
path = _gitea_token_path()
|
|
||||||
return path.is_file() and path.stat().st_size > 0
|
|
||||||
except OSError:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def sync_cassandra_repo(env_values: dict[str, str]) -> str:
|
def sync_cassandra_repo(env_values: dict[str, str]) -> str:
|
||||||
"""Clone or fetch Cassandra through the runtime-only askpass credential."""
|
"""Clone or fetch Cassandra through the credential-isolated SCM broker."""
|
||||||
token_available = _gitea_token_available()
|
|
||||||
if shutil.which("git") is None:
|
if shutil.which("git") is None:
|
||||||
return "git-unavailable"
|
return "git-unavailable"
|
||||||
child_env = os.environ.copy()
|
child_env = os.environ.copy()
|
||||||
@ -173,9 +159,7 @@ def sync_cassandra_repo(env_values: dict[str, str]) -> str:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
child_env["GIT_ASKPASS"] = env_values.get(
|
child_env.pop("GIT_ASKPASS", None)
|
||||||
"GIT_ASKPASS", "/opt/coordinator/gitea_askpass.sh"
|
|
||||||
)
|
|
||||||
child_env["GIT_TERMINAL_PROMPT"] = "0"
|
child_env["GIT_TERMINAL_PROMPT"] = "0"
|
||||||
if (CASSANDRA_BASE_PATH / ".git").exists():
|
if (CASSANDRA_BASE_PATH / ".git").exists():
|
||||||
try:
|
try:
|
||||||
@ -232,8 +216,6 @@ def sync_cassandra_repo(env_values: dict[str, str]) -> str:
|
|||||||
return f"remote-repair-failed-{repaired.returncode}"
|
return f"remote-repair-failed-{repaired.returncode}"
|
||||||
except (OSError, subprocess.TimeoutExpired):
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
return "remote-repair-failed"
|
return "remote-repair-failed"
|
||||||
if not token_available:
|
|
||||||
return "ready; fetch skipped until Gitea token is configured"
|
|
||||||
command = [
|
command = [
|
||||||
"git",
|
"git",
|
||||||
"-C",
|
"-C",
|
||||||
@ -247,8 +229,6 @@ def sync_cassandra_repo(env_values: dict[str, str]) -> str:
|
|||||||
CASSANDRA_BASE_PATH.mkdir(parents=True, exist_ok=True)
|
CASSANDRA_BASE_PATH.mkdir(parents=True, exist_ok=True)
|
||||||
if any(CASSANDRA_BASE_PATH.iterdir()):
|
if any(CASSANDRA_BASE_PATH.iterdir()):
|
||||||
return "unmanaged-nonempty-directory"
|
return "unmanaged-nonempty-directory"
|
||||||
if not token_available:
|
|
||||||
return "awaiting-gitea-token"
|
|
||||||
command = [
|
command = [
|
||||||
"git",
|
"git",
|
||||||
"clone",
|
"clone",
|
||||||
|
|||||||
456
services/hermes/scripts/node_account_hardening.py
Normal file
456
services/hermes/scripts/node_account_hardening.py
Normal file
@ -0,0 +1,456 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Reconcile a dedicated unprivileged Hermes SSH account on one Atlas node."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import base64
|
||||||
|
import errno
|
||||||
|
import os
|
||||||
|
import stat
|
||||||
|
import struct
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
HOST_ETC = Path("/host-etc")
|
||||||
|
HOST_HOME = Path("/host-home")
|
||||||
|
HOST_K3S = Path("/host-k3s")
|
||||||
|
HOST_KUBELET = Path("/host-kubelet")
|
||||||
|
HOST_RUN_K3S = Path("/host-run-k3s")
|
||||||
|
HOST_RUN_CONTAINERD = Path("/host-run-containerd")
|
||||||
|
ACCOUNT = "hermes-agent"
|
||||||
|
ACCOUNT_UID = 1200
|
||||||
|
ACCOUNT_GID = 1200
|
||||||
|
ACCOUNT_HOME = "/home/hermes-agent"
|
||||||
|
ACCOUNT_SHELL = "/bin/bash"
|
||||||
|
HOST_ROOT_UID = 0
|
||||||
|
HOST_ROOT_GID = 0
|
||||||
|
LEGACY_ACCOUNTS = ("atlas", "oceanus")
|
||||||
|
MAX_ACCOUNT_FILE = 2 * 1024 * 1024
|
||||||
|
MAX_AUTHORIZED_KEYS = 1024 * 1024
|
||||||
|
ACL_VERSION = 2
|
||||||
|
ACL_UNDEFINED_ID = 0xFFFFFFFF
|
||||||
|
ACL_USER_OBJ = 0x01
|
||||||
|
ACL_USER = 0x02
|
||||||
|
ACL_GROUP_OBJ = 0x04
|
||||||
|
ACL_GROUP = 0x08
|
||||||
|
ACL_MASK = 0x10
|
||||||
|
ACL_OTHER = 0x20
|
||||||
|
ACL_HEADER = struct.Struct("<I")
|
||||||
|
ACL_ENTRY = struct.Struct("<HHI")
|
||||||
|
ACL_XATTR = "system.posix_acl_access"
|
||||||
|
|
||||||
|
|
||||||
|
class HardeningError(RuntimeError):
|
||||||
|
"""Raised before an unsafe or ambiguous host-account change."""
|
||||||
|
|
||||||
|
|
||||||
|
def _read_regular(path: Path, maximum: int = MAX_ACCOUNT_FILE) -> tuple[bytes, os.stat_result]:
|
||||||
|
flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
|
||||||
|
descriptor = os.open(path, flags)
|
||||||
|
try:
|
||||||
|
metadata = os.fstat(descriptor)
|
||||||
|
if not stat.S_ISREG(metadata.st_mode) or metadata.st_size > maximum:
|
||||||
|
raise HardeningError(f"unsafe regular file: {path.name}")
|
||||||
|
value = os.read(descriptor, maximum + 1)
|
||||||
|
finally:
|
||||||
|
os.close(descriptor)
|
||||||
|
if len(value) != metadata.st_size:
|
||||||
|
raise HardeningError(f"short file read: {path.name}")
|
||||||
|
return value, metadata
|
||||||
|
|
||||||
|
|
||||||
|
def _records(value: bytes, fields: int, name: str) -> list[list[str]]:
|
||||||
|
try:
|
||||||
|
text = value.decode("utf-8")
|
||||||
|
except UnicodeDecodeError as exc:
|
||||||
|
raise HardeningError(f"{name} is not UTF-8") from exc
|
||||||
|
if not text.endswith("\n"):
|
||||||
|
raise HardeningError(f"{name} is missing its final newline")
|
||||||
|
records = []
|
||||||
|
names = set()
|
||||||
|
for line in text.splitlines():
|
||||||
|
parts = line.split(":")
|
||||||
|
if len(parts) != fields or not parts[0] or parts[0] in names:
|
||||||
|
raise HardeningError(f"{name} has an invalid record")
|
||||||
|
names.add(parts[0])
|
||||||
|
records.append(parts)
|
||||||
|
return records
|
||||||
|
|
||||||
|
|
||||||
|
def _encode(records: list[list[str]]) -> bytes:
|
||||||
|
return ("\n".join(":".join(record) for record in records) + "\n").encode()
|
||||||
|
|
||||||
|
|
||||||
|
def _expected_records() -> dict[str, list[str]]:
|
||||||
|
return {
|
||||||
|
"passwd": [
|
||||||
|
ACCOUNT,
|
||||||
|
"x",
|
||||||
|
str(ACCOUNT_UID),
|
||||||
|
str(ACCOUNT_GID),
|
||||||
|
"Hermes Agent",
|
||||||
|
ACCOUNT_HOME,
|
||||||
|
ACCOUNT_SHELL,
|
||||||
|
],
|
||||||
|
"group": [ACCOUNT, "x", str(ACCOUNT_GID), ""],
|
||||||
|
"shadow": [ACCOUNT, "!", "1", "0", "99999", "7", "", "", ""],
|
||||||
|
"gshadow": [ACCOUNT, "!", "", ""],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _reconcile_record(
|
||||||
|
records: list[list[str]], expected: list[str], *, identity_index: int
|
||||||
|
) -> list[list[str]]:
|
||||||
|
for record in records:
|
||||||
|
same_name = record[0] == expected[0]
|
||||||
|
same_identity = record[identity_index] == expected[identity_index]
|
||||||
|
if same_name or same_identity:
|
||||||
|
if record != expected:
|
||||||
|
raise HardeningError("dedicated Hermes account identity conflicts")
|
||||||
|
return records
|
||||||
|
return [*records, expected]
|
||||||
|
|
||||||
|
|
||||||
|
def _backup_once(path: Path, value: bytes, metadata: os.stat_result) -> Path:
|
||||||
|
backup = path.with_name(path.name + ".hermes-boundary-backup")
|
||||||
|
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0)
|
||||||
|
try:
|
||||||
|
descriptor = os.open(backup, flags, stat.S_IMODE(metadata.st_mode))
|
||||||
|
except FileExistsError:
|
||||||
|
_read_regular(backup)
|
||||||
|
return backup
|
||||||
|
try:
|
||||||
|
if os.write(descriptor, value) != len(value):
|
||||||
|
raise HardeningError(f"short backup write: {path.name}")
|
||||||
|
os.fchown(descriptor, metadata.st_uid, metadata.st_gid)
|
||||||
|
os.fsync(descriptor)
|
||||||
|
finally:
|
||||||
|
os.close(descriptor)
|
||||||
|
return backup
|
||||||
|
|
||||||
|
|
||||||
|
def _atomic_write(
|
||||||
|
path: Path, value: bytes, *, mode: int, uid: int, gid: int
|
||||||
|
) -> None:
|
||||||
|
temporary = path.with_name(f".{path.name}.hermes-{os.getpid()}")
|
||||||
|
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0)
|
||||||
|
descriptor = os.open(temporary, flags, mode)
|
||||||
|
try:
|
||||||
|
if os.write(descriptor, value) != len(value):
|
||||||
|
raise HardeningError(f"short atomic write: {path.name}")
|
||||||
|
os.fchmod(descriptor, mode)
|
||||||
|
os.fchown(descriptor, uid, gid)
|
||||||
|
os.fsync(descriptor)
|
||||||
|
except Exception:
|
||||||
|
temporary.unlink(missing_ok=True)
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
os.close(descriptor)
|
||||||
|
os.replace(temporary, path)
|
||||||
|
directory = os.open(path.parent, os.O_RDONLY)
|
||||||
|
try:
|
||||||
|
os.fsync(directory)
|
||||||
|
finally:
|
||||||
|
os.close(directory)
|
||||||
|
|
||||||
|
|
||||||
|
def _reconcile_databases() -> None:
|
||||||
|
expected = _expected_records()
|
||||||
|
definitions = (
|
||||||
|
("passwd", 7, 2),
|
||||||
|
("group", 4, 2),
|
||||||
|
("shadow", 9, 0),
|
||||||
|
("gshadow", 4, 0),
|
||||||
|
)
|
||||||
|
planned = []
|
||||||
|
account_present = False
|
||||||
|
for name, fields, identity_index in definitions:
|
||||||
|
path = HOST_ETC / name
|
||||||
|
value, metadata = _read_regular(path)
|
||||||
|
records = _records(value, fields, name)
|
||||||
|
updated = _reconcile_record(
|
||||||
|
records, expected[name], identity_index=identity_index
|
||||||
|
)
|
||||||
|
if name == "passwd":
|
||||||
|
account_present = expected[name] in records
|
||||||
|
planned.append((path, value, metadata, _encode(updated)))
|
||||||
|
try:
|
||||||
|
(HOST_HOME / ACCOUNT).lstat()
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
if not account_present:
|
||||||
|
raise HardeningError("dedicated Hermes account home already exists")
|
||||||
|
for path, value, metadata, _updated in planned:
|
||||||
|
_backup_once(path, value, metadata)
|
||||||
|
try:
|
||||||
|
for path, _old, metadata, updated in planned:
|
||||||
|
_atomic_write(
|
||||||
|
path,
|
||||||
|
updated,
|
||||||
|
mode=stat.S_IMODE(metadata.st_mode),
|
||||||
|
uid=metadata.st_uid,
|
||||||
|
gid=metadata.st_gid,
|
||||||
|
)
|
||||||
|
for path, _old, _metadata, _updated in planned:
|
||||||
|
name = path.name
|
||||||
|
fields = next(item[1] for item in definitions if item[0] == name)
|
||||||
|
records = _records(_read_regular(path)[0], fields, name)
|
||||||
|
if expected[name] not in records:
|
||||||
|
raise HardeningError("dedicated Hermes account validation failed")
|
||||||
|
except Exception:
|
||||||
|
for path, old, metadata, _updated in planned:
|
||||||
|
_atomic_write(
|
||||||
|
path,
|
||||||
|
old,
|
||||||
|
mode=stat.S_IMODE(metadata.st_mode),
|
||||||
|
uid=metadata.st_uid,
|
||||||
|
gid=metadata.st_gid,
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def _validated_public_key(path: Path) -> bytes:
|
||||||
|
value, _ = _read_regular(path, 16 * 1024)
|
||||||
|
line = value.strip()
|
||||||
|
if b"\n" in line or b"\r" in line:
|
||||||
|
raise HardeningError("Hermes public key must contain one line")
|
||||||
|
fields = line.split()
|
||||||
|
if len(fields) not in {2, 3} or fields[0] not in {
|
||||||
|
b"ssh-ed25519",
|
||||||
|
b"ecdsa-sha2-nistp256",
|
||||||
|
b"ssh-rsa",
|
||||||
|
}:
|
||||||
|
raise HardeningError("Hermes public key format is unsupported")
|
||||||
|
try:
|
||||||
|
base64.b64decode(fields[1], validate=True)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HardeningError("Hermes public key payload is invalid") from exc
|
||||||
|
return line
|
||||||
|
|
||||||
|
|
||||||
|
def _directory(path: Path, *, mode: int, uid: int, gid: int) -> None:
|
||||||
|
try:
|
||||||
|
path.mkdir(mode=mode)
|
||||||
|
except FileExistsError:
|
||||||
|
pass
|
||||||
|
metadata = path.lstat()
|
||||||
|
if not stat.S_ISDIR(metadata.st_mode) or stat.S_ISLNK(metadata.st_mode):
|
||||||
|
raise HardeningError(f"unsafe account directory: {path.name}")
|
||||||
|
if metadata.st_uid not in {0, uid} or metadata.st_gid not in {0, gid}:
|
||||||
|
raise HardeningError(f"account directory ownership conflicts: {path.name}")
|
||||||
|
os.chown(path, uid, gid)
|
||||||
|
path.chmod(mode)
|
||||||
|
|
||||||
|
|
||||||
|
def _without_key(value: bytes, key: bytes) -> bytes:
|
||||||
|
return b"".join(
|
||||||
|
line
|
||||||
|
for line in value.splitlines(keepends=True)
|
||||||
|
if line.strip(b"\r\n") != key
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _move_key(public_key: Path) -> None:
|
||||||
|
key = _validated_public_key(public_key)
|
||||||
|
home = HOST_HOME / ACCOUNT
|
||||||
|
ssh = home / ".ssh"
|
||||||
|
_directory(home, mode=0o700, uid=ACCOUNT_UID, gid=ACCOUNT_GID)
|
||||||
|
_directory(ssh, mode=0o700, uid=ACCOUNT_UID, gid=ACCOUNT_GID)
|
||||||
|
target = ssh / "authorized_keys"
|
||||||
|
if target.exists():
|
||||||
|
current, metadata = _read_regular(target, MAX_AUTHORIZED_KEYS)
|
||||||
|
if current != key + b"\n":
|
||||||
|
_backup_once(target, current, metadata)
|
||||||
|
_atomic_write(target, key + b"\n", mode=0o600, uid=ACCOUNT_UID, gid=ACCOUNT_GID)
|
||||||
|
for legacy in LEGACY_ACCOUNTS:
|
||||||
|
authorized = HOST_HOME / legacy / ".ssh" / "authorized_keys"
|
||||||
|
try:
|
||||||
|
value, metadata = _read_regular(authorized, MAX_AUTHORIZED_KEYS)
|
||||||
|
except FileNotFoundError:
|
||||||
|
continue
|
||||||
|
updated = _without_key(value, key)
|
||||||
|
if updated == value:
|
||||||
|
continue
|
||||||
|
_backup_once(authorized, value, metadata)
|
||||||
|
_atomic_write(
|
||||||
|
authorized,
|
||||||
|
updated,
|
||||||
|
mode=stat.S_IMODE(metadata.st_mode),
|
||||||
|
uid=metadata.st_uid,
|
||||||
|
gid=metadata.st_gid,
|
||||||
|
)
|
||||||
|
if _read_regular(target, MAX_AUTHORIZED_KEYS)[0] != key + b"\n":
|
||||||
|
raise HardeningError("dedicated Hermes authorized key validation failed")
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_acl(value: bytes, mode: int) -> list[tuple[int, int, int]]:
|
||||||
|
"""Decode a bounded POSIX ACL or derive one from ordinary mode bits."""
|
||||||
|
if not value:
|
||||||
|
group = (mode >> 3) & 0o7
|
||||||
|
return [
|
||||||
|
(ACL_USER_OBJ, (mode >> 6) & 0o7, ACL_UNDEFINED_ID),
|
||||||
|
(ACL_GROUP_OBJ, group, ACL_UNDEFINED_ID),
|
||||||
|
(ACL_MASK, group, ACL_UNDEFINED_ID),
|
||||||
|
(ACL_OTHER, mode & 0o7, ACL_UNDEFINED_ID),
|
||||||
|
]
|
||||||
|
if len(value) < ACL_HEADER.size or (len(value) - ACL_HEADER.size) % ACL_ENTRY.size:
|
||||||
|
raise HardeningError("sensitive directory ACL is malformed")
|
||||||
|
if ACL_HEADER.unpack_from(value)[0] != ACL_VERSION:
|
||||||
|
raise HardeningError("sensitive directory ACL version is unsupported")
|
||||||
|
entries = [
|
||||||
|
ACL_ENTRY.unpack_from(value, offset)
|
||||||
|
for offset in range(ACL_HEADER.size, len(value), ACL_ENTRY.size)
|
||||||
|
]
|
||||||
|
if any(permission > 0o7 for _tag, permission, _identifier in entries):
|
||||||
|
raise HardeningError("sensitive directory ACL permission is malformed")
|
||||||
|
required = {ACL_USER_OBJ, ACL_GROUP_OBJ, ACL_OTHER}
|
||||||
|
if not required <= {tag for tag, _permission, _identifier in entries}:
|
||||||
|
raise HardeningError("sensitive directory ACL is incomplete")
|
||||||
|
return entries
|
||||||
|
|
||||||
|
|
||||||
|
def _encode_acl(entries: list[tuple[int, int, int]]) -> bytes:
|
||||||
|
return ACL_HEADER.pack(ACL_VERSION) + b"".join(
|
||||||
|
ACL_ENTRY.pack(*entry) for entry in entries
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _acl_with_deny(value: bytes, mode: int) -> bytes:
|
||||||
|
entries = _decode_acl(value, mode)
|
||||||
|
entries = [
|
||||||
|
entry
|
||||||
|
for entry in entries
|
||||||
|
if not (entry[0] == ACL_USER and entry[2] == ACCOUNT_UID)
|
||||||
|
]
|
||||||
|
entries.append((ACL_USER, 0, ACCOUNT_UID))
|
||||||
|
if not any(tag == ACL_MASK for tag, _permission, _identifier in entries):
|
||||||
|
entries.append((ACL_MASK, (mode >> 3) & 0o7, ACL_UNDEFINED_ID))
|
||||||
|
order = {
|
||||||
|
ACL_USER_OBJ: 0,
|
||||||
|
ACL_USER: 1,
|
||||||
|
ACL_GROUP_OBJ: 2,
|
||||||
|
ACL_GROUP: 3,
|
||||||
|
ACL_MASK: 4,
|
||||||
|
ACL_OTHER: 5,
|
||||||
|
}
|
||||||
|
entries.sort(key=lambda entry: (order.get(entry[0], 99), entry[2]))
|
||||||
|
return _encode_acl(entries)
|
||||||
|
|
||||||
|
|
||||||
|
def _read_acl(path: Path) -> bytes:
|
||||||
|
try:
|
||||||
|
return os.getxattr(path, ACL_XATTR, follow_symlinks=False)
|
||||||
|
except OSError as exc:
|
||||||
|
if exc.errno in {errno.ENODATA, getattr(errno, "ENOATTR", errno.ENODATA)}:
|
||||||
|
return b""
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_acl_backup(value: bytes) -> None:
|
||||||
|
if value[:1] not in {b"A", b"N"}:
|
||||||
|
raise HardeningError("sensitive directory ACL backup is malformed")
|
||||||
|
if value[:1] == b"A":
|
||||||
|
if len(value) == 1:
|
||||||
|
raise HardeningError("sensitive directory ACL backup is malformed")
|
||||||
|
_decode_acl(value[1:], 0)
|
||||||
|
elif value != b"N":
|
||||||
|
raise HardeningError("sensitive directory ACL backup is malformed")
|
||||||
|
|
||||||
|
|
||||||
|
def _acl_backup_once(path: Path, value: bytes) -> None:
|
||||||
|
"""Persist the original ACL as a private, durable host recovery file."""
|
||||||
|
root = HOST_ETC / "hermes-node-boundary"
|
||||||
|
_directory(root, mode=0o700, uid=HOST_ROOT_UID, gid=HOST_ROOT_GID)
|
||||||
|
backup = root / f"{path.name}.acl"
|
||||||
|
encoded = b"A" + value if value else b"N"
|
||||||
|
try:
|
||||||
|
existing, _metadata = _read_regular(backup, 64 * 1024)
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
_validate_acl_backup(existing)
|
||||||
|
return
|
||||||
|
|
||||||
|
temporary = root / f".{path.name}.acl.hermes-{os.getpid()}"
|
||||||
|
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0)
|
||||||
|
descriptor = os.open(temporary, flags, 0o600)
|
||||||
|
try:
|
||||||
|
if os.write(descriptor, encoded) != len(encoded):
|
||||||
|
raise HardeningError("short sensitive directory ACL backup write")
|
||||||
|
os.fchmod(descriptor, 0o600)
|
||||||
|
os.fchown(descriptor, HOST_ROOT_UID, HOST_ROOT_GID)
|
||||||
|
os.fsync(descriptor)
|
||||||
|
except Exception:
|
||||||
|
temporary.unlink(missing_ok=True)
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
os.close(descriptor)
|
||||||
|
try:
|
||||||
|
os.link(temporary, backup, follow_symlinks=False)
|
||||||
|
except FileExistsError:
|
||||||
|
# Another reconciler won the race; never overwrite the first backup.
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
temporary.unlink(missing_ok=True)
|
||||||
|
stored, _metadata = _read_regular(backup, 64 * 1024)
|
||||||
|
_validate_acl_backup(stored)
|
||||||
|
directory = os.open(root, os.O_RDONLY)
|
||||||
|
try:
|
||||||
|
os.fsync(directory)
|
||||||
|
finally:
|
||||||
|
os.close(directory)
|
||||||
|
|
||||||
|
|
||||||
|
def _deny_sensitive_root(path: Path) -> None:
|
||||||
|
metadata = path.lstat()
|
||||||
|
if not stat.S_ISDIR(metadata.st_mode) or stat.S_ISLNK(metadata.st_mode):
|
||||||
|
raise HardeningError(f"unsafe sensitive directory: {path.name}")
|
||||||
|
if metadata.st_uid != HOST_ROOT_UID:
|
||||||
|
raise HardeningError(f"sensitive directory is not root-owned: {path.name}")
|
||||||
|
current = _read_acl(path)
|
||||||
|
_acl_backup_once(path, current)
|
||||||
|
updated = _acl_with_deny(current, stat.S_IMODE(metadata.st_mode))
|
||||||
|
try:
|
||||||
|
os.setxattr(path, ACL_XATTR, updated, follow_symlinks=False)
|
||||||
|
verified = _decode_acl(_read_acl(path), stat.S_IMODE(metadata.st_mode))
|
||||||
|
if (ACL_USER, 0, ACCOUNT_UID) not in verified:
|
||||||
|
raise HardeningError("sensitive directory ACL validation failed")
|
||||||
|
except Exception:
|
||||||
|
if current:
|
||||||
|
os.setxattr(path, ACL_XATTR, current, follow_symlinks=False)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
os.removexattr(path, ACL_XATTR, follow_symlinks=False)
|
||||||
|
except OSError as exc:
|
||||||
|
if exc.errno not in {
|
||||||
|
errno.ENODATA,
|
||||||
|
getattr(errno, "ENOATTR", errno.ENODATA),
|
||||||
|
}:
|
||||||
|
raise
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def _deny_sensitive_roots() -> None:
|
||||||
|
for path in (HOST_K3S, HOST_KUBELET, HOST_RUN_K3S, HOST_RUN_CONTAINERD):
|
||||||
|
_deny_sensitive_root(path)
|
||||||
|
|
||||||
|
|
||||||
|
def reconcile(public_key: Path) -> None:
|
||||||
|
"""Create the locked account and move only the Hermes authorization key."""
|
||||||
|
_reconcile_databases()
|
||||||
|
_deny_sensitive_roots()
|
||||||
|
_move_key(public_key)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--public-key-file", type=Path, required=True)
|
||||||
|
args = parser.parse_args()
|
||||||
|
reconcile(args.public_key_file)
|
||||||
|
print("Dedicated Hermes node account reconciled.", flush=True)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@ -86,6 +86,21 @@ def _write_empty_auth_store() -> None:
|
|||||||
os.chown(path, OWNER_UID, OWNER_GID)
|
os.chown(path, OWNER_UID, OWNER_GID)
|
||||||
|
|
||||||
|
|
||||||
|
def _stage_node_ssh_config() -> None:
|
||||||
|
"""Force every Atlas node alias onto the dedicated Hermes OS account."""
|
||||||
|
destination = RUNTIME_ROOT / "node-ssh-config"
|
||||||
|
source = _copy_secret("node-ssh-config", destination)
|
||||||
|
if "\x00" in source:
|
||||||
|
destination.unlink(missing_ok=True)
|
||||||
|
raise RuntimeError("node SSH config contains invalid control data")
|
||||||
|
destination.write_text(
|
||||||
|
"Host titan-*\n User hermes-agent\n" + source.rstrip() + "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
destination.chmod(0o600)
|
||||||
|
os.chown(destination, OWNER_UID, OWNER_GID)
|
||||||
|
|
||||||
|
|
||||||
def stage_agent() -> None:
|
def stage_agent() -> None:
|
||||||
"""Stage the owner agent's complete runtime access set."""
|
"""Stage the owner agent's complete runtime access set."""
|
||||||
for path in (RUNTIME_ROOT, RUNTIME_ROOT / "claude", RUNTIME_ROOT / "codex"):
|
for path in (RUNTIME_ROOT, RUNTIME_ROOT / "claude", RUNTIME_ROOT / "codex"):
|
||||||
@ -93,13 +108,11 @@ def stage_agent() -> None:
|
|||||||
for name in (
|
for name in (
|
||||||
"agent-api-key",
|
"agent-api-key",
|
||||||
"chat-relay-key",
|
"chat-relay-key",
|
||||||
"gitea-token",
|
|
||||||
"gitea-username",
|
|
||||||
"node-ssh-private-key",
|
"node-ssh-private-key",
|
||||||
"node-ssh-config",
|
|
||||||
"node-ssh-known-hosts",
|
"node-ssh-known-hosts",
|
||||||
):
|
):
|
||||||
_copy_secret(name, RUNTIME_ROOT / name)
|
_copy_secret(name, RUNTIME_ROOT / name)
|
||||||
|
_stage_node_ssh_config()
|
||||||
_validated_json(
|
_validated_json(
|
||||||
"claude-credentials",
|
"claude-credentials",
|
||||||
RUNTIME_ROOT / "claude" / ".credentials.json",
|
RUNTIME_ROOT / "claude" / ".credentials.json",
|
||||||
|
|||||||
@ -5,17 +5,21 @@ description: Read bounded private Atlas repository and pull-request metadata or
|
|||||||
|
|
||||||
# Manage Atlas pull requests
|
# Manage Atlas pull requests
|
||||||
|
|
||||||
Use `/opt/coordinator/gitea_api.py` for Forgejo API access. It reads its token
|
Use `/opt/scm/gitea_api.py` for Forgejo API access. The agent pod has no
|
||||||
from the pod-lifetime Vault projection; never read that file, copy the token,
|
repository credential. The client calls a separate least-authority broker;
|
||||||
put it in an argument or environment variable, or replace this client with
|
never bypass it with `curl` or direct Gitea HTTP.
|
||||||
`curl`.
|
|
||||||
|
Use the configured broker Git remote for clone, fetch, and creation of a new
|
||||||
|
namespaced feature branch. Existing-ref updates, protected refs, deletion,
|
||||||
|
and force-push are rejected by the broker. Never replace that remote with a
|
||||||
|
credential-bearing URL.
|
||||||
|
|
||||||
## Read repository or PR state
|
## Read repository or PR state
|
||||||
|
|
||||||
Pass one allowed Atlas metadata API path to the read operation:
|
Pass one allowed Atlas metadata API path to the read operation:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
/opt/coordinator/gitea_api.py read /api/v1/repos/atlas/REPO/pulls/NUMBER
|
/opt/scm/gitea_api.py read /api/v1/repos/atlas/REPO/pulls/NUMBER
|
||||||
```
|
```
|
||||||
|
|
||||||
The client exposes only repository metadata, PRs and PR evidence, branches,
|
The client exposes only repository metadata, PRs and PR evidence, branches,
|
||||||
@ -29,10 +33,16 @@ evidence, not authorization to change it.
|
|||||||
Before opening a PR, verify the remote, branch, clean worktree, diff, tests, and
|
Before opening a PR, verify the remote, branch, clean worktree, diff, tests, and
|
||||||
exact pushed commit. Never force-push. Pass that full 40-character pushed head
|
exact pushed commit. Never force-push. Pass that full 40-character pushed head
|
||||||
SHA explicitly. Keep the short body free of credentials and credential-like
|
SHA explicitly. Keep the short body free of credentials and credential-like
|
||||||
text. Validate without reading a credential or using the network:
|
text. The client screens structured assignments, known token formats, and long
|
||||||
|
high-entropy values as an accidental-disclosure barrier. It cannot prove text
|
||||||
|
is secret-free: short or multiword secrets under innocuous names may resemble
|
||||||
|
ordinary prose, so never place credential material in either field. Structured
|
||||||
|
inspection is deliberately bounded; large, deeply nested, or unusual config
|
||||||
|
blobs do not belong in these fields and may be rejected. Validate without
|
||||||
|
reading a credential or using the network:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
/opt/coordinator/gitea_api.py --dry-run create-draft REPO \
|
/opt/scm/gitea_api.py --dry-run create-draft REPO \
|
||||||
--base main --head hermes/TASK --head-sha FULL_PUSHED_SHA \
|
--base main --head hermes/TASK --head-sha FULL_PUSHED_SHA \
|
||||||
--title "Focused change" --body "Tests and review evidence"
|
--title "Focused change" --body "Tests and review evidence"
|
||||||
```
|
```
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
apiVersion: batch/v1
|
apiVersion: batch/v1
|
||||||
kind: Job
|
kind: Job
|
||||||
metadata:
|
metadata:
|
||||||
name: vault-k8s-auth-hermes-8
|
name: vault-k8s-auth-hermes-9
|
||||||
namespace: vault
|
namespace: vault
|
||||||
spec:
|
spec:
|
||||||
backoffLimit: 2
|
backoffLimit: 2
|
||||||
|
|||||||
@ -256,7 +256,9 @@ write_policy_and_role "game-stream" "game-stream" "game-stream-vault" \
|
|||||||
write_policy_and_role "hermes" "hermes" "hermes-vault,hermes-triage" \
|
write_policy_and_role "hermes" "hermes" "hermes-vault,hermes-triage" \
|
||||||
"hermes/triage-oidc hermes/agent-tokens hermes/triage-api" ""
|
"hermes/triage-oidc hermes/agent-tokens hermes/triage-api" ""
|
||||||
write_policy_and_role "hermes-agent" "hermes" "hermes-agent,hermes-switchyard" \
|
write_policy_and_role "hermes-agent" "hermes" "hermes-agent,hermes-switchyard" \
|
||||||
"hermes/agent-oidc hermes/agent-tokens hermes/chat-telegram hermes/developer-keycloak hermes/developer-gitea hermes/developer-harbor hermes/developer-jenkins hermes/developer-ssh" ""
|
"hermes/agent-oidc hermes/agent-tokens hermes/chat-telegram hermes/developer-keycloak hermes/developer-harbor hermes/developer-jenkins hermes/developer-ssh" ""
|
||||||
|
write_policy_and_role "hermes-scm-broker" "hermes-scm" "hermes-scm-broker" \
|
||||||
|
"hermes/developer-gitea" ""
|
||||||
write_policy_and_role "hermes-credential-sync" "hermes" "hermes-agent" \
|
write_policy_and_role "hermes-credential-sync" "hermes" "hermes-agent" \
|
||||||
"" "hermes/agent-tokens"
|
"" "hermes/agent-tokens"
|
||||||
write_policy_and_role "hermes-node-ssh" "hermes" "hermes-node-ssh-access" \
|
write_policy_and_role "hermes-node-ssh" "hermes" "hermes-node-ssh-access" \
|
||||||
|
|||||||
@ -92,13 +92,14 @@ def test_agent_config_keeps_delegated_reviewers_from_owning_task_lifecycle():
|
|||||||
assert "Atlas organization has private visibility" in instructions
|
assert "Atlas organization has private visibility" in instructions
|
||||||
assert "may be public or private" in instructions
|
assert "may be public or private" in instructions
|
||||||
assert "do not infer a\nrepository's visibility" in instructions
|
assert "do not infer a\nrepository's visibility" in instructions
|
||||||
assert "already supplied through `GIT_ASKPASS`" in instructions
|
assert "brokered Git for clone, fetch" in instructions
|
||||||
|
assert "client carries no repository credential" in instructions
|
||||||
assert "Never call `kanban_show` without a known, non-empty task ID" in instructions
|
assert "Never call `kanban_show` without a known, non-empty task ID" in instructions
|
||||||
assert "bounded ad-hoc inspection and acceptance checks may" in instructions
|
assert "bounded ad-hoc inspection and acceptance checks may" in instructions
|
||||||
assert "load implementation or TDD skills" in instructions
|
assert "load implementation or TDD skills" in instructions
|
||||||
assert "Never call `kanban_show` without\na known, non-empty task ID" in soul
|
assert "Never call `kanban_show` without\na known, non-empty task ID" in soul
|
||||||
assert "must load a skill only when its workflow\nmaterially applies" in soul
|
assert "must load a skill only when its workflow\nmaterially applies" in soul
|
||||||
assert "runtime-only `GIT_ASKPASS`" in soul
|
assert "no repository token is present in this pod" in soul
|
||||||
assert "`scm.bstein.dev` is Forgejo/Gitea, not GitHub" in instructions
|
assert "`scm.bstein.dev` is Forgejo/Gitea, not GitHub" in instructions
|
||||||
assert "GitHub/`gh` skill for an Atlas remote" in instructions
|
assert "GitHub/`gh` skill for an Atlas remote" in instructions
|
||||||
assert "load\n`$manage-atlas-pull-requests`" in instructions
|
assert "load\n`$manage-atlas-pull-requests`" in instructions
|
||||||
@ -560,13 +561,15 @@ def test_chat_image_generation_uses_private_owner_broker():
|
|||||||
"app": "hermes-chat-tenant"
|
"app": "hermes-chat-tenant"
|
||||||
}
|
}
|
||||||
vault_policy = (VAULT / "scripts" / "vault_k8s_auth_configure.sh").read_text()
|
vault_policy = (VAULT / "scripts" / "vault_k8s_auth_configure.sh").read_text()
|
||||||
assert (
|
agent_role = vault_policy[
|
||||||
'"hermes/agent-oidc hermes/agent-tokens hermes/chat-telegram '
|
vault_policy.index('write_policy_and_role "hermes-agent"') :
|
||||||
'hermes/developer-keycloak hermes/developer-gitea '
|
vault_policy.index(
|
||||||
'hermes/developer-harbor hermes/developer-jenkins '
|
"write_policy_and_role",
|
||||||
'hermes/developer-ssh"'
|
vault_policy.index('write_policy_and_role "hermes-agent"') + 1,
|
||||||
in vault_policy
|
)
|
||||||
)
|
]
|
||||||
|
assert "hermes/developer-gitea" not in agent_role
|
||||||
|
assert 'write_policy_and_role "hermes-scm-broker" "hermes-scm"' in vault_policy
|
||||||
assert (
|
assert (
|
||||||
'write_policy_and_role "hermes-node-ssh" "hermes" '
|
'write_policy_and_role "hermes-node-ssh" "hermes" '
|
||||||
'"hermes-node-ssh-access"' in vault_policy
|
'"hermes-node-ssh-access"' in vault_policy
|
||||||
|
|||||||
@ -15,7 +15,8 @@ import pytest
|
|||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
|
|
||||||
SCRIPTS = Path(__file__).parents[2] / "services/hermes/scripts"
|
ROOT = Path(__file__).parents[2]
|
||||||
|
SCRIPTS = ROOT / "services/hermes/scripts"
|
||||||
sys.path.insert(0, str(SCRIPTS))
|
sys.path.insert(0, str(SCRIPTS))
|
||||||
HERMES = Path(__file__).parents[2] / "services/hermes"
|
HERMES = Path(__file__).parents[2] / "services/hermes"
|
||||||
KEYCLOAK = Path(__file__).parents[2] / "services/keycloak"
|
KEYCLOAK = Path(__file__).parents[2] / "services/keycloak"
|
||||||
@ -1353,20 +1354,27 @@ def test_agent_network_boundary_allows_only_authenticated_and_metrics_surfaces()
|
|||||||
"ports": [{"protocol": "TCP", "port": 9010}],
|
"ports": [{"protocol": "TCP", "port": 9010}],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
assert isolation["spec"]["egress"] == [{}]
|
egress = isolation["spec"]["egress"]
|
||||||
|
assert any(
|
||||||
|
rule.get("ports") == [{"protocol": "TCP", "port": 9081}]
|
||||||
|
for rule in egress
|
||||||
|
)
|
||||||
|
assert "hermes-scm" in yaml.safe_dump(egress)
|
||||||
|
assert "gitea" in yaml.safe_dump(egress)
|
||||||
|
assert egress != [{}]
|
||||||
|
|
||||||
|
|
||||||
def test_owner_agent_has_cluster_admin_kubernetes_context():
|
def test_owner_agent_has_scoped_read_only_kubernetes_context():
|
||||||
config = yaml.safe_load((HERMES / "agent-kubeconfig.yaml").read_text())
|
config = yaml.safe_load((HERMES / "agent-kubeconfig.yaml").read_text())
|
||||||
assert config["current-context"] == "atlas-owner"
|
assert config["current-context"] == "atlas-observer"
|
||||||
assert config["contexts"][0]["context"]["namespace"] == "default"
|
assert config["contexts"][0]["context"]["namespace"] == "default"
|
||||||
rbac_path = HERMES / "agent-rbac.yaml"
|
rbac_path = ROOT / "services/hermes-observer-rbac/rbac.yaml"
|
||||||
documents = [item for item in yaml.safe_load_all(rbac_path.read_text()) if item]
|
documents = [item for item in yaml.safe_load_all(rbac_path.read_text()) if item]
|
||||||
binding = next(item for item in documents if item["kind"] == "ClusterRoleBinding")
|
binding = next(item for item in documents if item["kind"] == "ClusterRoleBinding")
|
||||||
assert binding["roleRef"] == {
|
assert binding["roleRef"] == {
|
||||||
"apiGroup": "rbac.authorization.k8s.io",
|
"apiGroup": "rbac.authorization.k8s.io",
|
||||||
"kind": "ClusterRole",
|
"kind": "ClusterRole",
|
||||||
"name": "cluster-admin",
|
"name": "hermes-agent-cluster-observer-v2",
|
||||||
}
|
}
|
||||||
assert binding["subjects"] == [
|
assert binding["subjects"] == [
|
||||||
{"kind": "ServiceAccount", "name": "hermes-agent", "namespace": "hermes"}
|
{"kind": "ServiceAccount", "name": "hermes-agent", "namespace": "hermes"}
|
||||||
@ -1441,15 +1449,16 @@ def test_owner_agent_has_pinned_dedicated_node_ssh_access():
|
|||||||
"secretProviderClass": "hermes-node-ssh-access"
|
"secretProviderClass": "hermes-node-ssh-access"
|
||||||
}
|
}
|
||||||
reconciler = pod["containers"][0]["args"][0]
|
reconciler = pod["containers"][0]["args"][0]
|
||||||
assert "cat /vault/secrets/node-ssh-public-key" in reconciler
|
assert "/opt/node-hardener/node_account_hardening.py" in reconciler
|
||||||
assert "grep -qxF" in reconciler
|
assert "--public-key-file /vault/secrets/node-ssh-public-key" in reconciler
|
||||||
assert "for user in atlas oceanus" in reconciler
|
assert "sleep 300" in reconciler
|
||||||
assert "/host-etc/passwd" in reconciler
|
host_etc = next(item for item in pod["volumes"] if item["name"] == "host-etc")
|
||||||
assert "chown \"${uid}:${gid}\"" in reconciler
|
assert host_etc["hostPath"] == {"path": "/etc", "type": "Directory"}
|
||||||
host_passwd = next(
|
hardener = (HERMES / "scripts/node_account_hardening.py").read_text()
|
||||||
item for item in pod["volumes"] if item["name"] == "host-passwd"
|
assert 'ACCOUNT = "hermes-agent"' in hardener
|
||||||
)
|
assert 'LEGACY_ACCOUNTS = ("atlas", "oceanus")' in hardener
|
||||||
assert host_passwd["hostPath"] == {"path": "/etc/passwd", "type": "File"}
|
assert 'ACCOUNT_UID = 1200' in hardener
|
||||||
|
assert 'ACCOUNT_GID = 1200' in hardener
|
||||||
|
|
||||||
|
|
||||||
def test_owner_agent_tracks_no_ssh_identity_or_host_key_material():
|
def test_owner_agent_tracks_no_ssh_identity_or_host_key_material():
|
||||||
@ -1518,7 +1527,9 @@ def test_switchyard_has_a_dedicated_non_owner_identity_and_read_only_catalog():
|
|||||||
|
|
||||||
rbac = [
|
rbac = [
|
||||||
item
|
item
|
||||||
for item in yaml.safe_load_all((HERMES / "agent-rbac.yaml").read_text())
|
for item in yaml.safe_load_all(
|
||||||
|
(ROOT / "services/hermes-observer-rbac/rbac.yaml").read_text()
|
||||||
|
)
|
||||||
if item
|
if item
|
||||||
]
|
]
|
||||||
binding = next(item for item in rbac if item["kind"] == "ClusterRoleBinding")
|
binding = next(item for item in rbac if item["kind"] == "ClusterRoleBinding")
|
||||||
|
|||||||
@ -445,8 +445,10 @@ def test_cassandra_sync_repairs_existing_worktree(
|
|||||||
assert all("GITEA_TOKEN" not in environment for environment in environments)
|
assert all("GITEA_TOKEN" not in environment for environment in environments)
|
||||||
|
|
||||||
|
|
||||||
def test_cassandra_sync_repairs_origin_without_token(tmp_path: Path, monkeypatch):
|
def test_cassandra_sync_repairs_origin_through_broker_without_token(
|
||||||
"""Missing credentials skip only the fetch, not the local origin repair."""
|
tmp_path: Path, monkeypatch
|
||||||
|
):
|
||||||
|
"""Repository sync uses the credential-isolated broker remote."""
|
||||||
workspace = tmp_path / "cassandra"
|
workspace = tmp_path / "cassandra"
|
||||||
(workspace / ".git").mkdir(parents=True)
|
(workspace / ".git").mkdir(parents=True)
|
||||||
monkeypatch.setattr(coordinator, "CASSANDRA_BASE_PATH", workspace)
|
monkeypatch.setattr(coordinator, "CASSANDRA_BASE_PATH", workspace)
|
||||||
@ -461,8 +463,13 @@ def test_cassandra_sync_repairs_origin_without_token(tmp_path: Path, monkeypatch
|
|||||||
monkeypatch.setattr(coordinator.subprocess, "run", run)
|
monkeypatch.setattr(coordinator.subprocess, "run", run)
|
||||||
state = coordinator.sync_cassandra_repo({})
|
state = coordinator.sync_cassandra_repo({})
|
||||||
|
|
||||||
assert state == "ready; fetch skipped until Gitea token is configured"
|
assert state == "ready"
|
||||||
assert [command[-3] for command in commands] == ["remote", "set-url"]
|
assert any(
|
||||||
|
"hermes-scm-broker.hermes-scm.svc.cluster.local" in " ".join(cmd)
|
||||||
|
for cmd in commands
|
||||||
|
)
|
||||||
|
assert all("token" not in " ".join(cmd).lower() for cmd in commands)
|
||||||
|
assert commands[-1][-4:] == ["fetch", "--quiet", "--prune", "origin"]
|
||||||
|
|
||||||
|
|
||||||
def test_migrate_open_cassandra_tasks_preserves_running_and_done_tasks():
|
def test_migrate_open_cassandra_tasks_preserves_running_and_done_tasks():
|
||||||
|
|||||||
@ -2,18 +2,22 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
import copy
|
import copy
|
||||||
import importlib.util
|
import importlib.util
|
||||||
import json
|
import json
|
||||||
import sys
|
import sys
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
from email.message import Message
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
ROOT = Path(__file__).parents[2]
|
ROOT = Path(__file__).parents[2]
|
||||||
CLIENT_PATH = ROOT / "services/hermes/scripts/gitea_api.py"
|
CLIENT_PATH = ROOT / "services/hermes/scm-common/scripts/gitea_api.py"
|
||||||
HEAD_SHA = "465cf9146b05c174a2a8d310aff6c64be58277b6"
|
HEAD_SHA = "465cf9146b05c174a2a8d310aff6c64be58277b6"
|
||||||
|
if str(CLIENT_PATH.parent) not in sys.path:
|
||||||
|
sys.path.insert(0, str(CLIENT_PATH.parent))
|
||||||
|
|
||||||
|
|
||||||
def _load():
|
def _load():
|
||||||
@ -58,8 +62,11 @@ def _draft_response(**updates):
|
|||||||
|
|
||||||
|
|
||||||
class Response:
|
class Response:
|
||||||
def __init__(self, body: object):
|
def __init__(self, body: object, status: int | None = None):
|
||||||
self.body = body if isinstance(body, bytes) else json.dumps(body).encode()
|
self.body = body if isinstance(body, bytes) else json.dumps(body).encode()
|
||||||
|
self.status = status if status is not None else (201 if isinstance(body, dict) else 200)
|
||||||
|
self.headers = Message()
|
||||||
|
self.headers["Content-Type"] = "application/json"
|
||||||
|
|
||||||
def __enter__(self):
|
def __enter__(self):
|
||||||
return self
|
return self
|
||||||
@ -101,6 +108,10 @@ def test_redirect_handler_rejects_cross_origin_with_sentinel_authorization():
|
|||||||
("https://evil.example", "/api/v1/repos/atlas/cassandra"),
|
("https://evil.example", "/api/v1/repos/atlas/cassandra"),
|
||||||
("http://scm.bstein.dev", "/api/v1/repos/atlas/cassandra"),
|
("http://scm.bstein.dev", "/api/v1/repos/atlas/cassandra"),
|
||||||
("https://scm.bstein.dev:443", "/api/v1/repos/atlas/cassandra"),
|
("https://scm.bstein.dev:443", "/api/v1/repos/atlas/cassandra"),
|
||||||
|
("https://scm.bstein.dev/", "/api/v1/repos/atlas/cassandra"),
|
||||||
|
("HTTPS://scm.bstein.dev", "/api/v1/repos/atlas/cassandra"),
|
||||||
|
("https://SCM.bstein.dev", "/api/v1/repos/atlas/cassandra"),
|
||||||
|
("https://user@scm.bstein.dev", "/api/v1/repos/atlas/cassandra"),
|
||||||
("https://scm.bstein.dev", "https://evil.example/api/v1/repos/atlas/cassandra"),
|
("https://scm.bstein.dev", "https://evil.example/api/v1/repos/atlas/cassandra"),
|
||||||
("https://scm.bstein.dev", "/api/v1/repos/evil/cassandra"),
|
("https://scm.bstein.dev", "/api/v1/repos/evil/cassandra"),
|
||||||
("https://scm.bstein.dev", "/api/v1/repos/%61tlas/cassandra"),
|
("https://scm.bstein.dev", "/api/v1/repos/%61tlas/cassandra"),
|
||||||
@ -114,6 +125,72 @@ def test_host_owner_and_path_escape_attempts_are_rejected(base_url: str, path: s
|
|||||||
client.build_request("GET", path, base_url=base_url, token="secret")
|
client.build_request("GET", path, base_url=base_url, token="secret")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"path",
|
||||||
|
[
|
||||||
|
"/api/v1/repos/atlas/cassandra/pulls/1\nHost: evil.example",
|
||||||
|
"/api/v1/repos/atlas/cassandra/pulls/1\r\nX-Test: value",
|
||||||
|
"/api/v1/repos/atlas/cassandra/pulls/1\tignored",
|
||||||
|
"/api/v1/repos/atlas/cassandra/pulls/1\x00ignored",
|
||||||
|
"/api/v1/repos/atlas/cassandra/pulls/1\x1fignored",
|
||||||
|
"/api/v1/repos/atlas/cassandra/pulls/1\x7fignored",
|
||||||
|
"/api/v1/repos/atlas/cassandra\\pulls\\1",
|
||||||
|
"/api/v1/repos/atlas/cassandra/pulls/%31",
|
||||||
|
"/api/v1/repos/atlas/cassandra/pulls/1",
|
||||||
|
" https://scm.bstein.dev/api/v1/repos/atlas/cassandra",
|
||||||
|
"https://scm.bstein.dev/api/v1/repos/atlas/cassandra",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_raw_noncanonical_target_is_rejected_before_urlsplit_and_opener(
|
||||||
|
path: str, monkeypatch
|
||||||
|
):
|
||||||
|
client = _load()
|
||||||
|
split_called = False
|
||||||
|
opener_called = False
|
||||||
|
original_urlsplit = client.urllib.parse.urlsplit
|
||||||
|
|
||||||
|
def urlsplit(*args, **kwargs):
|
||||||
|
nonlocal split_called
|
||||||
|
split_called = True
|
||||||
|
return original_urlsplit(*args, **kwargs)
|
||||||
|
|
||||||
|
def opener(*_args, **_kwargs):
|
||||||
|
nonlocal opener_called
|
||||||
|
opener_called = True
|
||||||
|
return Response(b"{}")
|
||||||
|
|
||||||
|
monkeypatch.setattr(client.urllib.parse, "urlsplit", urlsplit)
|
||||||
|
with pytest.raises(client.PolicyError):
|
||||||
|
client.read(path, token="runtime", opener=opener)
|
||||||
|
assert split_called is False
|
||||||
|
assert opener_called is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"path",
|
||||||
|
[
|
||||||
|
"/api/v1/repos/atlas/cassandra/pulls/1?",
|
||||||
|
"//scm.bstein.dev/api/v1/repos/atlas/cassandra",
|
||||||
|
"/api/v1/repos/atlas/cassandra/./pulls/1",
|
||||||
|
"/api/v1/repos/atlas/cassandra/../admin",
|
||||||
|
"/api/v1/repos/atlas/cassandra//pulls/1",
|
||||||
|
"/api/v1/repos/atlas/cassandra/pulls/1?limit=01",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_noncanonical_round_trip_or_segments_never_reach_opener(path: str):
|
||||||
|
client = _load()
|
||||||
|
opener_called = False
|
||||||
|
|
||||||
|
def opener(*_args, **_kwargs):
|
||||||
|
nonlocal opener_called
|
||||||
|
opener_called = True
|
||||||
|
return Response(b"{}")
|
||||||
|
|
||||||
|
with pytest.raises(client.PolicyError):
|
||||||
|
client.read(path, token="runtime", opener=opener)
|
||||||
|
assert opener_called is False
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"path",
|
"path",
|
||||||
[
|
[
|
||||||
@ -179,6 +256,109 @@ def test_read_query_is_bounded(path: str):
|
|||||||
client.authorize_request("GET", path, None)
|
client.authorize_request("GET", path, None)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"query",
|
||||||
|
[
|
||||||
|
"page=0",
|
||||||
|
"page=10001",
|
||||||
|
"page=01",
|
||||||
|
"limit=0",
|
||||||
|
"limit=51",
|
||||||
|
"limit=01",
|
||||||
|
"page=" + "9" * 4000,
|
||||||
|
"page=0",
|
||||||
|
"page=%EF%BC%90",
|
||||||
|
"state=%6fpen",
|
||||||
|
"p%61ge=1",
|
||||||
|
"page=1&page=2",
|
||||||
|
"page=1&" + "x" * 17 + "=1",
|
||||||
|
"state=" + "x" * 17,
|
||||||
|
"page=1&limit=2&state=open&extra=3",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_read_query_requires_canonical_bounded_ascii(query: str):
|
||||||
|
client = _load()
|
||||||
|
|
||||||
|
with pytest.raises(client.PolicyError):
|
||||||
|
client.authorize_request(
|
||||||
|
"GET", f"/api/v1/repos/atlas/cassandra/pulls?{query}", None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"suffix",
|
||||||
|
[
|
||||||
|
"pulls/{number}",
|
||||||
|
"pulls/{number}.patch",
|
||||||
|
"pulls/{number}.diff",
|
||||||
|
"pulls/{number}/commits",
|
||||||
|
"pulls/{number}/files",
|
||||||
|
"commits/{number}/status",
|
||||||
|
"commits/{number}/statuses",
|
||||||
|
"statuses/{number}",
|
||||||
|
"branches/{number}",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_oversized_numeric_or_captured_path_never_reaches_opener(suffix: str):
|
||||||
|
client = _load()
|
||||||
|
called = False
|
||||||
|
|
||||||
|
def opener(*_args, **_kwargs):
|
||||||
|
nonlocal called
|
||||||
|
called = True
|
||||||
|
return Response(b"{}")
|
||||||
|
|
||||||
|
path = "/api/v1/repos/atlas/cassandra/" + suffix.format(number="9" * 4000)
|
||||||
|
with pytest.raises(client.PolicyError):
|
||||||
|
client.read(path, token="runtime", opener=opener)
|
||||||
|
assert called is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"number",
|
||||||
|
["0", "01", "2147483648", "12", "%31", "12345678901"],
|
||||||
|
)
|
||||||
|
@pytest.mark.parametrize("tail", ["", ".patch", ".diff", "/commits", "/files"])
|
||||||
|
def test_noncanonical_or_out_of_range_pr_number_never_reaches_opener(
|
||||||
|
number: str, tail: str
|
||||||
|
):
|
||||||
|
client = _load()
|
||||||
|
called = False
|
||||||
|
|
||||||
|
def opener(*_args, **_kwargs):
|
||||||
|
nonlocal called
|
||||||
|
called = True
|
||||||
|
return Response(b"{}")
|
||||||
|
|
||||||
|
with pytest.raises(client.PolicyError):
|
||||||
|
client.read(
|
||||||
|
f"/api/v1/repos/atlas/cassandra/pulls/{number}{tail}",
|
||||||
|
token="runtime",
|
||||||
|
opener=opener,
|
||||||
|
)
|
||||||
|
assert called is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_maximum_bounded_pr_number_is_readable():
|
||||||
|
client = _load()
|
||||||
|
called = False
|
||||||
|
|
||||||
|
def opener(*_args, **_kwargs):
|
||||||
|
nonlocal called
|
||||||
|
called = True
|
||||||
|
return Response(b"{}")
|
||||||
|
|
||||||
|
assert (
|
||||||
|
client.read(
|
||||||
|
"/api/v1/repos/atlas/cassandra/pulls/2147483647",
|
||||||
|
token="runtime",
|
||||||
|
opener=opener,
|
||||||
|
)
|
||||||
|
== b"{}"
|
||||||
|
)
|
||||||
|
assert called is True
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
("method", "path", "data"),
|
("method", "path", "data"),
|
||||||
[
|
[
|
||||||
@ -230,10 +410,108 @@ def test_complete_git_ref_validation_rejects_invalid_names(ref: str):
|
|||||||
def test_git_ref_validation_uses_fixed_trusted_binary():
|
def test_git_ref_validation_uses_fixed_trusted_binary():
|
||||||
client = _load()
|
client = _load()
|
||||||
|
|
||||||
assert client.GIT_BIN == "/usr/bin/git"
|
assert client._validate_ref.__globals__["GIT_BIN"] == "/usr/bin/git"
|
||||||
assert client._validate_ref("hermes/valid-fix", "head") == "hermes/valid-fix"
|
assert client._validate_ref("hermes/valid-fix", "head") == "hermes/valid-fix"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("field", ["base", "head"])
|
||||||
|
@pytest.mark.parametrize("oversized", ["r" * 100_000, "🧪" * 128])
|
||||||
|
def test_oversized_ref_never_invokes_git_request_or_opener(
|
||||||
|
field: str, oversized: str, monkeypatch
|
||||||
|
):
|
||||||
|
client = _load()
|
||||||
|
git_called = False
|
||||||
|
request_built = False
|
||||||
|
opener_called = False
|
||||||
|
original_build_request = client.build_request
|
||||||
|
|
||||||
|
def git_run(*_args, **_kwargs):
|
||||||
|
nonlocal git_called
|
||||||
|
git_called = True
|
||||||
|
raise AssertionError("Git must not receive an oversized ref")
|
||||||
|
|
||||||
|
def build_request(*args, **kwargs):
|
||||||
|
nonlocal request_built
|
||||||
|
request_built = True
|
||||||
|
return original_build_request(*args, **kwargs)
|
||||||
|
|
||||||
|
def opener(*_args, **_kwargs):
|
||||||
|
nonlocal opener_called
|
||||||
|
opener_called = True
|
||||||
|
return Response(_draft_response())
|
||||||
|
|
||||||
|
monkeypatch.setattr(client._validate_ref.__globals__["subprocess"], "run", git_run)
|
||||||
|
client.build_request = build_request
|
||||||
|
refs = {"base": "main", "head": "hermes/fix"}
|
||||||
|
refs[field] = oversized
|
||||||
|
with pytest.raises(client.PolicyError, match="branch-name limit"):
|
||||||
|
client.create_draft(
|
||||||
|
"cassandra",
|
||||||
|
base=refs["base"],
|
||||||
|
head=refs["head"],
|
||||||
|
head_sha=HEAD_SHA,
|
||||||
|
title="Focused fix",
|
||||||
|
body="Review evidence",
|
||||||
|
token="runtime",
|
||||||
|
opener=opener,
|
||||||
|
)
|
||||||
|
assert git_called is False
|
||||||
|
assert request_built is False
|
||||||
|
assert opener_called is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("field", "oversized"),
|
||||||
|
[("repo", "r" * 101), ("title", "🧪" * 200), ("body", "🧪" * 9_000)],
|
||||||
|
)
|
||||||
|
def test_other_text_bounds_fail_before_git_request_or_opener(
|
||||||
|
field: str, oversized: str, monkeypatch
|
||||||
|
):
|
||||||
|
client = _load()
|
||||||
|
git_called = False
|
||||||
|
request_built = False
|
||||||
|
opener_called = False
|
||||||
|
original_build_request = client.build_request
|
||||||
|
|
||||||
|
def git_run(*_args, **_kwargs):
|
||||||
|
nonlocal git_called
|
||||||
|
git_called = True
|
||||||
|
raise AssertionError("Git must not run before cheap input bounds")
|
||||||
|
|
||||||
|
def build_request(*args, **kwargs):
|
||||||
|
nonlocal request_built
|
||||||
|
request_built = True
|
||||||
|
return original_build_request(*args, **kwargs)
|
||||||
|
|
||||||
|
def opener(*_args, **_kwargs):
|
||||||
|
nonlocal opener_called
|
||||||
|
opener_called = True
|
||||||
|
return Response(_draft_response())
|
||||||
|
|
||||||
|
monkeypatch.setattr(client._validate_ref.__globals__["subprocess"], "run", git_run)
|
||||||
|
client.build_request = build_request
|
||||||
|
values = {
|
||||||
|
"repo": "cassandra",
|
||||||
|
"title": "Focused fix",
|
||||||
|
"body": "Review evidence",
|
||||||
|
}
|
||||||
|
values[field] = oversized
|
||||||
|
with pytest.raises(client.PolicyError):
|
||||||
|
client.create_draft(
|
||||||
|
values["repo"],
|
||||||
|
base="main",
|
||||||
|
head="hermes/fix",
|
||||||
|
head_sha=HEAD_SHA,
|
||||||
|
title=values["title"],
|
||||||
|
body=values["body"],
|
||||||
|
token="runtime",
|
||||||
|
opener=opener,
|
||||||
|
)
|
||||||
|
assert git_called is False
|
||||||
|
assert request_built is False
|
||||||
|
assert opener_called is False
|
||||||
|
|
||||||
|
|
||||||
def test_create_forces_draft_title_and_same_repository_branch_names():
|
def test_create_forces_draft_title_and_same_repository_branch_names():
|
||||||
client = _load()
|
client = _load()
|
||||||
assert (
|
assert (
|
||||||
@ -272,24 +550,289 @@ def test_runtime_token_is_only_an_authorization_header():
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"body",
|
"sensitive",
|
||||||
[
|
[
|
||||||
"pass" + "word=not-a-real-credential",
|
"client_" + "secret=not-a-real-value",
|
||||||
|
'{"client_' + 'secret":\n"synthetic-value"}',
|
||||||
|
'{"client_' + 'secret"\n:\n"synthetic-value"}',
|
||||||
|
'{"client\\u005f' + 'secret":"synthetic-value"}',
|
||||||
|
"client_" + "secret:\n synthetic-value",
|
||||||
|
'{"client\\q' + 'secret":"synthetic-value"}',
|
||||||
|
'{"client\n' + 'secret":"synthetic-value"}',
|
||||||
|
'"client_' + 'secret"\x0b:\n"synthetic-value"',
|
||||||
|
"ACCESS_" + "TOKEN = 'not-a-real-value'",
|
||||||
|
'{"refresh_' + 'token": "not-a-real-value"}',
|
||||||
|
"private_" + "key: not-a-real-value",
|
||||||
|
"AWS_SECRET_ACCESS_" + "KEY=not-a-real-value",
|
||||||
|
"aws_access_key_" + "id: not-a-real-value",
|
||||||
|
"AWS_SESSION_" + "TOKEN = 'not-a-real-value'",
|
||||||
|
'{"AccessKey' + 'Id":"not-a-real-value"}',
|
||||||
|
'{"SecretAccess' + 'Key":"not-a-real-value"}',
|
||||||
|
'{"Session' + 'Token":"not-a-real-value"}',
|
||||||
|
"aws-security-" + "token: not-a-real-value",
|
||||||
|
"Account" + "Key=not-a-real-value",
|
||||||
|
"SharedAccess" + "Signature: not-a-real-value",
|
||||||
|
"AZURE_STORAGE_CONNECTION_" + "STRING='not-a-real-value'",
|
||||||
|
"DefaultEndpointsProtocol=https;AccountName=fake;Account"
|
||||||
|
+ "Key=not-a-real-value;EndpointSuffix=example",
|
||||||
|
"?sv=2024-11-04&ss=b&srt=sco&sp=rwdlac&se=2099-01-01&sig=" + "not-a-real-value",
|
||||||
|
"DOCKER_AUTH_" + "CONFIG='not-a-real-value'",
|
||||||
|
'{"auths":{"registry.example":{"auth":"bm90LXJlYWw="}}}',
|
||||||
|
'{"identity' + 'token":"not-a-real-value"}',
|
||||||
|
'{"type":"service_' + 'account","client_email":"fake@example.test"}',
|
||||||
|
'{"private_key_' + 'id":"not-a-real-value"}',
|
||||||
|
'{"client_' + 'email":"fake@example.test"}',
|
||||||
|
"GOOGLE_CREDENTIALS" + "=not-a-real-value",
|
||||||
|
"personal_access_" + "token: not-a-real-value",
|
||||||
|
"GITEA_" + "TOKEN=not-a-real-value",
|
||||||
|
"gitlab-token" + ": not-a-real-value",
|
||||||
|
"pat" + "=not-a-real-value",
|
||||||
"Authorization: " + "Bearer not-a-real-credential-value",
|
"Authorization: " + "Bearer not-a-real-credential-value",
|
||||||
"token: " + "ghp_" + "notarealcredentialvalue123456",
|
"authorization = " + '"Basic not-a-real-credential-value"',
|
||||||
|
"Bearer" + "=not-a-real-credential-value",
|
||||||
|
"Basic" + ": not-a-real-credential-value",
|
||||||
|
"pass" + "word=not-a-real-credential",
|
||||||
|
"ghp_" + "notarealcredentialvalue123456",
|
||||||
|
"github_pat_" + "notarealcredentialvalue123456",
|
||||||
|
"glpat-" + "notarealcredentialvalue123456",
|
||||||
|
"xoxb-" + "not-a-real-credential-value-123456",
|
||||||
|
"sk-ant-" + "notarealcredentialvalue123456",
|
||||||
|
"sk-proj-" + "notarealcredentialvalue123456",
|
||||||
|
"sk_live_" + "notarealcredentialvalue123456",
|
||||||
|
"ya29." + "notarealcredentialvalue123456",
|
||||||
|
"gta_" + "notarealcredentialvalue123456",
|
||||||
|
"whsec_" + "notarealcredentialvalue123456",
|
||||||
|
"npm_" + "notarealcredentialvalue123456",
|
||||||
|
"pypi-" + "notarealcredentialvalue123456789012345",
|
||||||
|
"hf_" + "notarealcredentialvalue123456",
|
||||||
|
"SG." + "notarealvalue1234" + ".notarealcredentialvalue123456",
|
||||||
|
"SK" + "a" * 32,
|
||||||
|
"https://hooks.slack.com/services/" + "T000/B000/notarealvalue123456",
|
||||||
|
"https://discord.com/api/webhooks/123456789/" + "notarealcredentialvalue123456",
|
||||||
|
"https://fake.webhook.office.com/" + "notarealcredentialvalue123456",
|
||||||
|
"webhook_" + "url=https://example.test/not-real",
|
||||||
|
"FutureCloudSigning" + "Credential=not-a-real-value",
|
||||||
|
"future-client-signing-" + "key: not-a-real-value",
|
||||||
|
"future_client_signing_" + "key='not-a-real-value'",
|
||||||
|
'{"serviceAccountPrivate' + 'Key":"not-a-real-value"}',
|
||||||
|
"CONTAINER_REGISTRY_" + "CREDENTIAL=not-a-real-value",
|
||||||
|
"someWebhookSigning" + "Secret: not-a-real-value",
|
||||||
|
"client" + "Key=not-a-real-value",
|
||||||
|
"session" + "Key: not-a-real-value",
|
||||||
|
"access" + "Id=not-a-real-value",
|
||||||
|
"credentials" + ": {user: fake}",
|
||||||
|
"private" + "Key: |",
|
||||||
|
"nuget_api_" + "key=not-a-real-value",
|
||||||
|
"oy2" + "a" * 44,
|
||||||
|
"sk_test_" + "notarealcredentialvalue123456",
|
||||||
|
"A1b2C3d4E5f6G7h8I9j0K_l-M+n/O=pQ2rS3tU4vW5xY6zZ7aB8cC9d",
|
||||||
|
"AKIA" + "A" * 16,
|
||||||
|
"AIza" + "a" * 35,
|
||||||
"-----BEGIN OPENSSH " + "PRIVATE KEY-----",
|
"-----BEGIN OPENSSH " + "PRIVATE KEY-----",
|
||||||
|
"ssh-ed25519 " + "bm90YXJlYWxjcmVkZW50aWFsdmFsdWU=",
|
||||||
"eyJnotarealheader." + "notarealpayloadvalue." + "notarealsignature",
|
"eyJnotarealheader." + "notarealpayloadvalue." + "notarealsignature",
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_body_rejects_common_credential_shapes(body: str):
|
@pytest.mark.parametrize("field", ["title", "body"])
|
||||||
|
def test_create_rejects_expanded_credential_shapes_before_network(
|
||||||
|
sensitive: str, field: str
|
||||||
|
):
|
||||||
client = _load()
|
client = _load()
|
||||||
|
called = False
|
||||||
|
request_built = False
|
||||||
|
|
||||||
|
original_build_request = client.build_request
|
||||||
|
|
||||||
|
def build_request(*args, **kwargs):
|
||||||
|
nonlocal request_built
|
||||||
|
request_built = True
|
||||||
|
return original_build_request(*args, **kwargs)
|
||||||
|
|
||||||
|
def opener(*_args, **_kwargs):
|
||||||
|
nonlocal called
|
||||||
|
called = True
|
||||||
|
return Response(_draft_response())
|
||||||
|
|
||||||
|
client.build_request = build_request
|
||||||
|
values = {"title": "Focused fix", "body": "Review evidence"}
|
||||||
|
values[field] = sensitive
|
||||||
|
|
||||||
with pytest.raises(client.PolicyError, match="credential material"):
|
with pytest.raises(client.PolicyError, match="credential material"):
|
||||||
client._validate_body(body)
|
client.create_draft(
|
||||||
|
"cassandra",
|
||||||
|
base="main",
|
||||||
|
head="hermes/fix",
|
||||||
|
head_sha=HEAD_SHA,
|
||||||
|
title=values["title"],
|
||||||
|
body=values["body"],
|
||||||
|
token="runtime-sentinel",
|
||||||
|
opener=opener,
|
||||||
|
)
|
||||||
|
assert request_built is False
|
||||||
|
assert called is False
|
||||||
|
|
||||||
|
|
||||||
def test_create_rejects_exact_runtime_token_before_network():
|
def test_very_long_compact_body_is_rejected_before_request_or_network():
|
||||||
client = _load()
|
client = _load()
|
||||||
|
request_built = False
|
||||||
|
opener_called = False
|
||||||
|
original_build_request = client.build_request
|
||||||
|
|
||||||
|
def build_request(*args, **kwargs):
|
||||||
|
nonlocal request_built
|
||||||
|
request_built = True
|
||||||
|
return original_build_request(*args, **kwargs)
|
||||||
|
|
||||||
|
def opener(*_args, **_kwargs):
|
||||||
|
nonlocal opener_called
|
||||||
|
opener_called = True
|
||||||
|
return Response(_draft_response())
|
||||||
|
|
||||||
|
client.build_request = build_request
|
||||||
|
with pytest.raises(client.PolicyError, match="credential material"):
|
||||||
|
client.create_draft(
|
||||||
|
"cassandra",
|
||||||
|
base="main",
|
||||||
|
head="hermes/fix",
|
||||||
|
head_sha=HEAD_SHA,
|
||||||
|
title="Focused fix",
|
||||||
|
body="a" * 300,
|
||||||
|
token="runtime-sentinel",
|
||||||
|
opener=opener,
|
||||||
|
)
|
||||||
|
assert request_built is False
|
||||||
|
assert opener_called is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"safe_text",
|
||||||
|
[
|
||||||
|
"AWS_SECRET_ACCESS_KEY is injected at runtime",
|
||||||
|
"Token: reject empty values",
|
||||||
|
"Authorization = preserve header behavior",
|
||||||
|
"Password: add regression",
|
||||||
|
"Review the Authorization header behavior",
|
||||||
|
"Bearer authentication is required for this route",
|
||||||
|
"Document Docker auths payload rejection",
|
||||||
|
"The client_email field belongs to service accounts",
|
||||||
|
"AccountKey assignments must be blocked",
|
||||||
|
"This patch changes token validation without including a value",
|
||||||
|
"FutureCloudSigningCredential handling needs a regression test",
|
||||||
|
"The clientKey name is documented without an assigned value",
|
||||||
|
"A SHA-256 digest 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef is evidence",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_credential_policy_keeps_normal_engineering_prose_usable(safe_text: str):
|
||||||
|
client = _load()
|
||||||
|
|
||||||
|
assert client._validate_body(safe_text) == safe_text
|
||||||
|
assert client._draft_title(safe_text) == f"WIP: {safe_text}"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("field", ["title", "body"])
|
||||||
|
def test_create_rejects_exact_runtime_token_before_network(field: str):
|
||||||
|
client = _load()
|
||||||
|
called = False
|
||||||
|
request_built = False
|
||||||
|
|
||||||
|
original_build_request = client.build_request
|
||||||
|
|
||||||
|
def build_request(*args, **kwargs):
|
||||||
|
nonlocal request_built
|
||||||
|
request_built = True
|
||||||
|
return original_build_request(*args, **kwargs)
|
||||||
|
|
||||||
|
def opener(*_args, **_kwargs):
|
||||||
|
nonlocal called
|
||||||
|
called = True
|
||||||
|
return Response(_draft_response())
|
||||||
|
|
||||||
|
client.build_request = build_request
|
||||||
|
runtime_token = "exact-random-runtime-sentinel-7b73ac61"
|
||||||
|
values = {"title": "Focused fix", "body": "Review evidence"}
|
||||||
|
values[field] = f"Accidental {runtime_token} value"
|
||||||
|
with pytest.raises(client.PolicyError, match="runtime credential"):
|
||||||
|
client.create_draft(
|
||||||
|
"cassandra",
|
||||||
|
base="main",
|
||||||
|
head="hermes/fix",
|
||||||
|
head_sha=HEAD_SHA,
|
||||||
|
title=values["title"],
|
||||||
|
body=values["body"],
|
||||||
|
token=runtime_token,
|
||||||
|
opener=opener,
|
||||||
|
)
|
||||||
|
assert request_built is False
|
||||||
|
assert called is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("field", ["repo", "base", "head", "title", "body"])
|
||||||
|
def test_every_public_field_rejects_exact_runtime_token_before_git_or_network(
|
||||||
|
field: str, monkeypatch
|
||||||
|
):
|
||||||
|
client = _load()
|
||||||
|
git_called = False
|
||||||
|
request_built = False
|
||||||
|
opener_called = False
|
||||||
|
runtime_token = "runtime-sentinel"
|
||||||
|
values = {
|
||||||
|
"repo": "cassandra",
|
||||||
|
"base": "main",
|
||||||
|
"head": "hermes/fix",
|
||||||
|
"title": "Focused fix",
|
||||||
|
"body": "Review evidence",
|
||||||
|
}
|
||||||
|
values[field] = runtime_token
|
||||||
|
|
||||||
|
def git_run(*_args, **_kwargs):
|
||||||
|
nonlocal git_called
|
||||||
|
git_called = True
|
||||||
|
raise AssertionError("Git must not run for a credential-bearing field")
|
||||||
|
|
||||||
|
def build_request(*_args, **_kwargs):
|
||||||
|
nonlocal request_built
|
||||||
|
request_built = True
|
||||||
|
raise AssertionError("a request must not be built")
|
||||||
|
|
||||||
|
def opener(*_args, **_kwargs):
|
||||||
|
nonlocal opener_called
|
||||||
|
opener_called = True
|
||||||
|
raise AssertionError("the opener must not be called")
|
||||||
|
|
||||||
|
monkeypatch.setattr(client._validate_ref.__globals__["subprocess"], "run", git_run)
|
||||||
|
monkeypatch.setattr(client, "build_request", build_request)
|
||||||
|
with pytest.raises(client.PolicyError, match="runtime credential"):
|
||||||
|
client.create_draft(
|
||||||
|
values["repo"],
|
||||||
|
base=values["base"],
|
||||||
|
head=values["head"],
|
||||||
|
head_sha=HEAD_SHA,
|
||||||
|
title=values["title"],
|
||||||
|
body=values["body"],
|
||||||
|
token=runtime_token,
|
||||||
|
opener=opener,
|
||||||
|
)
|
||||||
|
assert git_called is False
|
||||||
|
assert request_built is False
|
||||||
|
assert opener_called is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"sensitive",
|
||||||
|
[
|
||||||
|
" ".join(["{}"] * 32) + ' {"client_secret":"synthetic-value"}',
|
||||||
|
"prefix_ghp_notarealcredentialvalue123456_suffix",
|
||||||
|
"client_secret: correct horse battery staple",
|
||||||
|
"'client_secret':\n synthetic-value",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
@pytest.mark.parametrize("field", ["title", "body"])
|
||||||
|
def test_structured_scanner_closes_bounded_and_wrapped_token_bypasses(
|
||||||
|
sensitive: str, field: str
|
||||||
|
):
|
||||||
|
client = _load()
|
||||||
|
values = {"title": "Focused fix", "body": "Review evidence"}
|
||||||
|
values[field] = sensitive
|
||||||
called = False
|
called = False
|
||||||
|
|
||||||
def opener(*_args, **_kwargs):
|
def opener(*_args, **_kwargs):
|
||||||
@ -297,18 +840,73 @@ def test_create_rejects_exact_runtime_token_before_network():
|
|||||||
called = True
|
called = True
|
||||||
return Response(_draft_response())
|
return Response(_draft_response())
|
||||||
|
|
||||||
with pytest.raises(client.PolicyError, match="runtime credential"):
|
with pytest.raises(client.PolicyError, match="credential material"):
|
||||||
|
client.create_draft(
|
||||||
|
"cassandra",
|
||||||
|
base="main",
|
||||||
|
head="hermes/fix",
|
||||||
|
head_sha=HEAD_SHA,
|
||||||
|
title=values["title"],
|
||||||
|
body=values["body"],
|
||||||
|
token="runtime-sentinel",
|
||||||
|
opener=opener,
|
||||||
|
)
|
||||||
|
assert called is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("status", [200, 202, 204, 206])
|
||||||
|
def test_create_accepts_only_http_201(status: int):
|
||||||
|
client = _load()
|
||||||
|
|
||||||
|
with pytest.raises(client.PolicyError, match="unexpected HTTP status"):
|
||||||
client.create_draft(
|
client.create_draft(
|
||||||
"cassandra",
|
"cassandra",
|
||||||
base="main",
|
base="main",
|
||||||
head="hermes/fix",
|
head="hermes/fix",
|
||||||
head_sha=HEAD_SHA,
|
head_sha=HEAD_SHA,
|
||||||
title="Focused fix",
|
title="Focused fix",
|
||||||
body="accidental runtime-sentinel value",
|
body="Review evidence",
|
||||||
token="runtime-sentinel",
|
token="runtime",
|
||||||
opener=opener,
|
opener=lambda *_a, **_k: Response(_draft_response(), status=status),
|
||||||
)
|
)
|
||||||
assert called is False
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("status", [201, 202, 204, 206])
|
||||||
|
def test_read_accepts_only_http_200(status: int):
|
||||||
|
client = _load()
|
||||||
|
|
||||||
|
with pytest.raises(client.PolicyError, match="unexpected HTTP status"):
|
||||||
|
client.read(
|
||||||
|
"/api/v1/repos/atlas/cassandra",
|
||||||
|
token="runtime",
|
||||||
|
opener=lambda *_a, **_k: Response(b"{}", status=status),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_successful_create_validates_each_ref_once(monkeypatch):
|
||||||
|
client = _load()
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def git_run(command, **_kwargs):
|
||||||
|
calls.append(command)
|
||||||
|
return type("Result", (), {"returncode": 0})()
|
||||||
|
|
||||||
|
monkeypatch.setattr(client._validate_ref.__globals__["subprocess"], "run", git_run)
|
||||||
|
client.create_draft(
|
||||||
|
"cassandra",
|
||||||
|
base="main",
|
||||||
|
head="hermes/fix",
|
||||||
|
head_sha=HEAD_SHA,
|
||||||
|
title="Focused fix",
|
||||||
|
body="Review evidence",
|
||||||
|
token="runtime",
|
||||||
|
opener=lambda *_a, **_k: Response(_draft_response()),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert [command[-1] for command in calls] == [
|
||||||
|
"refs/heads/main",
|
||||||
|
"refs/heads/hermes/fix",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_create_verifies_every_server_postcondition():
|
def test_create_verifies_every_server_postcondition():
|
||||||
@ -345,6 +943,7 @@ def test_create_postcondition_rejects_every_material_mismatch():
|
|||||||
client = _load()
|
client = _load()
|
||||||
mutations = [
|
mutations = [
|
||||||
("number", 0),
|
("number", 0),
|
||||||
|
("number", 2_147_483_648),
|
||||||
("state", "closed"),
|
("state", "closed"),
|
||||||
("draft", False),
|
("draft", False),
|
||||||
("merged", True),
|
("merged", True),
|
||||||
@ -396,6 +995,19 @@ def test_read_response_is_bounded():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_direct_api_rejects_unexpected_success_content_type():
|
||||||
|
client = _load()
|
||||||
|
response = Response(b"{}")
|
||||||
|
response.headers.replace_header("Content-Type", "text/html")
|
||||||
|
|
||||||
|
with pytest.raises(client.PolicyError, match="unexpected response type"):
|
||||||
|
client.read(
|
||||||
|
"/api/v1/repos/atlas/cassandra",
|
||||||
|
token="runtime",
|
||||||
|
opener=lambda *_a, **_k: response,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_output_redaction_covers_exact_token_and_authorization_header():
|
def test_output_redaction_covers_exact_token_and_authorization_header():
|
||||||
client = _load()
|
client = _load()
|
||||||
raw = b'{"message":"do-not-leak","debug":"Authorization: token do-not-leak"}'
|
raw = b'{"message":"do-not-leak","debug":"Authorization: token do-not-leak"}'
|
||||||
@ -403,3 +1015,24 @@ def test_output_redaction_covers_exact_token_and_authorization_header():
|
|||||||
|
|
||||||
assert b"do-not-leak" not in redacted
|
assert b"do-not-leak" not in redacted
|
||||||
assert redacted.count(b"[REDACTED]") >= 1
|
assert redacted.count(b"[REDACTED]") >= 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"reflected",
|
||||||
|
[
|
||||||
|
b"runtime-sentinel",
|
||||||
|
base64.b64encode(b"runtime-sentinel"),
|
||||||
|
base64.b64encode(b"hermes-automation:runtime-sentinel"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_direct_api_rejects_credential_reflection(reflected: bytes):
|
||||||
|
client = _load()
|
||||||
|
|
||||||
|
with pytest.raises(client.PolicyError, match="credential material"):
|
||||||
|
client.read(
|
||||||
|
"/api/v1/repos/atlas/cassandra",
|
||||||
|
token="runtime-sentinel",
|
||||||
|
opener=lambda *_a, **_k: Response(
|
||||||
|
b'{"unexpected":"' + reflected + b'"}'
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|||||||
@ -13,8 +13,10 @@ import pytest
|
|||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
ROOT = Path(__file__).parents[2]
|
ROOT = Path(__file__).parents[2]
|
||||||
CLIENT_PATH = ROOT / "services/hermes/scripts/gitea_api.py"
|
CLIENT_PATH = ROOT / "services/hermes/scm-common/scripts/gitea_api.py"
|
||||||
HEAD_SHA = "465cf9146b05c174a2a8d310aff6c64be58277b6"
|
HEAD_SHA = "465cf9146b05c174a2a8d310aff6c64be58277b6"
|
||||||
|
if str(CLIENT_PATH.parent) not in sys.path:
|
||||||
|
sys.path.insert(0, str(CLIENT_PATH.parent))
|
||||||
|
|
||||||
|
|
||||||
def _load():
|
def _load():
|
||||||
@ -96,12 +98,12 @@ def test_http_error_path_redacts_token(monkeypatch, capsys):
|
|||||||
assert client.main(["read", "/api/v1/repos/atlas/cassandra"]) == 1
|
assert client.main(["read", "/api/v1/repos/atlas/cassandra"]) == 1
|
||||||
captured = capsys.readouterr()
|
captured = capsys.readouterr()
|
||||||
assert "do-not-leak" not in captured.err
|
assert "do-not-leak" not in captured.err
|
||||||
assert "HTTP 403" in captured.err
|
assert "credential was disclosed" in captured.err
|
||||||
|
|
||||||
|
|
||||||
def test_flux_manifest_projects_runtime_vault_token_and_skill_only():
|
def test_flux_manifest_isolates_vault_token_in_separate_broker_only():
|
||||||
client_source = CLIENT_PATH.read_text(encoding="utf-8")
|
client_source = CLIENT_PATH.read_text(encoding="utf-8")
|
||||||
assert "/runtime-access/gitea-token" in client_source
|
assert "scm_broker_client" in client_source
|
||||||
assert "GITEA_TOKEN" not in client_source
|
assert "GITEA_TOKEN" not in client_source
|
||||||
|
|
||||||
deployment = yaml.safe_load(
|
deployment = yaml.safe_load(
|
||||||
@ -109,9 +111,7 @@ def test_flux_manifest_projects_runtime_vault_token_and_skill_only():
|
|||||||
)
|
)
|
||||||
template = deployment["spec"]["template"]
|
template = deployment["spec"]["template"]
|
||||||
annotations = template["metadata"]["annotations"]
|
annotations = template["metadata"]["annotations"]
|
||||||
assert annotations["vault.hashicorp.com/agent-inject-secret-gitea-token"] == (
|
assert not any("gitea" in key.lower() for key in annotations)
|
||||||
"kv/data/atlas/hermes/developer-gitea"
|
|
||||||
)
|
|
||||||
runtime = next(
|
runtime = next(
|
||||||
volume
|
volume
|
||||||
for volume in template["spec"]["volumes"]
|
for volume in template["spec"]["volumes"]
|
||||||
@ -136,6 +136,17 @@ def test_flux_manifest_projects_runtime_vault_token_and_skill_only():
|
|||||||
kustomization = yaml.safe_load(
|
kustomization = yaml.safe_load(
|
||||||
(ROOT / "services/hermes/kustomization.yaml").read_text(encoding="utf-8")
|
(ROOT / "services/hermes/kustomization.yaml").read_text(encoding="utf-8")
|
||||||
)
|
)
|
||||||
|
common = yaml.safe_load(
|
||||||
|
(ROOT / "services/hermes/scm-common/kustomization.yaml").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
boundary = common["configMapGenerator"][0]
|
||||||
|
assert boundary["name"] == "hermes-scm-boundary"
|
||||||
|
assert "gitea_api.py=scripts/gitea_api.py" in boundary["files"]
|
||||||
|
assert "gitea_api_policy.py=scripts/gitea_api_policy.py" in boundary["files"]
|
||||||
|
assert "scm_broker_client.py=scripts/scm_broker_client.py" in boundary["files"]
|
||||||
|
assert not any("gitea_askpass" in item for item in boundary["files"])
|
||||||
generator = next(
|
generator = next(
|
||||||
item
|
item
|
||||||
for item in kustomization["configMapGenerator"]
|
for item in kustomization["configMapGenerator"]
|
||||||
@ -145,3 +156,19 @@ def test_flux_manifest_projects_runtime_vault_token_and_skill_only():
|
|||||||
"SKILL.md=skills/manage-atlas-pull-requests/SKILL.md",
|
"SKILL.md=skills/manage-atlas-pull-requests/SKILL.md",
|
||||||
"openai.yaml=skills/manage-atlas-pull-requests/agents/openai.yaml",
|
"openai.yaml=skills/manage-atlas-pull-requests/agents/openai.yaml",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
broker = yaml.safe_load(
|
||||||
|
(ROOT / "services/hermes-scm-broker/deployment.yaml").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert broker["metadata"]["namespace"] == "hermes-scm"
|
||||||
|
pod = broker["spec"]["template"]
|
||||||
|
assert pod["spec"]["serviceAccountName"] == "hermes-scm-broker"
|
||||||
|
assert pod["metadata"]["annotations"][
|
||||||
|
"vault.hashicorp.com/agent-inject-secret-gitea-token"
|
||||||
|
] == "kv/data/atlas/hermes/developer-gitea"
|
||||||
|
assert not any(
|
||||||
|
volume.get("hostPath") or volume.get("persistentVolumeClaim")
|
||||||
|
for volume in pod["spec"]["volumes"]
|
||||||
|
)
|
||||||
|
|||||||
305
testing/tests/test_hermes_node_account_hardening.py
Normal file
305
testing/tests/test_hermes_node_account_hardening.py
Normal file
@ -0,0 +1,305 @@
|
|||||||
|
"""Contracts for the dedicated, unprivileged Hermes node SSH identity."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import importlib.util
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
ROOT = Path(__file__).parents[2]
|
||||||
|
SCRIPT = ROOT / "services/hermes/scripts/node_account_hardening.py"
|
||||||
|
|
||||||
|
|
||||||
|
def _load():
|
||||||
|
spec = importlib.util.spec_from_file_location("node_account_hardening_test", SCRIPT)
|
||||||
|
assert spec and spec.loader
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
sys.modules[spec.name] = module
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
def _fixture(tmp_path: Path, monkeypatch):
|
||||||
|
module = _load()
|
||||||
|
host_etc = tmp_path / "etc"
|
||||||
|
host_home = tmp_path / "home"
|
||||||
|
host_etc.mkdir()
|
||||||
|
host_home.mkdir()
|
||||||
|
originals = {
|
||||||
|
"passwd": (
|
||||||
|
"root:x:0:0:root:/root:/bin/bash\n"
|
||||||
|
"atlas:x:2000:2000:Atlas:/home/atlas:/bin/bash\n"
|
||||||
|
"oceanus:x:2001:2001:Oceanus:/home/oceanus:/bin/bash\n"
|
||||||
|
),
|
||||||
|
"group": (
|
||||||
|
"root:x:0:\n"
|
||||||
|
"atlas:x:2000:\n"
|
||||||
|
"oceanus:x:2001:\n"
|
||||||
|
"disk:x:6:atlas\n"
|
||||||
|
"sudo:x:27:oceanus\n"
|
||||||
|
),
|
||||||
|
"shadow": (
|
||||||
|
"root:!:1:0:99999:7:::\n"
|
||||||
|
"atlas:!:1:0:99999:7:::\n"
|
||||||
|
"oceanus:!:1:0:99999:7:::\n"
|
||||||
|
),
|
||||||
|
"gshadow": (
|
||||||
|
"root:!::\n"
|
||||||
|
"atlas:!::\n"
|
||||||
|
"oceanus:!::\n"
|
||||||
|
"disk:!::atlas\n"
|
||||||
|
"sudo:!::oceanus\n"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
for name, value in originals.items():
|
||||||
|
(host_etc / name).write_text(value, encoding="utf-8")
|
||||||
|
key = "ssh-ed25519 " + base64.b64encode(b"synthetic-hermes-key").decode()
|
||||||
|
other = "ssh-ed25519 " + base64.b64encode(b"human-operator-key").decode()
|
||||||
|
for user in ("atlas", "oceanus"):
|
||||||
|
ssh = host_home / user / ".ssh"
|
||||||
|
ssh.mkdir(parents=True)
|
||||||
|
(ssh / "authorized_keys").write_text(
|
||||||
|
f"{other} {user}\n{key}\n", encoding="utf-8"
|
||||||
|
)
|
||||||
|
public_key = tmp_path / "public-key"
|
||||||
|
public_key.write_text(key + "\n", encoding="utf-8")
|
||||||
|
monkeypatch.setattr(module, "HOST_ETC", host_etc)
|
||||||
|
monkeypatch.setattr(module, "HOST_HOME", host_home)
|
||||||
|
monkeypatch.setattr(module, "ACCOUNT_UID", os.getuid())
|
||||||
|
monkeypatch.setattr(module, "ACCOUNT_GID", os.getgid())
|
||||||
|
# ACL behavior has its own executable test below. These account-database
|
||||||
|
# tests must not depend on the host running pytest with CAP_FOWNER.
|
||||||
|
monkeypatch.setattr(module, "_deny_sensitive_roots", lambda: None)
|
||||||
|
return module, originals, key, other, public_key
|
||||||
|
|
||||||
|
|
||||||
|
def test_reconciler_creates_locked_groupless_account_and_moves_only_exact_key(
|
||||||
|
tmp_path: Path, monkeypatch
|
||||||
|
):
|
||||||
|
module, originals, key, other, public_key = _fixture(tmp_path, monkeypatch)
|
||||||
|
|
||||||
|
module.reconcile(public_key)
|
||||||
|
first = {
|
||||||
|
name: (module.HOST_ETC / name).read_text(encoding="utf-8")
|
||||||
|
for name in originals
|
||||||
|
}
|
||||||
|
module.reconcile(public_key)
|
||||||
|
second = {
|
||||||
|
name: (module.HOST_ETC / name).read_text(encoding="utf-8")
|
||||||
|
for name in originals
|
||||||
|
}
|
||||||
|
|
||||||
|
assert first == second
|
||||||
|
expected = module._expected_records()
|
||||||
|
for name, original in originals.items():
|
||||||
|
assert first[name] == original + ":".join(expected[name]) + "\n"
|
||||||
|
backup = module.HOST_ETC / f"{name}.hermes-boundary-backup"
|
||||||
|
assert backup.read_text(encoding="utf-8") == original
|
||||||
|
assert expected["shadow"][1] == "!"
|
||||||
|
assert expected["group"][-1] == ""
|
||||||
|
assert "disk:x:6:atlas\n" in first["group"]
|
||||||
|
assert "sudo:x:27:oceanus\n" in first["group"]
|
||||||
|
|
||||||
|
hermes_keys = (
|
||||||
|
module.HOST_HOME / module.ACCOUNT / ".ssh/authorized_keys"
|
||||||
|
).read_text(encoding="utf-8")
|
||||||
|
assert hermes_keys == key + "\n"
|
||||||
|
for user in module.LEGACY_ACCOUNTS:
|
||||||
|
legacy = (
|
||||||
|
module.HOST_HOME / user / ".ssh/authorized_keys"
|
||||||
|
).read_text(encoding="utf-8")
|
||||||
|
assert legacy == f"{other} {user}\n"
|
||||||
|
backup = module.HOST_HOME / user / ".ssh/authorized_keys.hermes-boundary-backup"
|
||||||
|
assert backup.read_text(encoding="utf-8") == f"{other} {user}\n{key}\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_identity_conflict_fails_closed_without_editing_account_databases(
|
||||||
|
tmp_path: Path, monkeypatch
|
||||||
|
):
|
||||||
|
module, originals, _key, _other, public_key = _fixture(tmp_path, monkeypatch)
|
||||||
|
conflict = originals["passwd"] + (
|
||||||
|
f"unrelated:x:{module.ACCOUNT_UID}:{module.ACCOUNT_GID}:Other:/home/other:/bin/bash\n"
|
||||||
|
)
|
||||||
|
(module.HOST_ETC / "passwd").write_text(conflict, encoding="utf-8")
|
||||||
|
|
||||||
|
with pytest.raises(module.HardeningError, match="identity conflicts"):
|
||||||
|
module.reconcile(public_key)
|
||||||
|
|
||||||
|
assert (module.HOST_ETC / "passwd").read_text(encoding="utf-8") == conflict
|
||||||
|
for name in ("group", "shadow", "gshadow"):
|
||||||
|
assert (module.HOST_ETC / name).read_text(encoding="utf-8") == originals[name]
|
||||||
|
assert not (module.HOST_HOME / module.ACCOUNT).exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_malformed_account_file_fails_closed(tmp_path: Path, monkeypatch):
|
||||||
|
module, originals, _key, _other, public_key = _fixture(tmp_path, monkeypatch)
|
||||||
|
(module.HOST_ETC / "group").write_text("malformed\n", encoding="utf-8")
|
||||||
|
|
||||||
|
with pytest.raises(module.HardeningError, match="invalid record"):
|
||||||
|
module.reconcile(public_key)
|
||||||
|
|
||||||
|
assert (module.HOST_ETC / "passwd").read_text(encoding="utf-8") == originals[
|
||||||
|
"passwd"
|
||||||
|
]
|
||||||
|
assert (module.HOST_ETC / "group").read_text(encoding="utf-8") == "malformed\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_preexisting_home_fails_before_account_database_or_key_changes(
|
||||||
|
tmp_path: Path, monkeypatch
|
||||||
|
):
|
||||||
|
module, originals, _key, _other, public_key = _fixture(tmp_path, monkeypatch)
|
||||||
|
home = module.HOST_HOME / module.ACCOUNT
|
||||||
|
home.mkdir()
|
||||||
|
(home / "unrelated").write_text("preserve\n", encoding="utf-8")
|
||||||
|
|
||||||
|
with pytest.raises(module.HardeningError, match="home already exists"):
|
||||||
|
module.reconcile(public_key)
|
||||||
|
|
||||||
|
for name, original in originals.items():
|
||||||
|
assert (module.HOST_ETC / name).read_text(encoding="utf-8") == original
|
||||||
|
assert not (module.HOST_ETC / f"{name}.hermes-boundary-backup").exists()
|
||||||
|
assert (home / "unrelated").read_text(encoding="utf-8") == "preserve\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_flux_daemonset_reconciles_every_node_without_mutating_human_groups():
|
||||||
|
documents = list(
|
||||||
|
yaml.safe_load_all(
|
||||||
|
(ROOT / "services/hermes/node-ssh-access.yaml").read_text(encoding="utf-8")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
daemonset = next(item for item in documents if item["kind"] == "DaemonSet")
|
||||||
|
spec = daemonset["spec"]["template"]["spec"]
|
||||||
|
assert spec["tolerations"] == [{"operator": "Exists"}]
|
||||||
|
command = spec["containers"][0]["args"][0]
|
||||||
|
assert "/opt/node-hardener/node_account_hardening.py" in command
|
||||||
|
assert "sleep 300" in command
|
||||||
|
mounts = {item["name"]: item for item in spec["volumes"]}
|
||||||
|
assert mounts["host-home"]["hostPath"]["path"] == "/home"
|
||||||
|
assert mounts["host-etc"]["hostPath"]["path"] == "/etc"
|
||||||
|
assert mounts["host-k3s"]["hostPath"]["path"] == "/var/lib/rancher/k3s"
|
||||||
|
assert mounts["host-kubelet"]["hostPath"]["path"] == "/var/lib/kubelet"
|
||||||
|
assert mounts["host-run-k3s"]["hostPath"]["path"] == "/run/k3s"
|
||||||
|
assert mounts["host-run-containerd"]["hostPath"]["path"] == "/run/containerd"
|
||||||
|
assert "usermod" not in command
|
||||||
|
assert "groupmod" not in command
|
||||||
|
|
||||||
|
policies = list(
|
||||||
|
yaml.safe_load_all(
|
||||||
|
(ROOT / "services/hermes/networkpolicy.yaml").read_text(encoding="utf-8")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
isolation = next(
|
||||||
|
item
|
||||||
|
for item in policies
|
||||||
|
if item["metadata"]["name"] == "hermes-node-ssh-access-isolation"
|
||||||
|
)
|
||||||
|
assert isolation["spec"]["ingress"] == []
|
||||||
|
assert isolation["spec"]["egress"] == []
|
||||||
|
|
||||||
|
source = SCRIPT.read_text(encoding="utf-8")
|
||||||
|
assert "ACCOUNT = \"hermes-agent\"" in source
|
||||||
|
assert "ACCOUNT_UID = 1200" in source
|
||||||
|
assert "ACCOUNT_GID = 1200" in source
|
||||||
|
assert 'LEGACY_ACCOUNTS = ("atlas", "oceanus")' in source
|
||||||
|
assert "supplementary" not in source.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_sensitive_roots_get_explicit_zero_permission_acl_for_hermes(
|
||||||
|
tmp_path: Path, monkeypatch
|
||||||
|
):
|
||||||
|
module = _load()
|
||||||
|
sensitive = tmp_path / "k3s"
|
||||||
|
sensitive.mkdir(mode=0o755)
|
||||||
|
host_etc = tmp_path / "etc"
|
||||||
|
host_etc.mkdir()
|
||||||
|
monkeypatch.setattr(module, "ACCOUNT_UID", 1200)
|
||||||
|
monkeypatch.setattr(module, "HOST_ETC", host_etc)
|
||||||
|
monkeypatch.setattr(module, "HOST_ROOT_UID", os.getuid())
|
||||||
|
monkeypatch.setattr(module, "HOST_ROOT_GID", os.getgid())
|
||||||
|
# Production requires uid 0. The test process owns its fixture but runs the
|
||||||
|
# real xattr/ACL implementation using the test owner's uid as that boundary.
|
||||||
|
|
||||||
|
module._deny_sensitive_root(sensitive)
|
||||||
|
first = module._read_acl(sensitive)
|
||||||
|
module._deny_sensitive_root(sensitive)
|
||||||
|
second = module._read_acl(sensitive)
|
||||||
|
|
||||||
|
assert first == second
|
||||||
|
entries = module._decode_acl(first, 0o755)
|
||||||
|
assert (module.ACL_USER, 0, 1200) in entries
|
||||||
|
assert (module.ACL_USER_OBJ, 0o7, module.ACL_UNDEFINED_ID) in entries
|
||||||
|
assert (module.ACL_GROUP_OBJ, 0o5, module.ACL_UNDEFINED_ID) in entries
|
||||||
|
assert (module.ACL_OTHER, 0o5, module.ACL_UNDEFINED_ID) in entries
|
||||||
|
backup = host_etc / "hermes-node-boundary/k3s.acl"
|
||||||
|
assert backup.read_bytes() == b"N"
|
||||||
|
assert backup.stat().st_mode & 0o777 == 0o600
|
||||||
|
|
||||||
|
|
||||||
|
def test_sensitive_root_rejects_non_directory_and_non_root_owner(
|
||||||
|
tmp_path: Path, monkeypatch
|
||||||
|
):
|
||||||
|
module = _load()
|
||||||
|
regular = tmp_path / "not-a-directory"
|
||||||
|
regular.write_text("preserve", encoding="utf-8")
|
||||||
|
with pytest.raises(module.HardeningError, match="unsafe sensitive directory"):
|
||||||
|
module._deny_sensitive_root(regular)
|
||||||
|
|
||||||
|
directory = tmp_path / "not-root-owned"
|
||||||
|
directory.mkdir()
|
||||||
|
if directory.stat().st_uid == 0:
|
||||||
|
pytest.skip("cannot construct a non-root-owned fixture as root")
|
||||||
|
with pytest.raises(module.HardeningError, match="not root-owned"):
|
||||||
|
module._deny_sensitive_root(directory)
|
||||||
|
|
||||||
|
|
||||||
|
def test_acl_parser_rejects_malformed_or_incomplete_values():
|
||||||
|
module = _load()
|
||||||
|
with pytest.raises(module.HardeningError, match="malformed"):
|
||||||
|
module._decode_acl(b"too short", 0o755)
|
||||||
|
incomplete = module._encode_acl(
|
||||||
|
[(module.ACL_USER_OBJ, 0o7, module.ACL_UNDEFINED_ID)]
|
||||||
|
)
|
||||||
|
with pytest.raises(module.HardeningError, match="incomplete"):
|
||||||
|
module._decode_acl(incomplete, 0o755)
|
||||||
|
|
||||||
|
|
||||||
|
def test_acl_failure_stops_before_installing_the_ssh_key(tmp_path: Path, monkeypatch):
|
||||||
|
module = _load()
|
||||||
|
public_key = tmp_path / "public-key"
|
||||||
|
public_key.write_text(
|
||||||
|
"ssh-ed25519 " + base64.b64encode(b"synthetic-key").decode() + "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
moved = False
|
||||||
|
monkeypatch.setattr(module, "_reconcile_databases", lambda: None)
|
||||||
|
|
||||||
|
def fail_acl():
|
||||||
|
raise module.HardeningError("ACL unavailable")
|
||||||
|
|
||||||
|
def move_key(_path):
|
||||||
|
nonlocal moved
|
||||||
|
moved = True
|
||||||
|
|
||||||
|
monkeypatch.setattr(module, "_deny_sensitive_roots", fail_acl)
|
||||||
|
monkeypatch.setattr(module, "_move_key", move_key)
|
||||||
|
|
||||||
|
with pytest.raises(module.HardeningError, match="ACL unavailable"):
|
||||||
|
module.reconcile(public_key)
|
||||||
|
assert moved is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_runtime_ssh_config_forces_dedicated_account_for_every_titan():
|
||||||
|
stage = (
|
||||||
|
ROOT / "services/hermes/scripts/stage_runtime_access.py"
|
||||||
|
).read_text(encoding="utf-8")
|
||||||
|
assert '"Host titan-*\\n User hermes-agent\\n"' in stage
|
||||||
|
agent = yaml.safe_load(
|
||||||
|
(ROOT / "services/hermes/agent-deployment.yaml").read_text(encoding="utf-8")
|
||||||
|
)
|
||||||
|
assert "User atlas" not in yaml.safe_dump(agent)
|
||||||
|
assert "User oceanus" not in yaml.safe_dump(agent)
|
||||||
@ -14,10 +14,16 @@ import yaml
|
|||||||
ROOT = Path(__file__).parents[2]
|
ROOT = Path(__file__).parents[2]
|
||||||
HERMES = ROOT / "services" / "hermes"
|
HERMES = ROOT / "services" / "hermes"
|
||||||
SCRIPTS = HERMES / "scripts"
|
SCRIPTS = HERMES / "scripts"
|
||||||
|
SCM_SCRIPTS = HERMES / "scm-common" / "scripts"
|
||||||
|
if str(SCRIPTS) not in sys.path:
|
||||||
|
sys.path.insert(0, str(SCRIPTS))
|
||||||
|
if str(SCM_SCRIPTS) not in sys.path:
|
||||||
|
sys.path.insert(0, str(SCM_SCRIPTS))
|
||||||
|
|
||||||
|
|
||||||
def _load(name: str):
|
def _load(name: str):
|
||||||
spec = importlib.util.spec_from_file_location(name, SCRIPTS / f"{name}.py")
|
root = SCM_SCRIPTS if name == "gitea_api" else SCRIPTS
|
||||||
|
spec = importlib.util.spec_from_file_location(name, root / f"{name}.py")
|
||||||
assert spec and spec.loader
|
assert spec and spec.loader
|
||||||
module = importlib.util.module_from_spec(spec)
|
module = importlib.util.module_from_spec(spec)
|
||||||
sys.modules[spec.name] = module
|
sys.modules[spec.name] = module
|
||||||
@ -167,8 +173,6 @@ def test_agent_runtime_stage_keeps_credentials_in_memory(tmp_path: Path, monkeyp
|
|||||||
values = {
|
values = {
|
||||||
"agent-api-key": "agent-key",
|
"agent-api-key": "agent-key",
|
||||||
"chat-relay-key": "relay-key",
|
"chat-relay-key": "relay-key",
|
||||||
"gitea-token": "gitea-key",
|
|
||||||
"gitea-username": "hermes-automation",
|
|
||||||
"node-ssh-private-key": "private-key",
|
"node-ssh-private-key": "private-key",
|
||||||
"node-ssh-config": "host-config",
|
"node-ssh-config": "host-config",
|
||||||
"node-ssh-known-hosts": "known-hosts",
|
"node-ssh-known-hosts": "known-hosts",
|
||||||
@ -192,6 +196,10 @@ def test_agent_runtime_stage_keeps_credentials_in_memory(tmp_path: Path, monkeyp
|
|||||||
assert (runtime / "codex/skills").is_symlink()
|
assert (runtime / "codex/skills").is_symlink()
|
||||||
assert not (runtime / "claude/backups").exists()
|
assert not (runtime / "claude/backups").exists()
|
||||||
assert not (runtime / "codex/sessions").exists()
|
assert not (runtime / "codex/sessions").exists()
|
||||||
|
assert not (runtime / "gitea-token").exists()
|
||||||
|
assert not (runtime / "gitea-username").exists()
|
||||||
|
ssh_config = (runtime / "node-ssh-config").read_text(encoding="utf-8")
|
||||||
|
assert ssh_config.startswith("Host titan-*\n User hermes-agent\n")
|
||||||
auth = json.loads((runtime / "hermes-auth.json").read_text(encoding="utf-8"))
|
auth = json.loads((runtime / "hermes-auth.json").read_text(encoding="utf-8"))
|
||||||
assert auth == {"version": 1, "providers": {}, "credential_pool": {}}
|
assert auth == {"version": 1, "providers": {}, "credential_pool": {}}
|
||||||
|
|
||||||
@ -355,12 +363,12 @@ def test_manifests_never_seed_access_material_into_persistent_env():
|
|||||||
assert triage_annotations[
|
assert triage_annotations[
|
||||||
"vault.hashicorp.com/agent-inject-secret-triage-api-key"
|
"vault.hashicorp.com/agent-inject-secret-triage-api-key"
|
||||||
] == "kv/data/atlas/hermes/triage-api"
|
] == "kv/data/atlas/hermes/triage-api"
|
||||||
askpass = (SCRIPTS / "gitea_askpass.sh").read_text(encoding="utf-8")
|
gitea_api = (
|
||||||
assert "/runtime-access/gitea-token" in askpass
|
ROOT / "services/hermes/scm-common/scripts/gitea_api.py"
|
||||||
assert "GITEA_TOKEN" not in askpass
|
).read_text(encoding="utf-8")
|
||||||
gitea_api = (SCRIPTS / "gitea_api.py").read_text(encoding="utf-8")
|
assert "scm_broker_client" in gitea_api
|
||||||
assert "/runtime-access/gitea-token" in gitea_api
|
|
||||||
assert "GITEA_TOKEN" not in gitea_api
|
assert "GITEA_TOKEN" not in gitea_api
|
||||||
|
assert "gitea-token" not in annotations
|
||||||
|
|
||||||
|
|
||||||
def test_chat_media_reads_one_raw_runtime_secret(tmp_path: Path):
|
def test_chat_media_reads_one_raw_runtime_secret(tmp_path: Path):
|
||||||
|
|||||||
352
testing/tests/test_hermes_scm_broker.py
Normal file
352
testing/tests/test_hermes_scm_broker.py
Normal file
@ -0,0 +1,352 @@
|
|||||||
|
"""Executable contracts for the credential-isolated Hermes SCM broker."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from email.message import Message
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
ROOT = Path(__file__).parents[2]
|
||||||
|
SCRIPTS = ROOT / "services/hermes/scm-common/scripts"
|
||||||
|
sys.path.insert(0, str(SCRIPTS))
|
||||||
|
|
||||||
|
|
||||||
|
def _load(name: str):
|
||||||
|
path = SCRIPTS / f"{name}.py"
|
||||||
|
spec = importlib.util.spec_from_file_location(f"test_{name}", path)
|
||||||
|
assert spec and spec.loader
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
sys.modules[spec.name] = module
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
class Response:
|
||||||
|
def __init__(self, body: bytes, *, status: int = 200, content_type: str):
|
||||||
|
self.body = body
|
||||||
|
self.status = status
|
||||||
|
self.headers = Message()
|
||||||
|
self.headers["Content-Type"] = content_type
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *_args):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def read(self, limit=-1):
|
||||||
|
return self.body if limit < 0 else self.body[:limit]
|
||||||
|
|
||||||
|
|
||||||
|
def _receive_command(old: bytes, new: bytes, ref: bytes) -> bytes:
|
||||||
|
command = old + b" " + new + b" " + ref + b"\x00report-status\n"
|
||||||
|
return f"{len(command) + 4:04x}".encode() + command + b"0000PACK"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"target",
|
||||||
|
[
|
||||||
|
"/api/v1/repos/atlas/cassandra/pulls/1/merge",
|
||||||
|
"/api/v1/repos/atlas/cassandra/pulls/1/reviews",
|
||||||
|
"/api/v1/admin/users",
|
||||||
|
"/git/evil/cassandra.git/info/refs?service=git-upload-pack",
|
||||||
|
"/git/atlas/cassandra.git/hooks",
|
||||||
|
"/git/atlas/cassandra.git/git-receive-pack?force=true",
|
||||||
|
"/git/atlas/cassandra.git/info/refs?service=git-upload-pack\nignored",
|
||||||
|
"/git/atlas/cassandra.git/info/refs?service=git-upload-pack\tignored",
|
||||||
|
"/git/atlas/cassandra.git/info/refs?service=git-upload-pack#fragment",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_broker_rejects_privileged_or_non_atlas_routes(target: str):
|
||||||
|
broker = _load("scm_broker")
|
||||||
|
|
||||||
|
with pytest.raises(broker.PolicyError):
|
||||||
|
broker._git_target(target)
|
||||||
|
|
||||||
|
|
||||||
|
def test_broker_metadata_boundary_reuses_explicit_read_allowlist():
|
||||||
|
api = _load("gitea_api")
|
||||||
|
|
||||||
|
assert (
|
||||||
|
api.authorize_request("GET", "/api/v1/repos/atlas/cassandra/pulls/1", None)
|
||||||
|
== "pull"
|
||||||
|
)
|
||||||
|
for path in (
|
||||||
|
"/api/v1/repos/atlas/cassandra/pulls/1/merge",
|
||||||
|
"/api/v1/repos/atlas/cassandra/pulls/1/reviews",
|
||||||
|
"/api/v1/repos/atlas/cassandra/hooks",
|
||||||
|
"/api/v1/admin/users",
|
||||||
|
):
|
||||||
|
with pytest.raises(api.PolicyError):
|
||||||
|
api.authorize_request("GET", path, None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_receive_pack_allows_only_new_namespaced_feature_branch():
|
||||||
|
broker = _load("scm_broker")
|
||||||
|
zero = b"0" * 40
|
||||||
|
commit = b"1" * 40
|
||||||
|
|
||||||
|
broker._validate_receive_pack(
|
||||||
|
_receive_command(zero, commit, b"refs/heads/hermes/focused-fix"),
|
||||||
|
"runtime-sentinel",
|
||||||
|
)
|
||||||
|
for old, new, ref in (
|
||||||
|
(commit, b"2" * 40, b"refs/heads/hermes/focused-fix"),
|
||||||
|
(commit, zero, b"refs/heads/hermes/focused-fix"),
|
||||||
|
(zero, commit, b"refs/heads/main"),
|
||||||
|
(zero, commit, b"refs/heads/master"),
|
||||||
|
(zero, commit, b"refs/tags/release"),
|
||||||
|
):
|
||||||
|
with pytest.raises(broker.PolicyError):
|
||||||
|
broker._validate_receive_pack(
|
||||||
|
_receive_command(old, new, ref), "runtime-sentinel"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_git_proxy_uses_fixed_origin_and_never_reflects_credential():
|
||||||
|
broker = _load("scm_broker")
|
||||||
|
seen = []
|
||||||
|
|
||||||
|
def opener(request, timeout):
|
||||||
|
seen.append((request, timeout))
|
||||||
|
return Response(
|
||||||
|
b"git-result",
|
||||||
|
content_type="application/x-git-upload-pack-result",
|
||||||
|
)
|
||||||
|
|
||||||
|
result = broker._upstream_git_request(
|
||||||
|
"/atlas/cassandra.git/git-upload-pack",
|
||||||
|
method="POST",
|
||||||
|
body=b"request",
|
||||||
|
content_type="application/x-git-upload-pack-request",
|
||||||
|
expected_type="application/x-git-upload-pack-result",
|
||||||
|
token="runtime-sentinel",
|
||||||
|
opener=opener,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == b"git-result"
|
||||||
|
request = seen[0][0]
|
||||||
|
assert request.full_url == (
|
||||||
|
"https://scm.bstein.dev/atlas/cassandra.git/git-upload-pack"
|
||||||
|
)
|
||||||
|
assert b"runtime-sentinel" not in request.data
|
||||||
|
assert "runtime-sentinel" not in request.full_url
|
||||||
|
|
||||||
|
with pytest.raises(broker.PolicyError, match="credential material"):
|
||||||
|
broker._upstream_git_request(
|
||||||
|
"/atlas/cassandra.git/git-upload-pack",
|
||||||
|
method="POST",
|
||||||
|
body=b"request",
|
||||||
|
content_type="application/x-git-upload-pack-request",
|
||||||
|
expected_type="application/x-git-upload-pack-result",
|
||||||
|
token="runtime-sentinel",
|
||||||
|
opener=lambda *_a, **_k: Response(
|
||||||
|
b"runtime-sentinel",
|
||||||
|
content_type="application/x-git-upload-pack-result",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
encoded = broker._credential_forms("runtime-sentinel")[1]
|
||||||
|
with pytest.raises(broker.PolicyError, match="credential material"):
|
||||||
|
broker._upstream_git_request(
|
||||||
|
"/atlas/cassandra.git/git-upload-pack",
|
||||||
|
method="POST",
|
||||||
|
body=b"request",
|
||||||
|
content_type="application/x-git-upload-pack-request",
|
||||||
|
expected_type="application/x-git-upload-pack-result",
|
||||||
|
token="runtime-sentinel",
|
||||||
|
opener=lambda *_a, **_k: Response(
|
||||||
|
b"Authorization: Basic " + encoded,
|
||||||
|
content_type="application/x-git-upload-pack-result",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("status", [201, 202, 204, 206, 301, 302, 307, 308])
|
||||||
|
def test_git_proxy_requires_exact_http_200(status: int):
|
||||||
|
broker = _load("scm_broker")
|
||||||
|
|
||||||
|
with pytest.raises(broker.PolicyError, match="unexpected HTTP status"):
|
||||||
|
broker._upstream_git_request(
|
||||||
|
"/atlas/cassandra.git/git-upload-pack",
|
||||||
|
method="POST",
|
||||||
|
body=b"request",
|
||||||
|
content_type="application/x-git-upload-pack-request",
|
||||||
|
expected_type="application/x-git-upload-pack-result",
|
||||||
|
token="runtime-sentinel",
|
||||||
|
opener=lambda *_a, **_k: Response(
|
||||||
|
b"result",
|
||||||
|
status=status,
|
||||||
|
content_type="application/x-git-upload-pack-result",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_agent_broker_client_sends_no_credential_or_authorization_header():
|
||||||
|
client = _load("scm_broker_client")
|
||||||
|
seen = []
|
||||||
|
|
||||||
|
def opener(request, timeout):
|
||||||
|
seen.append((request, timeout))
|
||||||
|
return Response(b"{}", content_type="application/json")
|
||||||
|
|
||||||
|
assert client.read(
|
||||||
|
"/api/v1/repos/atlas/cassandra", opener=opener
|
||||||
|
) == b"{}"
|
||||||
|
request = seen[0][0]
|
||||||
|
assert request.full_url == client.BROKER_ORIGIN + "/v1/metadata"
|
||||||
|
assert request.get_header("Authorization") is None
|
||||||
|
assert json.loads(request.data) == {
|
||||||
|
"path": "/api/v1/repos/atlas/cassandra"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("raw", ["", "01", "9" * 4000, "134217729", "12"])
|
||||||
|
def test_broker_content_length_is_canonical_and_bounded(raw: str):
|
||||||
|
broker = _load("scm_broker")
|
||||||
|
headers = Message()
|
||||||
|
headers["Content-Length"] = raw
|
||||||
|
|
||||||
|
with pytest.raises(broker.PolicyError, match="request length"):
|
||||||
|
broker._content_length(headers, broker.MAX_GIT_REQUEST)
|
||||||
|
|
||||||
|
|
||||||
|
def _can_i(role: dict, api_group: str, resource: str, verb: str) -> bool:
|
||||||
|
return any(
|
||||||
|
(api_group in rule["apiGroups"] or "*" in rule["apiGroups"])
|
||||||
|
and (resource in rule["resources"] or "*" in rule["resources"])
|
||||||
|
and (verb in rule["verbs"] or "*" in rule["verbs"])
|
||||||
|
for rule in role["rules"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_agent_rbac_preserves_read_diagnostics_without_administrator_authority():
|
||||||
|
documents = list(
|
||||||
|
yaml.safe_load_all(
|
||||||
|
(ROOT / "services/hermes-observer-rbac/rbac.yaml").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
cluster_role, namespaced_role, binding = documents
|
||||||
|
assert binding["roleRef"]["name"] == "hermes-agent-cluster-observer-v2"
|
||||||
|
assert _can_i(cluster_role, "", "nodes", "get")
|
||||||
|
assert _can_i(namespaced_role, "", "pods", "get")
|
||||||
|
assert _can_i(namespaced_role, "", "pods/log", "get")
|
||||||
|
assert _can_i(
|
||||||
|
namespaced_role,
|
||||||
|
"kustomize.toolkit.fluxcd.io",
|
||||||
|
"kustomizations",
|
||||||
|
"list",
|
||||||
|
)
|
||||||
|
|
||||||
|
denied = (
|
||||||
|
("", "secrets", "get"),
|
||||||
|
("", "serviceaccounts/token", "create"),
|
||||||
|
("", "pods", "create"),
|
||||||
|
("", "pods/exec", "create"),
|
||||||
|
("", "pods/attach", "create"),
|
||||||
|
("", "pods/portforward", "create"),
|
||||||
|
("rbac.authorization.k8s.io", "clusterrolebindings", "create"),
|
||||||
|
("authorization.k8s.io", "selfsubjectaccessreviews", "create"),
|
||||||
|
("apps", "deployments", "patch"),
|
||||||
|
)
|
||||||
|
assert all(
|
||||||
|
not _can_i(role, *request)
|
||||||
|
for request in denied
|
||||||
|
for role in (cluster_role, namespaced_role)
|
||||||
|
)
|
||||||
|
|
||||||
|
bindings = yaml.safe_load(
|
||||||
|
(ROOT / "services/hermes-observer-rbac/rolebindings.yaml").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
)["items"]
|
||||||
|
namespaces = {item["metadata"]["namespace"] for item in bindings}
|
||||||
|
assert "hermes-scm" not in namespaces
|
||||||
|
assert {"cassandra", "flux-system", "hermes", "kube-system"} <= namespaces
|
||||||
|
assert all(item["roleRef"]["name"].endswith("namespaced-observer-v2") for item in bindings)
|
||||||
|
|
||||||
|
|
||||||
|
def test_flux_boundary_keeps_broker_secret_and_network_separate_from_agent():
|
||||||
|
agent = yaml.safe_load(
|
||||||
|
(ROOT / "services/hermes/agent-deployment.yaml").read_text(encoding="utf-8")
|
||||||
|
)
|
||||||
|
annotations = agent["spec"]["template"]["metadata"]["annotations"]
|
||||||
|
assert not any("gitea" in key.lower() for key in annotations)
|
||||||
|
assert "GIT_ASKPASS" not in json.dumps(agent)
|
||||||
|
|
||||||
|
broker = yaml.safe_load(
|
||||||
|
(ROOT / "services/hermes-scm-broker/deployment.yaml").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert broker["metadata"]["namespace"] == "hermes-scm"
|
||||||
|
assert broker["spec"]["template"]["spec"]["serviceAccountName"] == (
|
||||||
|
"hermes-scm-broker"
|
||||||
|
)
|
||||||
|
assert "gitea-token" in json.dumps(broker)
|
||||||
|
|
||||||
|
policy = yaml.safe_load(
|
||||||
|
(ROOT / "services/hermes-scm-broker/networkpolicy.yaml").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
ingress = policy["spec"]["ingress"]
|
||||||
|
assert ingress[0]["from"][0]["namespaceSelector"]["matchLabels"] == {
|
||||||
|
"kubernetes.io/metadata.name": "hermes"
|
||||||
|
}
|
||||||
|
assert ingress[0]["from"][0]["podSelector"]["matchLabels"] == {
|
||||||
|
"app": "hermes-agent"
|
||||||
|
}
|
||||||
|
|
||||||
|
vault = (
|
||||||
|
ROOT / "services/vault/scripts/vault_k8s_auth_configure.sh"
|
||||||
|
).read_text(encoding="utf-8")
|
||||||
|
assert 'write_policy_and_role "hermes-scm-broker" "hermes-scm"' in vault
|
||||||
|
assert '"hermes/developer-gitea" ""' in vault
|
||||||
|
agent_start = vault.index('write_policy_and_role "hermes-agent"')
|
||||||
|
agent_end = vault.index("write_policy_and_role", agent_start + 1)
|
||||||
|
assert "developer-gitea" not in vault[agent_start:agent_end]
|
||||||
|
|
||||||
|
|
||||||
|
def test_flux_bootstrap_has_no_agent_namespace_dependency_cycle():
|
||||||
|
hermes_resources = yaml.safe_load(
|
||||||
|
(ROOT / "services/hermes/kustomization.yaml").read_text(encoding="utf-8")
|
||||||
|
)["resources"]
|
||||||
|
assert "namespace.yaml" in hermes_resources
|
||||||
|
assert "scm-common" in hermes_resources
|
||||||
|
|
||||||
|
applications = (
|
||||||
|
ROOT / "clusters/atlas/flux-system/applications/kustomization.yaml"
|
||||||
|
).read_text(encoding="utf-8")
|
||||||
|
assert "hermes-scm-agent-code" not in applications
|
||||||
|
|
||||||
|
broker_code = yaml.safe_load(
|
||||||
|
(
|
||||||
|
ROOT
|
||||||
|
/ "clusters/atlas/flux-system/applications/hermes-scm-broker-code/kustomization.yaml"
|
||||||
|
).read_text(encoding="utf-8")
|
||||||
|
)
|
||||||
|
assert broker_code["spec"]["path"] == "./services/hermes/scm-common"
|
||||||
|
assert broker_code["spec"]["targetNamespace"] == "hermes-scm"
|
||||||
|
assert broker_code["spec"]["dependsOn"] == [{"name": "hermes-scm-namespace"}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_gitea_bootstrap_enforces_human_review_without_overwriting_drift():
|
||||||
|
script = (
|
||||||
|
ROOT / "services/gitea/scripts/gitea_atlas_identity_ensure.sh"
|
||||||
|
).read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert 'required_approvals\\\":1' in script
|
||||||
|
assert 'enable_push_whitelist\\\":true' in script
|
||||||
|
assert 'push_whitelist_usernames\\\":[\\\"${protected_reviewer}\\\"]' in script
|
||||||
|
assert 'enable_merge_whitelist\\\":true' in script
|
||||||
|
assert 'merge_whitelist_usernames\\\":[\\\"${protected_reviewer}\\\"]' in script
|
||||||
|
assert "protection differs from the human-review policy" in script
|
||||||
|
assert "api_request PATCH" not in script
|
||||||
Loading…
x
Reference in New Issue
Block a user