feat(gate): pakke-gaten leser INNHOLDET, ikke bare filnavn + vurdering av Azure-omdøping
ORDRE 20260818T103716Z-251212929. To deler. DEL 2 - innholds-gapet (TDD, red-first MÅLT): Hver eksisterende gate i test_handover_package_loadbearing.py leser arkiv-MEDLEMSNAVN. Ingen leste hva medlemmene SIER - som er nøyaktig hvorfor ressursgruppe, ressurs, prosjekt og vertsnavn nådde en ekstern organisasjon i 14:24-bygget uten at én av 869 tester merket det. RED FIRST, mot ekte data: gate-kroppen kjørt mot den LEVERTE zip-en gir 7 funn (vertsnavnet + seks /Users-stier), mot `git archive77076b9` gir den 8. Den finner altså det som faktisk lakk, før den brukes til å påstå at HEAD er ren. Gaten er en NEKTELSE, aldri et filter: den fjerner ingenting fra arkivet, den sier at treet ikke er leveringsklart. Pakka forblir `git archive HEAD` - kø-(p) intakt. Mønstrene bor i ÉN liste, hver rad med sin egen kjent-positive prøve, og hver prøve er BYGGET VED KONKATENERING så fila ikke matcher seg selv (verifisert: 0 funn i egen kilde - ellers hadde eneste fiks vært et hull i gaten der en hemmelighet kan gjemme seg). Aksept-lista er selv gatet: en oppføring som ikke lenger nås er drift og felles. MÅLT, seks mutasjoner - fem røde, én uten diskriminerende kraft: - detach scanneren -> 1 rød (leaked-kontrollen) - aksept-lista sluker ekte vert -> 2 røde - ødelegg vertsnavn-regexen -> 2 røde - foreldet aksept-oppføring -> 1 rød (minimalitets-kontrollen) - koordinat tilbake i HEAD -> 1 rød, gaten ALENE - fjern nevner-asserten -> 0 røde (kontroll, ikke søm - uttalt) Nevner: 325 medlemmer lest, 1 hoppet over (sqlite-binær). 873 passed / 5 skipped. ÆRLIGHETS-GRENSE, uttalt i koden: gaten fanger STRUKTUR. Vertsnavnet har en form; ressursgruppe og prosjekt er fri tekst uten form, og ble i august bare oppdaget fordi de sto i samme tabell som verten. Å lukke det gapet krever en navneliste - den andre kopien av eksponeringsregelen, som er dét pakkas `git archive HEAD`-form finnes for å forby. DEL 1 - vurdering av omdøping (ingenting rørt i Azure): docs/2026-08-18-vurdering-azure-omdoeping.md. Anbefaling: IKKE døp om. Lekkasjen ga MÅLRETTING, ikke tilgang, og målrettingen kan ikke trekkes tilbake - navnene ligger i publisert historikk og i en zip hos en tredjepart. Omdøping finnes dessuten ikke som operasjon: et custom subdomain KAN IKKE endres (Learn), så det er riving + gjenoppbygging i sju steg. Det ene tiltaket som faktisk fjerner en autorisasjonsvei Entra ikke dekker er `disableLocalAuth` + nøkkelregenerering. Beslutningen er operatørens; valgene står med konsekvenser, ikke som konklusjon. PREMISS KORRIGERT (Verifiseringsloven ansikt 3): ordren sa koordinatene sto i repoet og at tre /Users/ktg-stier lå på open/main. Målt på6d2837f: 0 og 0 -241b50dog6d2837flukket begge. Premisset var sant da ordren ble skrevet (10:37Z) og sluttet å være det 13:03/13:20. Ingen begrunnet aksept-oppføring var derfor nødvendig for sti-klassen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01964PUr46mfnxWtMw23AnVD
This commit is contained in:
parent
6d2837fdca
commit
4cf8c4f6ba
2 changed files with 404 additions and 0 deletions
199
docs/2026-08-18-vurdering-azure-omdoeping.md
Normal file
199
docs/2026-08-18-vurdering-azure-omdoeping.md
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
# Vurdering: skal Azure-ressursene døpes om?
|
||||
|
||||
**Bestilt av** ordre `20260818T103716Z-251212929` (fra `.claude`). **Leveransen er en vurdering, ikke
|
||||
en omdøping** — ingenting i Azure er rørt. Beslutningen er operatørens.
|
||||
|
||||
Alt under er enten **målt** i dette repoet eller **verifisert mot Microsoft Learn**. Der noe ikke er
|
||||
verifisert, står det. Kildene er listet i §6.
|
||||
|
||||
## 1. Hva røper koordinatene faktisk?
|
||||
|
||||
Koordinatene som nådde en ekstern organisasjon 14.08 (i `dist/portfolio-optimiser-foundry-1.1.0.zip`,
|
||||
bygget 14:24):
|
||||
|
||||
| Verdi | Type |
|
||||
|---|---|
|
||||
| `<resource-group>` | ressursgruppe (ARM-planet) |
|
||||
| `<resource>` | Foundry-ressurs = **custom subdomain** (DNS) |
|
||||
| `<project>` | prosjekt (ARM + data-plan-sti) |
|
||||
| `eastus` | region |
|
||||
| `https://<resource>.services.ai.azure.com/api/projects/<project>` | prosjekt-endepunkt |
|
||||
|
||||
De literale navnene er byttet mot plassholdere her, samme form som `DEPLOY.md`, auth-oppskriften og
|
||||
måleprotokollen etter `241b50d`. Dokumentet handler om hva navnene *er*, ikke om hvilke de var — og
|
||||
innholds-gaten i `tests/test_handover_package_loadbearing.py` avviste førsteutkastet som bar dem.
|
||||
|
||||
**Utledbart uansett — ikke lekket av oss:**
|
||||
|
||||
- **At det er en AIServices-ressurs med prosjekter.** URL-formen `…/api/projects/<project>` ER den
|
||||
dokumenterte Foundry-prosjekt-endepunkt-formen. Enhver som ser en slik URL vet ressurstypen.
|
||||
- **Modell, versjon og deployment-navn** (`gpt-4.1-mini`, `2025-04-14`, GlobalStandard). Offentlig
|
||||
katalog-nomenklatur.
|
||||
- **Rolle-GUID-en `53ca6127-db72-4b80-b1b0-d745d6d5456d`.** Azures **offentlige** innebygde
|
||||
role definition id for `Foundry User`, identisk i hver tenant. Den står i Learn-dokumentasjonen.
|
||||
Den er **ikke** en koordinat, og skal ikke plassholdes.
|
||||
- **At vertsnavnet i det hele tatt er et globalt, gjettbart navnerom.** Custom subdomain ligger under
|
||||
ett felles DNS-navnerom, og navnet er unikt på tvers av alle kunder — så *eksistensen* av et gitt
|
||||
navn kan enhver bekrefte ved å slå det opp. Lekkasjen fjernet **gjettingen**, ikke muligheten.
|
||||
|
||||
**Kun kjent fordi navnene lekket:**
|
||||
|
||||
- **Ressursgruppenavnet.** Det finnes ikke i DNS og ikke i noen data-plan-URL. Det er rent
|
||||
ARM-plan-informasjon.
|
||||
- **Regionen** (`eastus`). Ikke utledbar fra vertsnavnet for `*.services.ai.azure.com`.
|
||||
- **Prosjektnavnet** (`<project>`) — det står riktignok i endepunkts-URL-en, men URL-en er selv en
|
||||
del av lekkasjen.
|
||||
- **Koblingen mellom dem.** Det operativt verdifulle er ikke ett navn, men at ressursgruppe,
|
||||
ressurs, prosjekt, region og rolle-scope kommer som ett ferdig sett.
|
||||
|
||||
**Kort:** dette er rekognoseringsinformasjon om ARM-planet. Det er ikke nøkler, og det er ikke
|
||||
tenant-id eller abonnements-id — ingen av de to sto i dokumentet (målt: `0` treff på
|
||||
`/subscriptions/<guid>` i hele treet og i den leverte pakka).
|
||||
|
||||
## 2. Hva skal til for å misbruke dem?
|
||||
|
||||
### Det Entra faktisk stopper
|
||||
|
||||
Et kall mot prosjekt-endepunktet krever **både** et gyldig Entra-token for scopet
|
||||
`https://ai.azure.com/.default` **og** en RBAC-tildeling på ressurs- eller prosjekt-scope. Microsofts
|
||||
egen feilkode-tabell skiller de to: **401** = manglende/utløpt token, **403** = manglende
|
||||
rolletildeling. Å kjenne adressen gir altså i seg selv **null** inferens-tilgang.
|
||||
|
||||
Token-basert auth krever dessuten et custom subdomain — regionale endepunkter støtter ikke Entra i
|
||||
det hele tatt. Vi bruker custom subdomain, altså er Entra-stien tilgjengelig.
|
||||
|
||||
### Det Entra ikke stopper
|
||||
|
||||
1. **Nøkkelbasert auth, hvis den er på.** Entra blir *eneste* autorisasjonsmetode først når
|
||||
`disableLocalAuth` er satt til `true` — det er en eksplisitt handling (Azure Policy på
|
||||
abonnement/ressursgruppe, `disableLocalAuth` i ARM/Bicep, eller `Set-AzCognitiveServicesAccount
|
||||
-DisableLocalAuth $true`). Er den ikke satt, finnes det nøkler som omgår Entra fullstendig.
|
||||
**Ikke verifisert for denne ressursen:** `az cognitiveservices account create`-kommandoen i
|
||||
måleprotokollen (`docs/2026-08-14-fase1b-forste-levende-kjoring.md` §0) ba ikke om det, og om
|
||||
abonnementet har policyen er ukjent herfra. **Dette er den ene sjekken som faktisk endrer
|
||||
risikobildet** — se §4.
|
||||
Merk også at avslåing ikke slår inn momentant: endringen skjer i kontrollplanet med én gang, men
|
||||
gatewayen kan godta tidligere gyldige nøkler til cachen oppdateres — typisk minutter, opptil
|
||||
flere timer. Og allerede utdelte nøkler må regenereres separat; å slå av lokal auth *tilbakekaller*
|
||||
dem ikke.
|
||||
2. **Målrettet phishing og consent-phishing.** Koordinatene gjør en henvendelse troverdig — avsender
|
||||
kan navngi ressursgruppe, ressurs og prosjekt riktig. Entra beskytter identiteten, ikke
|
||||
overtalelsen. Dette er den mest realistiske misbruksveien for denne typen lekkasje.
|
||||
3. **Kvote- og kostnadsmisbruk ved kompromittert identitet.** Kvote tildeles **per abonnement, per
|
||||
region, per modell og deployment-type**, i tokens-per-minutt, og deles av alle deployments av
|
||||
samme modell i samme region i abonnementet. En misbrukt identitet med `Foundry User` på dette
|
||||
prosjektet spiser altså av en pott som er felles, og kan strupe *andre* deployments av samme
|
||||
modell i samme abonnement — ikke bare denne. Forbruket faktureres.
|
||||
4. **Nettverksflaten.** `publicNetworkAccess` er en egenskap som må settes til `Disabled` for å stenge
|
||||
den offentlige inngangen. Er den ikke det, er endepunktet nåbart fra internett — det var sant før
|
||||
lekkasjen også. Forskjellen er at adressen nå er *kjent*, ikke at den ble *nåbar*.
|
||||
|
||||
**Ikke verifisert:** om et uautentisert kall skiller et eksisterende prosjektnavn fra et
|
||||
ikke-eksisterende (altså om `<project>` kan bekreftes uten token). Det ville krevd et faktisk kall
|
||||
mot en fremmed ressurs, og det er ikke gjort.
|
||||
|
||||
## 3. Hva koster omdøping?
|
||||
|
||||
### I repoet: null
|
||||
|
||||
Målt på `6d2837f` (= `open/main` = `origin/main`), nevner **327 sporede filer**:
|
||||
|
||||
| Sted | Treff på de tre literale navnene |
|
||||
|---|---|
|
||||
| Sporet tre (kode, tester, docs, `env.template`) | **0** |
|
||||
| Usporet deck `docs/presentasjon-portfolio-optimiser.html` | **0** |
|
||||
| `shared/` (subtree) | **0** |
|
||||
|
||||
De 18 gjenværende linjene med `services.ai.azure.com` er plassholderformen (`<resource>.`),
|
||||
wildcard-formen (`*.`) eller testdummies (`x.`, `platform.`, `wrong.`). `env.template` bærer
|
||||
variabelnavn, aldri verdier. **Omdøping krever altså ingen redigering i repoet** — `241b50d` gjorde
|
||||
allerede den jobben.
|
||||
|
||||
### I historikken og i den leverte pakka: kan ikke tilbakekalles
|
||||
|
||||
- **Git-historikken:** 3 linjer i 1 fil, i 3 commits (`5bd8e1c`, `bb4807a`, `241b50d`). Publisert på
|
||||
`open/main`. Ordren forbyr å skrive om historikken, og vei B ble alt avvist 18.08.
|
||||
- **Den leverte pakka:** 4 linjer, hos en tredjepart siden 14.08. En omdøping i Azure gjør ikke det
|
||||
usett.
|
||||
|
||||
### I Azure: en full riving og gjenoppbygging
|
||||
|
||||
**Et custom subdomain kan ikke endres.** Microsoft er eksplisitt: navnet kan ikke endres etter at det
|
||||
er opprettet og knyttet til ressursen, og for å gjenbruke et navn må den eksisterende ressursen
|
||||
slettes. «Omdøping» finnes derfor ikke som operasjon — det er:
|
||||
|
||||
1. opprett ny AIServices-ressurs med nytt subdomain (+ `--allow-project-management`)
|
||||
2. opprett nytt prosjekt
|
||||
3. redeploy `gpt-4-1-mini` (ny kvotetildeling i regionen)
|
||||
4. tildel `Foundry User` på nytt prosjekt-scope
|
||||
5. oppdater `PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT` og `PORTFOLIO_MODEL_MAP` lokalt
|
||||
6. slett — og purge — den gamle ressursen (purge krever `Contributor` på abonnements-scope)
|
||||
7. re-kjør stigen i måleprotokollen §1 for å bevise at auth/RBAC/endepunkt fortsatt komponerer
|
||||
|
||||
**Ikke verifisert:** om det gamle subdomain-navnet holdes reservert en periode etter sletting slik
|
||||
App Service og API Management gjør (anti-subdomain-takeover). Learn dokumenterer den mekanismen for
|
||||
de tjenestene, men ikke for Foundry-/Cognitive Services-subdomener. Ikke anta at navnet frigis — og
|
||||
ikke anta at det er låst.
|
||||
|
||||
## 4. Anbefaling — og hva hvert valg koster
|
||||
|
||||
**Anbefalingen er: ikke døp om. Fjern i stedet den ene veien som ikke går gjennom Entra, og gjør
|
||||
misbruk synlig.**
|
||||
|
||||
Begrunnelsen er at omdøping løser feil problem. Det lekkasjen ga en motpart er **målretting**, ikke
|
||||
**tilgang**. Og målrettingen kan ikke trekkes tilbake: navnene ligger i publisert git-historikk og i
|
||||
en zip hos en tredjepart. En omdøping ville altså kjøpt at *dagens* ressurs ikke er den som ble
|
||||
navngitt — ikke at navngivingen forsvinner. Det er en reell, men liten gevinst, og den betales med en
|
||||
full riving av det eneste levende Foundry-oppsettet prosjektet har.
|
||||
|
||||
**Valg A — behold navnene, herd oppsettet (anbefalt).**
|
||||
- Sjekk `DisableLocalAuth` på ressursen. Er den ikke `true`: regenerer begge nøklene *og* sett den.
|
||||
Dette er den eneste tiltaket som fjerner en autorisasjonsvei Entra ikke dekker.
|
||||
- Sjekk at rolletildelingen står på **prosjekt**-scope og ikke bredere, og vurder
|
||||
`Foundry Agent Consumer` framfor `Foundry User` dersom kjøringene bare gjør inferens.
|
||||
- Sett et kostnadsvarsel på abonnementet og hold TPM-tildelingen på deploymentet lav. Kvotemisbruk
|
||||
blir da både begrenset og synlig.
|
||||
- *Konsekvens:* måleprotokollen forblir reproduserbar, ingen ny kjøring må betales, og navnene
|
||||
fortsetter å stå i historikken — som de ville gjort uansett.
|
||||
|
||||
**Valg B — riv og bygg opp igjen med et intetsigende navn.**
|
||||
- *Kjøper:* at et navn en motpart eventuelt sitter og venter på, ikke lenger peker på noe levende.
|
||||
- *Koster:* de sju stegene i §3, ny betalt verifiseringskjøring, og at
|
||||
`docs/2026-08-14-fase1b-forste-levende-kjoring.md` beskriver et oppsett som ikke finnes lenger.
|
||||
- *Kjøper ikke:* at navnene forsvinner fra historikken eller fra den leverte pakka.
|
||||
- Velg denne hvis vurderingen er at ressursgruppe- og prosjektnavnet i seg selv er sensitivt i
|
||||
organisasjonssammenheng — det er en vurdering operatøren kan gjøre og ikke jeg.
|
||||
|
||||
**Valg C — gjør begge.** Herdingen i A er verdt å gjøre *uansett* hvilket av A og B som velges; B
|
||||
uten A etterlater den samme nøkkel-veien åpen på en ny ressurs.
|
||||
|
||||
## 5. Hva denne vurderingen ikke dekker
|
||||
|
||||
- Ingenting i Azure er inspisert. Alle utsagn om *denne* ressursens faktiske konfigurasjon
|
||||
(`disableLocalAuth`, `publicNetworkAccess`, rolle-scope) er markert som uverifiserte over.
|
||||
- **Innholds-gaten fanger vertsnavnet, ikke ressursgruppe- og prosjektnavnet.** Et vertsnavn har en
|
||||
struktur (`<label>.services.ai.azure.com`); en ressursgruppe heter hva som helst. De to andre
|
||||
navnene ble bare oppdaget fordi de sto i samme tabell som verten. En gate kan ikke lukke det
|
||||
gapet uten en navneliste, og en navneliste er den andre kopien av eksponeringsregelen.
|
||||
- Ordre-teksten sa at koordinatene fortsatt sto i repoet. Det er **ikke** tilfelle per `6d2837f` —
|
||||
målt, se §3. Premisset var riktig da ordren ble skrevet (10:37Z) og sluttet å være det 13:03.
|
||||
|
||||
## 6. Kilder
|
||||
|
||||
Alle verifisert 18.08.2026 mot Microsoft Learn:
|
||||
|
||||
- Custom subdomain kan ikke endres; må slette ressursen for å gjenbruke navnet —
|
||||
`learn.microsoft.com/azure/ai-services/cognitive-services-custom-subdomains`
|
||||
- Entra-auth krever custom subdomain; 401 vs 403; scope `https://ai.azure.com/.default` —
|
||||
`learn.microsoft.com/azure/foundry/concepts/authentication-authorization-foundry`
|
||||
- `disableLocalAuth` er en eksplisitt handling; propagering minutter til timer; nøkler må regenereres
|
||||
separat — `learn.microsoft.com/azure/ai-services/disable-local-auth`
|
||||
- Rolle-id `53ca6127-db72-4b80-b1b0-d745d6d5456d` = `Foundry User`, offentlig og lik i hver tenant;
|
||||
`Foundry Agent Consumer` som minste-privilegium for ren inferens —
|
||||
`learn.microsoft.com/azure/foundry/concepts/rbac-foundry`
|
||||
- Kvote per abonnement/region/modell/deployment-type i TPM; deles av deployments i samme region —
|
||||
`learn.microsoft.com/azure/foundry/openai/how-to/quota`
|
||||
- `publicNetworkAccess` / `disableLocalAuth` som ARM-egenskaper —
|
||||
`learn.microsoft.com/azure/templates/microsoft.cognitiveservices/accounts`
|
||||
- Anti-subdomain-takeover-reservasjon (dokumentert for App Service / API Management, **ikke** for
|
||||
Cognitive Services) — `learn.microsoft.com/azure/security/fundamentals/subdomain-takeover`
|
||||
|
|
@ -36,6 +36,7 @@ than in ours:
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
|
@ -221,3 +222,207 @@ def test_receiver_documents_start_the_service_as_a_python_process(package: zipfi
|
|||
f"{label} instructs a container/azd build step that this package no longer ships: "
|
||||
f"{offenders}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The content gap (ordre 20260818T103716Z). Every gate above reads archive MEMBER
|
||||
# NAMES. None reads what the members SAY -- which is exactly how a Foundry resource
|
||||
# group, resource, project and host name reached an external organisation in the
|
||||
# 14:24 build on 14.08 without one of 869 tests noticing. The archive is
|
||||
# ``git archive HEAD`` by construction, so a content gate here is a REFUSAL, never a
|
||||
# filter: it never removes anything from the archive, it says the tree is not
|
||||
# deliverable. That keeps the kø-(p) rule intact -- there is still exactly one copy
|
||||
# of "what a receiver gets", and it is HEAD.
|
||||
#
|
||||
# One list, never patterns spread through the code. Each row carries its own
|
||||
# known-positive SAMPLE, so a pattern that can no longer find anything cannot be
|
||||
# added: ``test_every_content_pattern_can_find`` runs the whole list against its own
|
||||
# samples. Every sample is BUILT BY CONCATENATION so this file's own source bytes
|
||||
# contain no literal match -- otherwise the gate would be red on the file that
|
||||
# defines it, and the only sane fix would be a hole in the gate at exactly the place
|
||||
# a secret could hide.
|
||||
#
|
||||
# HONESTY LIMIT, stated because it decided the design: this gate catches STRUCTURE, so it
|
||||
# catches the host (`<label>.services.ai.azure.com`) and misses the resource group and the
|
||||
# project name, which are free-form strings with no shape to match. In the 14.08 leak those
|
||||
# two were only spotted because they sat in the same table as the host. Closing that gap
|
||||
# would take a name list -- the second copy of the exposure rule, free to drift, which this
|
||||
# package's `git archive HEAD` shape exists to forbid. A structural gate that catches the one
|
||||
# recoverable-by-anyone coordinate is worth more than a name list nobody prunes.
|
||||
#
|
||||
# And it is DELAYED BY ONE COMMIT: `git archive HEAD` reads HEAD, not the working tree, so a
|
||||
# coordinate added in an uncommitted edit is invisible here until it is committed (funn 35,
|
||||
# already true of every other assertion in this file).
|
||||
_GUID_ZERO = "-".join(("0" * 8, "0" * 4, "0" * 4, "0" * 4, "0" * 12))
|
||||
|
||||
_SECRET_CONTENT_PATTERNS: tuple[tuple[str, re.Pattern[str], str], ...] = (
|
||||
(
|
||||
# A tenant's own Foundry/OpenAI host. The label must START with an alphanumeric,
|
||||
# which is what keeps the repo's placeholder (`<resource>.`) and wildcard
|
||||
# (`*.services.ai.azure.com`) forms out: neither `>` nor `*` is a label character.
|
||||
"azure-ai-host",
|
||||
re.compile(
|
||||
r"[A-Za-z0-9][A-Za-z0-9-]*\.(?:services\.ai|openai|cognitiveservices)\.azure\.com"
|
||||
),
|
||||
"https://" + "sample" + ".services.ai.azure.com/api/projects/p",
|
||||
),
|
||||
(
|
||||
# Subscription id in its structural context. A bare GUID is NOT a coordinate --
|
||||
# `53ca6127-db72-4b80-b1b0-d745d6d5456d` is Azure's PUBLIC built-in role definition
|
||||
# id for Foundry User, identical in every tenant, and it is quoted in DEPLOY.md.
|
||||
"arm-subscription-scope",
|
||||
re.compile(r"/subscriptions/[0-9a-fA-F]{8}-[0-9a-fA-F-]{27}"),
|
||||
"/subscriptions/" + _GUID_ZERO + "/resourceGroups/rg",
|
||||
),
|
||||
(
|
||||
# An absolute path into somebody's home directory: a machine layout, and usually a
|
||||
# username with it. Zero on HEAD since 6d2837f; six lines in the delivered 14:24 zip.
|
||||
"absolute-home-path",
|
||||
re.compile(r"/(?:Users|home)/[A-Za-z0-9._-]+/"),
|
||||
"/Users/" + "someone" + "/repos/thing",
|
||||
),
|
||||
(
|
||||
"bearer-jwt",
|
||||
re.compile(r"ey" + r"J[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}"),
|
||||
"ey" + "J" + "abcdefghij.klmnopqrst.uvwxyz0123",
|
||||
),
|
||||
(
|
||||
"vendor-api-key",
|
||||
re.compile(r"\b(?:sk-[A-Za-z0-9]{20,}|gh[pousr]_[A-Za-z0-9]{20,}|AKIA[0-9A-Z]{16})\b"),
|
||||
"sk-" + "A" * 24,
|
||||
),
|
||||
(
|
||||
# Foundry/Cognitive Services keys are 32 hex characters. Git hashes are 7 or 40, and
|
||||
# a 32-run inside a 40-char hash is excluded by the boundary look-arounds.
|
||||
"cognitive-services-key",
|
||||
re.compile(r"(?<![0-9a-fA-F])[0-9a-fA-F]{32}(?![0-9a-fA-F])"),
|
||||
"a" * 32,
|
||||
),
|
||||
(
|
||||
"private-key-block",
|
||||
re.compile(r"-----BEGIN (?:RSA |EC |OPENSSH )?" + "PRIVATE KEY-----"),
|
||||
"-----BEGIN " + "PRIVATE KEY-----",
|
||||
),
|
||||
)
|
||||
|
||||
# Matched text that is deliberately NOT a coordinate. Every entry needs a reason, and every
|
||||
# entry is checked for being STILL REACHED (``test_accepted_content_literals_stay_minimal``)
|
||||
# -- an allowlist nobody prunes is the second copy of the exposure rule, free to drift, which
|
||||
# is the very thing this package's "git archive HEAD" shape exists to forbid.
|
||||
_ACCEPTED_CONTENT_LITERALS: tuple[tuple[str, str], ...] = (
|
||||
("x.services.ai.azure.com", "preflight/backends test dummy: one-letter label, not a resource"),
|
||||
("platform.services.ai.azure.com", "hosted-backend test dummy for the platform-injected value"),
|
||||
("x.openai.azure.com", "preflight test dummy for the WRONG Azure surface"),
|
||||
("wrong.openai.azure.com", "preflight test dummy for the WRONG Azure surface"),
|
||||
)
|
||||
|
||||
# The commit the external organisation actually received (the 14:24 build of 14.08). It is the
|
||||
# known-positive control on REAL data: the scanner has to find what did leak, not only what a
|
||||
# synthetic sample can be made to contain.
|
||||
_LEAKED_COMMIT = "77076b9"
|
||||
|
||||
|
||||
def _scan_text(text: str) -> list[tuple[str, str]]:
|
||||
"""Return ``(pattern label, matched text)`` for everything in ``text`` that is not on the
|
||||
accepted list. Detection only -- nothing is rewritten, nothing is removed."""
|
||||
findings: list[tuple[str, str]] = []
|
||||
for label, pattern, _sample in _SECRET_CONTENT_PATTERNS:
|
||||
for match in pattern.finditer(text):
|
||||
hit = match.group(0)
|
||||
if any(hit in accepted for accepted, _why in _ACCEPTED_CONTENT_LITERALS):
|
||||
continue
|
||||
findings.append((label, hit))
|
||||
return findings
|
||||
|
||||
|
||||
def _scan_archive(archive: zipfile.ZipFile) -> tuple[list[str], int, int]:
|
||||
"""Scan every member of ``archive``. Returns ``(findings, files read, files skipped)``.
|
||||
|
||||
The denominator is returned, not discarded: "nothing found" over an unknown number of files
|
||||
is not zero, it is unmeasured."""
|
||||
findings: list[str] = []
|
||||
read = skipped = 0
|
||||
for name in archive.namelist():
|
||||
if name.endswith("/"):
|
||||
continue
|
||||
try:
|
||||
text = archive.read(name).decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
skipped += 1
|
||||
continue
|
||||
read += 1
|
||||
findings.extend(f"{name}: {label}: {hit}" for label, hit in _scan_text(text))
|
||||
return findings, read, skipped
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def leaked_package(tmp_path_factory: pytest.TempPathFactory) -> zipfile.ZipFile:
|
||||
"""The archive as it was at the commit that reached an external organisation."""
|
||||
dest = tmp_path_factory.mktemp("leaked") / "leaked.zip"
|
||||
subprocess.run(
|
||||
["git", "archive", "--format=zip", "--output", str(dest), _LEAKED_COMMIT],
|
||||
cwd=_REPO_ROOT,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
return zipfile.ZipFile(dest)
|
||||
|
||||
|
||||
def test_every_content_pattern_can_find(package: zipfile.ZipFile) -> None:
|
||||
"""Control: each row of the pattern list is proven able to match before any of them is
|
||||
trusted to report nothing. A pattern that silently matches nothing makes the gate below
|
||||
green forever -- this repo's recurring vacuity class."""
|
||||
for label, pattern, sample in _SECRET_CONTENT_PATTERNS:
|
||||
assert pattern.search(sample), f"pattern {label!r} does not match its own sample"
|
||||
|
||||
# And the samples must not be literal in this file, or the gate would be red on itself and
|
||||
# the only fix would be excluding the file that defines the patterns.
|
||||
own_source = package.read("tests/test_handover_package_loadbearing.py").decode("utf-8")
|
||||
assert not _scan_text(own_source), "the pattern samples are literal in this file's source"
|
||||
|
||||
|
||||
def test_content_scan_finds_what_actually_leaked(leaked_package: zipfile.ZipFile) -> None:
|
||||
"""Control on REAL data: point the scanner at the tree the external organisation received
|
||||
and it must report the Foundry coordinates and the absolute home paths. Without this the
|
||||
gate below would only prove that HEAD is clean, never that the scanner can see."""
|
||||
findings, read, _skipped = _scan_archive(leaked_package)
|
||||
assert read > 300, f"only {read} members read from the leaked archive -- control is vacuous"
|
||||
|
||||
labels = {finding.split(": ")[1] for finding in findings}
|
||||
assert "azure-ai-host" in labels, f"the leaked Foundry host was not found; got {findings}"
|
||||
assert "absolute-home-path" in labels, f"the leaked home paths were not found; got {findings}"
|
||||
|
||||
|
||||
def test_package_leaks_no_secret_content(package: zipfile.ZipFile) -> None:
|
||||
"""The gate. Detach point: put a tenant coordinate, an ARM subscription scope, an absolute
|
||||
home path or a credential back into any tracked file -> RED.
|
||||
|
||||
Refusal, not filtering: what fails here is the TREE, and the fix is to change HEAD."""
|
||||
findings, read, skipped = _scan_archive(package)
|
||||
assert read > 300, f"only {read} members read -- this gate would be reporting on nothing"
|
||||
assert skipped <= 1, f"unexpectedly many undecodable members ({skipped}) went unscanned"
|
||||
assert not findings, (
|
||||
f"the handover package leaks secret content ({read} members read, {skipped} skipped): "
|
||||
f"{findings}"
|
||||
)
|
||||
|
||||
|
||||
def test_accepted_content_literals_stay_minimal(package: zipfile.ZipFile) -> None:
|
||||
"""Every accepted literal must still be reachable in the archive. An entry that matches
|
||||
nothing is a standing exemption for a string the repo no longer has -- the second copy of
|
||||
the exposure rule, quietly drifting away from what is actually shipped."""
|
||||
corpus = "\n".join(
|
||||
archive_text
|
||||
for name in package.namelist()
|
||||
if not name.endswith("/")
|
||||
for archive_text in _decoded(package, name)
|
||||
)
|
||||
stale = [literal for literal, _why in _ACCEPTED_CONTENT_LITERALS if literal not in corpus]
|
||||
assert not stale, f"accepted content literals no longer occur in the package: {stale}"
|
||||
|
||||
|
||||
def _decoded(archive: zipfile.ZipFile, name: str) -> list[str]:
|
||||
try:
|
||||
return [archive.read(name).decode("utf-8")]
|
||||
except UnicodeDecodeError:
|
||||
return []
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue