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 archive 77076b9` 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 - 241b50d og 6d2837f
lukket 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:
Kjell Tore Guttormsen 2026-08-18 13:42:02 +02:00
commit 4cf8c4f6ba
2 changed files with 404 additions and 0 deletions

View 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`

View file

@ -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 []