← CKS: segurança Kubernetes em produção
15 / 16 · 120 MIN

Admissão, isolamento e rotação de Secrets

Segue a mudança desde a admissão dos Pods até ao consumo de credenciais, com decisões de continuidade e prática em cluster.

Da configuração aceite ao serviço disponível

Numa plataforma fictícia de processamento de fundos, a equipa de segurança pede Restricted antes de um rollout. A equipa de projeto precisa de preservar o fecho e dar a RUN uma configuração que possa ser recuperada. Se o API server aceitar um Deployment, isso confirma uma operação sobre o objeto. Ainda faltam a criação dos Pods, o arranque dos containers, a prontidão e a transação de negócio. Desenha esta sequência e associa a cada etapa uma evidência: resultado de admissão, condições do ReplicaSet, estado dos containers e teste da integração. Um FailedCreate com referência a PodSecurity pertence à criação, não ao diagnóstico de uma aplicação que já arrancou. Começa pela mensagem e pelo template efetivamente aplicado. Compara o campo referido com o requisito aprovado, incluindo init containers. Se a correção exige alterar a imagem ou os privilégios, envolve os responsáveis por aplicação e segurança. Define antes do rollout o tempo máximo de investigação e a configuração de contingência. Réplicas antigas saudáveis podem sustentar temporariamente o negócio, mas não demonstram que o sistema consegue substituí-las depois de uma falha. Essa capacidade de recriação deve entrar no critério de aceitação.

Aplicar e versionar Pod Security

Enforce, warn e audit têm funções diferentes. Num namespace com enforce=baseline e warn=restricted, um Pod pode ser aceite e produzir um aviso útil para a migração. O aviso não é uma aprovação pendente. Ao subir enforce para Restricted, os Pods existentes não são automaticamente expulsos. Um teste deve criar um novo Pod a partir do template, em vez de usar apenas o estado Running dos antigos. A política aplicada a pedidos dos controladores também explica por que razão aceitar um Deployment não garante admitir os Pods que dele resultam. Fixa a versão da política quando precisas de uma comparação controlada. O laboratório usa enforce-version=v1.35 e warn-version=v1.35 num servidor 1.37.0. O label seleciona controlos dessa política; não muda o binário do servidor. Revê campos ao nível do Pod e de cada container: uma configuração válida herdada pode ser substituída por um override incompatível. Em Linux, verifica runAsNonRoot, allowPrivilegeEscalation, capabilities e seccomp, além dos controlos herdados de Baseline. Um container auxiliar também faz parte da avaliação. Documenta dispensas por identidade, namespace ou RuntimeClass e quem pode alterá-las. Uma dispensa geral do controlador pode permitir bypass a muitos autores, para além da release em investigação.

Separar tenants e selecionar runtimes

Começa pelo modelo de confiança. Duas equipas internas que partilham processos de mudança podem ter necessidades diferentes de clientes que executam código não confiável. Namespaces ajudam a organizar objetos e políticas; a conclusão de isolamento exige avaliar acesso à API, rede, recursos, armazenamento e execução. Para cada fronteira, escreve uma ação permitida e uma ação que deve ser recusada. Quotas tratam consumo e não substituem autorização. Uma NetworkPolicy guardada na API precisa de um mecanismo de rede que a aplique. Este laboratório não testa essa camada. RuntimeClass seleciona um handler previamente configurado no CRI. Criar um objeto com o nome sandboxed não instala uma sandbox. Numa pool heterogénea, liga a seleção aos nodes preparados e confirma execução no runtime esperado. Os nodeSelectors do Pod e do RuntimeClass combinam requisitos; valores contraditórios não representam alternativas. Tolerations resolvem elegibilidade perante taints, sem corrigir seletores incompatíveis. Considera overhead e medições representativas antes de prometer capacidade ou latência. Uma aceitação séria identifica a tecnologia executada, o risco mitigado, a responsabilidade por atualizações e o comportamento de recuperação. Passar Restricted reduz determinadas configurações perigosas, mas não comprova ausência de vulnerabilidades do kernel nem uma fronteira completa entre tenants.

Entregar, consumir e rodar Secrets

Divide uma rotação em quatro momentos: o objeto foi atualizado, o valor chegou ao consumidor, o processo passou a utilizá-lo e a versão anterior pode ser retirada. Um GET bem-sucedido cobre apenas parte deste percurso. Um volume normal recebe atualizações com consistência eventual. Uma montagem subPath não recebe essas atualizações automáticas. Uma variável de ambiente existente conserva o valor obtido no arranque. Mesmo quando o ficheiro muda, uma aplicação que o leu uma vez pode manter o valor em memória. Define reload ou recriação e testa a autenticação real, sem imprimir credenciais nos logs. Limita a entrega ao container que precisa do dado. Um observer sem mount nem variável tem menor exposição direta, mas isto não demonstra isolamento forte entre código não confiável no mesmo Pod. Inclui na revisão permissões indiretas, como criar workloads que consumam Secrets. Para objetos imutáveis, usa uma estratégia de substituição com referências controladas e plano de retirada. Se faltar uma chave obrigatória, investiga nome, namespace e contrato da release antes de tornar a referência opcional. Essa alteração pode apenas deslocar uma falha clara no arranque para um erro menos visível no processamento de negócio.

Proteger armazenamento e tráfego

A cifragem dos objetos no armazenamento não resolve automaticamente a forma como as aplicações usam credenciais. Uma configuração que cifra novas escritas não demonstra migração dos objetos antigos. Antes de retirar uma chave ou a capacidade de ler registos antigos, valida a reescrita e a leitura, a consistência dos API servers e a recuperação dos backups. Preserva o material necessário à recuperação segundo o plano aprovado. O laboratório desta aula não inspeciona etcd nem executa rotação de chaves; estas decisões são preparação baseada em documentação. No tráfego entre Pods, distingue ligação cifrada, identidade autenticada e operação autorizada. Num cenário Istio com sidecars, PERMISSIVE admite clientes em claro durante a migração. Inventaria esses clientes antes de impor STRICT e define testes positivos e negativos no destino correto. Uma exceção portLevelMtls aplica-se à porta do workload selecionado, que pode diferir da porta publicada pelo Service. Um pedido mTLS válido não concede automaticamente autorização de negócio. Para RUN, entrega identidades permitidas, política efetiva, sinais de falha e procedimento de rotação ou recuperação. Não uses um HTTP 200 como única evidência de cifragem, e não confundas uma recusa causada por DNS com enforcement de segurança.

Preparar a prática e prever os resultados

Guarda o código como run.py. Usa um cluster kind descartável cujo nome comece por dr-cks-workload-, kubeconfig próprio, cliente kubectl 1.37.1 e servidor 1.37.0. O script recusa outro contexto e exige API HTTPS local com validação ativa. A imagem Python tem de estar previamente importada e identificada por digest no node; imagePullPolicy=Never impede que o exercício procure uma imagem diferente. O README do laboratório descreve a importação ARM64 e a referência local usada nos ensaios. Não reutilizes credenciais nem namespaces de produção. Executa python3 run.py --kubectl /caminho/kubectl --kubeconfig /caminho/kubeconfig --cluster NOME --image REFERENCIA_COM_DIGEST --output evidence.json. Escolhe um relatório novo. O script cria um namespace aleatório, configura apenas a sua política e usa valores explicitamente sintéticos. Antes de executar, prevê cinco resultados: Pod incompatível com aviso, criação direta recusada após enforce, Deployment aceite com recusa no ReplicaSet, volume atualizado e consumidores que conservam o valor inicial. Escreve a evidência que distinguiria cada situação de uma falha de imagem, scheduler ou aplicação. A política está fixada em v1.35; o servidor continua em 1.37.0. Os resultados não provam equivalência integral com o exame oficial.

Interpretar as 17 observações

O primeiro conjunto compara admissão e execução. O Pod antigo continua Running depois da mudança de política; um novo pedido incompatível é recusado. O Deployment passa pela API, mas o controlador regista FailedCreate. Um Pod corrigido arranca com UID 1000, NoNewPrivs definido e seccomp ativo; não tem o token de ServiceAccount montado. Estes resultados demonstram os pedidos e processos observados. Não demonstram que qualquer imagem, init container ou configuração adicional será aceite. O segundo conjunto compara entrega e consumo. O worker começa com o mesmo valor sintético no volume, subPath, ambiente e cache. O observer não recebe esse mount nem variável. Depois do patch, o script espera pela mudança do volume, com prazo limitado, e confirma que as outras três formas conservam o valor inicial. O tempo medido é uma observação deste ensaio, não um SLA. Por fim, a API recusa alterar dados imutáveis e reverter immutable; uma chave obrigatória inexistente impede o arranque de outro container. O relatório guarda 17 resultados e o hash do código. O namespace e os ficheiros temporários são removidos. O cluster descartável deve ser removido no encerramento, com o kubeconfig próprio.

Decidir o fecho e entregar a RUN

No primeiro caso, uma release de fecho tem 20 minutos para decidir contingência. O sinal principal é uma recusa de admissão, apesar de existir tráfego nas réplicas antigas. No segundo, a credencial anterior expira em 30 minutos e os consumidores usam mecanismos diferentes. Em ambos, a decisão precisa de critérios observáveis, responsáveis e prazo. Se faltar evidência dentro da janela, aplica a contingência aprovada e preserva o que permite investigar. Uma exceção ampla pode recuperar a função e simultaneamente invalidar o requisito de segurança da mudança. Entrega a RUN uma matriz de consumidores, versões e controlos: quem cria Pods, qual política se aplica, como cada processo recebe credenciais, como recarrega, o que deve ser recusado e quem resolve cada falha. Inclui a capacidade de recriação no teste de continuidade. Separa os resultados deste laboratório das práticas ainda necessárias: CNI, sandboxing, service mesh, cifragem de etcd e recuperação completa. Os cenários são originais e fictícios, sem representar procedimentos internos da BNP Paribas. O resumo final deve permitir que um colega explique a diferença entre objeto aceite, processo funcional e requisito de segurança comprovado, usando a evidência do percurso.

"""Original CKS admission and synthetic Secret-delivery lab on a disposable kind cluster."""
import argparse,datetime,hashlib,json,shutil,subprocess,tempfile,time,uuid
from pathlib import Path
p=argparse.ArgumentParser();p.add_argument('--kubectl',required=True);p.add_argument('--kubeconfig',required=True);p.add_argument('--cluster',required=True);p.add_argument('--image',required=True);p.add_argument('--output',required=True);a=p.parse_args()
assert '@sha256:' in a.image;assert a.cluster.startswith('dr-cks-workload-');assert Path(a.kubeconfig).is_absolute();out=Path(a.output);assert not out.exists()
ns='dr-workload-'+uuid.uuid4().hex[:8];tmp=Path(tempfile.mkdtemp(prefix='dr-workload-private-'));records=[];created=False
base=[a.kubectl,'--kubeconfig',a.kubeconfig,'--context','kind-'+a.cluster,'--cache-dir',str(tmp/'cache'),'--request-timeout=15s']
def k(*args,obj=None,ok=True):
 r=subprocess.run(base+list(args),input=json.dumps(obj)if obj is not None else None,capture_output=True,text=True,timeout=35)
 if ok and r.returncode:raise RuntimeError(str(args[:3])+': '+r.stderr[:500])
 return r

def check(name,condition,observed):
 assert condition,(name,observed);records.append(dict(name=name,passed=True,observed=observed));print(name,flush=True)
def get(kind,name=None):return json.loads(k('-n',ns,'get',kind,*([name]if name else []),'-o','json').stdout)
def poll(fn,seconds=90):
 end=time.monotonic()+seconds
 while time.monotonic()<end:
  value=fn()
  if value:return value
  time.sleep(1)
 raise AssertionError('Condition deadline exceeded')
def create(obj,ok=True):return k('-n',ns,'create','-f','-',obj=obj,ok=ok)
def pod(name,restricted=True):
 return dict(apiVersion='v1',kind='Pod',metadata=dict(name=name,labels={'app':name}),spec=dict(automountServiceAccountToken=False,terminationGracePeriodSeconds=1,securityContext=dict(runAsNonRoot=True,runAsUser=1000,runAsGroup=1000,seccompProfile={'type':'RuntimeDefault'}),containers=[dict(name='worker',image=a.image,imagePullPolicy='Never',command=['python','-c','import time; time.sleep(600)'],resources=dict(requests={'cpu':'10m','memory':'24Mi'},limits={'cpu':'200m','memory':'64Mi'}),securityContext=dict(allowPrivilegeEscalation=not restricted,readOnlyRootFilesystem=True,capabilities={'drop':['ALL']}))]))
def execpy(name,code,container='worker'):return json.loads(k('-n',ns,'exec',name,'-c',container,'--','python','-c',code).stdout)
try:
 cfg=json.loads(k('config','view','--minify','--raw','-o','json').stdout)['clusters'][0]['cluster'];assert cfg['server'].startswith('https://127.0.0.1:')and not cfg.get('insecure-skip-tls-verify');cfg=None
 version=json.loads(k('version','-o','json').stdout);assert version['serverVersion']['gitVersion']=='v1.37.0'and version['clientVersion']['gitVersion']=='v1.37.1'
 k('create','namespace',ns);created=True
 k('label','namespace',ns,'pod-security.kubernetes.io/enforce=baseline','pod-security.kubernetes.io/enforce-version=v1.35','pod-security.kubernetes.io/warn=restricted','pod-security.kubernetes.io/warn-version=v1.35')
 legacy=pod('legacy',False);r=create(legacy);check('baseline-admits-with-restricted-warning','would violate PodSecurity' in r.stderr and 'allowPrivilegeEscalation' in r.stderr,dict(admitted=True,restrictedWarning=True))
 poll(lambda:get('pod','legacy').get('status',{}).get('phase')=='Running');uid=get('pod','legacy')['metadata']['uid']
 r=k('label','namespace',ns,'pod-security.kubernetes.io/enforce=restricted','--overwrite');check('enforce-update-warns-existing-pod','legacy' in r.stderr and 'allowPrivilegeEscalation'in r.stderr,dict(existingViolationReported=True))
 current=get('pod','legacy');check('existing-pod-not-evicted',current['metadata']['uid']==uid and current['status']['phase']=='Running',dict(sameUID=True,phase=current['status']['phase']))
 r=create(pod('denied',False),ok=False);check('new-violating-pod-rejected',r.returncode!=0 and 'Forbidden'in r.stderr and 'allowPrivilegeEscalation'in r.stderr,dict(rejected=True,control='allowPrivilegeEscalation'))
 bad=pod('blocked-release',False);deployment=dict(apiVersion='apps/v1',kind='Deployment',metadata=dict(name='blocked-release'),spec=dict(replicas=1,selector={'matchLabels':{'app':'blocked-release'}},template={'metadata':{'labels':{'app':'blocked-release'}},'spec':bad['spec']}))
 r=create(deployment);check('deployment-object-admitted','would violate PodSecurity'in r.stderr,dict(admitted=True,warning=True))
 def controller_denial():
  for rs in get('replicasets')['items']:
   if rs['metadata'].get('labels',{}).get('app')=='blocked-release':
    for c in rs.get('status',{}).get('conditions',[]):
     if c.get('reason')=='FailedCreate' and 'violates PodSecurity'in c.get('message',''):return c
 condition=poll(controller_denial);check('controller-pod-create-denied',condition['status']=='True',dict(type=condition['type'],reason=condition['reason'],policyDenial=True))
 k('-n',ns,'delete','deployment','blocked-release','--wait=true')
 corrected=pod('corrected');create(corrected);poll(lambda:get('pod','corrected').get('status',{}).get('phase')=='Running')
 runtime=execpy('corrected',"import os,json;from pathlib import Path;s=dict(x.split(':',1)for x in Path('/proc/self/status').read_text().splitlines());print(json.dumps({'uid':os.getuid(),'nnp':s['NoNewPrivs'].strip(),'seccomp':s['Seccomp'].strip(),'tokenMounted':Path('/var/run/secrets/kubernetes.io/serviceaccount/token').exists()}))")
 check('corrected-pod-runs-restricted',runtime['uid']==1000 and runtime['nnp']=='1' and runtime['seccomp']=='2',runtime)
 check('serviceaccount-token-not-mounted',not runtime['tokenMounted'],dict(tokenMounted=False))
 create(dict(apiVersion='v1',kind='Secret',metadata=dict(name='rotation'),stringData={'value':'synthetic-v1'}))
 consumer=pod('consumer');c=consumer['spec']['containers'][0];c['env']=[dict(name='DR_VALUE',valueFrom={'secretKeyRef':{'name':'rotation','key':'value'}})];c['volumeMounts']=[dict(name='secret',mountPath='/rotating',readOnly=True),dict(name='secret',mountPath='/frozen',subPath='value',readOnly=True),dict(name='scratch',mountPath='/tmp')]
 c['command']=['python','-c',"import json,os,time;from pathlib import Path;Path('/tmp/startup.json').write_text(json.dumps({'env':os.environ['DR_VALUE'],'cached':Path('/rotating/value').read_text()}));time.sleep(600)"]
 consumer['spec']['volumes']=[dict(name='secret',secret={'secretName':'rotation'}),dict(name='scratch',emptyDir={})]
 observer=pod('x')['spec']['containers'][0];observer['name']='observer';consumer['spec']['containers'].append(observer);create(consumer)
 poll(lambda:all(x.get('ready')for x in get('pod','consumer').get('status',{}).get('containerStatuses',[]))and len(get('pod','consumer').get('status',{}).get('containerStatuses',[]))==2)
 inspect="import json,os;from pathlib import Path;d=json.loads(Path('/tmp/startup.json').read_text());d.update(volume=Path('/rotating/value').read_text(),subPath=Path('/frozen').read_text());print(json.dumps(d))"
 before=execpy('consumer',inspect);check('three-delivery-paths-initialized',all(v=='synthetic-v1'for v in before.values()),dict(paths=list(before),allInitial=True))
 other=execpy('consumer',"import json,os;from pathlib import Path;print(json.dumps({'filePresent':Path('/rotating/value').exists(),'envPresent':'DR_VALUE'in os.environ}))",'observer');check('mount-scoped-to-consumer',not other['filePresent']and not other['envPresent'],other)
 k('-n',ns,'patch','secret','rotation','--type=merge','-p',json.dumps({'stringData':{'value':'synthetic-v2'}}))
 start=time.monotonic();after=poll(lambda:(lambda x:x if x['volume']=='synthetic-v2'else None)(execpy('consumer',inspect)),180)
 check('regular-volume-eventually-updated',after['volume']=='synthetic-v2',dict(updated=True,observedDelaySeconds=round(time.monotonic()-start,2)))
 check('environment-retains-initial-value',after['env']=='synthetic-v1',dict(retainedInitial=True))
 check('subpath-retains-initial-value',after['subPath']=='synthetic-v1',dict(retainedInitial=True))
 check('application-cache-retains-initial-value',after['cached']=='synthetic-v1',dict(retainedInitial=True))
 create(dict(apiVersion='v1',kind='Secret',metadata=dict(name='fixed'),immutable=True,stringData={'value':'synthetic-fixed'}))
 r=k('-n',ns,'patch','secret','fixed','--type=merge','-p',json.dumps({'stringData':{'value':'synthetic-new'}}),ok=False);check('immutable-data-change-rejected',r.returncode!=0 and 'immutable'in r.stderr,dict(rejected=True))
 r=k('-n',ns,'patch','secret','fixed','--type=merge','-p','{"immutable":false}',ok=False);check('immutable-reversal-rejected',r.returncode!=0 and 'immutable'in r.stderr,dict(rejected=True))
 missing=pod('missing-key');missing['spec']['containers'][0]['env']=[dict(name='DR_VALUE',valueFrom={'secretKeyRef':{'name':'rotation','key':'absent'}})];create(missing)
 def missing_reason():
  statuses=get('pod','missing-key').get('status',{}).get('containerStatuses',[])
  return next((x.get('state',{}).get('waiting')for x in statuses if x.get('state',{}).get('waiting',{}).get('reason')=='CreateContainerConfigError'),None)
 error=poll(missing_reason);check('missing-required-key-blocks-start',error['reason']=='CreateContainerConfigError',dict(reason=error['reason'],missingKey='absent'))
 images={x['name']:x['imageID']for x in get('pod','consumer')['status']['containerStatuses']};assert len(records)==17,len(records)
finally:
 if created:k('delete','namespace',ns,'--wait=true','--timeout=90s')
 shutil.rmtree(tmp)
report=dict(executedAt=datetime.datetime.now(datetime.timezone.utc).isoformat(),scriptSha256=hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),version=version,examVersion='1.35',policyVersion='v1.35',versionDifferenceExplicit=True,images=images,observations=records,cleanup=dict(namespaceRemoved=True,temporaryMaterialRemoved=not tmp.exists(),realSecretsUsed=False),scope='Actual Pod Security admission, controller rejection, running Linux Pods and synthetic Secret delivery on Kubernetes1.37.0 with policy pinned v1.35. No CNI isolation, service mesh, sandboxed runtime, encryption-at-rest, tenant security boundary or full practical mock demonstrated.')
out.write_text(json.dumps(report,indent=2)+'\n');print('PASS',len(records))
NA PRÁTICA

O Deployment é aceite, mas o ReplicaSet não consegue criar Pods; um Secret muda no volume enquanto o processo conserva a versão antiga.

Armadilhas comuns

Confundir apply com disponibilidade, projeção com reload ou namespace com isolamento completo; usar dispensas globais sem avaliar o alcance.

Tópicos relacionados: Identidades e delegação · Hardening Linux · Segurança da cadeia de fornecimento

Leva esta ideia contigo

Aceitação útil acompanha cada fronteira: pedido, processo, consumo e função de negócio, com testes permitidos e recusados.

Criar conta

Referência: CKS certification and domains · Kubernetes v1.35; current six-domain CKS outline

Kubernetes® e CKS são marcas comerciais ou marcas registadas de The Linux Foundation. A dr.pt é uma plataforma de preparação independente e não está afiliada, associada, patrocinada, autorizada nem aprovada por The Linux Foundation. Os conteúdos e as perguntas são originais, não são perguntas oficiais de exame, e concluir os nossos testes não atribui nem garante qualquer certificação. Os nomes são usados apenas para identificar o tema. Todas as outras marcas pertencem aos respetivos titulares.