0pt had no SBOM/sign/digest chain at all (re-confirmed live before this
change: no cosign/syft/sbom hit anywhere under .gitea/workflows/, only doc
mentions in CHANGELOG.md/docs/architecture/ARCHITECTURE.md). Applies the
same two-part pattern already proven on sax3l/cl0ud and sax3l/st0re
(fix/wire-signed-digest-to-deploy, 2026-09-14):
1. .gitea/workflows/release.yml (new): builds the image, pushes it to
git.cloud.siax.io/sax3l/0pt:sha-<12>, SBOMs it (syft), signs it (cosign,
key-based), verifies the signature in the same run, and commits
source-bundle/production-evidence records back to the repo.
2. .gitea/workflows/cd.yml: the `deploy` job now polls the registry for
this exact commit's release-chain image and cosign-verifies it (against
the committed cosign.pub) before calling Coolify's deploy endpoint --
fails closed (10 min timeout) if release.yml never produced a verifiable
artifact for this sha. Added AFTER the existing guard step, which still
fails closed first on 0pt's real, pre-existing blocker: no Coolify
Application is provisioned for 0pt yet (COOLIFY_APP_ID is a literal
placeholder). This gate is wired ahead of that so it's already in place
the day the app is provisioned, instead of a second follow-up change.
Honest limitation carried over from cl0ud/st0re/h0st: once provisioned,
0pt's Coolify Application is expected to use the `dockerfile` build pack
(per siax.config.json), so Coolify would still perform its own independent
docker build rather than pulling the signed image, and /api/v1/deploy has
no digest parameter to pin against -- true digest-pinning needs a
production Coolify resource-type decision, out of CI-YAML scope. This gate
proves a signed, verified build of the exact commit exists before
authorizing any deploy call; it does not prove byte-identical output from
Coolify's own rebuild.
cosign.pub is a fresh, 0pt-specific keypair generated offline for this
change. NOT YET SET on sax3l/0pt (release.yml/cd.yml's gate will fail
closed until present): REGISTRY_USER, REGISTRY_TOKEN, COSIGN_PRIVATE_KEY,
COSIGN_PASSWORD, SIAX_CI_TOKEN repo secrets.
Validated without live Coolify/registry access: YAML-parsed all 2 files,
bash -n syntax-checked all 19 embedded run: blocks, and dry-ran the actual
verification logic -- the RepoDigests filter and bounded poll-retry loop
against mocked scenarios, plus a real cosign sign+verify round-trip
(offline signing-config, local throwaway registry container) proving the
gate's cosign verify command genuinely passes on the correct key/digest and
fails closed on a wrong key and on an unsigned image.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
connection_config.sqlEndpoint on POST /api/0pt/sources/{sourceId}/query
was passed straight from the request body to the mssql TDS driver as the
outbound connection target, with only its http(s):// scheme stripped —
no hostname allowlist or private/loopback/link-local/cloud-metadata
guard. This route already requires a Zitadel bearer token + tenant match
(P014 tenant guard), so this was not anonymously exploitable, but it is
the exact SSRF shape found and exploited-in-principle elsewhere in this
estate tonight (press0), so it's worth closing as defense-in-depth.
Adds src/lib/security/ssrf-guard.ts: resolves the endpoint's hostname
and rejects any address in a private/loopback/link-local/cloud-metadata/
unspecified range (RFC1918, 127.0.0.0/8, 169.254.0.0/16 incl. the
169.254.169.254 metadata address, ::1, fc00::/7, fe80::/10, IPv4-mapped
IPv6), failing closed on DNS resolution errors. The range-check logic is
ported 1:1 from press0's src/lib/security/ssrf-guard.ts built the same
night for the same bug class against a fetch()-based URL there; this
module's surface differs because sqlEndpoint is a bare host[:port], not
a fetchable URL. The DNS lookup is injectable (mirrors this adapter's
existing fetchImpl pattern) so tests are hermetic.
FabricOneLakeAdapter.getPool() now calls the guard before loadDriver()/
getToken()/opening the TDS pool, so a blocked target never reaches a
real outbound connection attempt or spends an AAD token exchange.
Tests: new tests/p0/fabric-onelake-ssrf-guard.test.ts unit-tests the
guard module directly (range boundaries, IPv4-mapped-IPv6 bypass
attempt, fail-closed on DNS failure/empty answer). New adapter-level
tests in tests/p0/fabric-onelake-adapter.test.ts prove (a) a
private-IP-resolving sqlEndpoint is rejected before fetch/connect is
ever called, (b) a literal loopback sqlEndpoint is rejected the same
way, and (c) a normal external Fabric hostname still passes the guard
and completes the query — the guard doesn't false-block legitimate
targets. Existing adapter tests are updated to inject a deterministic
DNS resolver so they stay hermetic instead of depending on real DNS.
Full suite: 997 passed, 0 failed, 184 skipped (self-skipping realinfra
tests with no live backend this run). typecheck and lint clean on all
touched files.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Per siax-standard conformance sweep: repo lacked the masterplan-lock CI guard
(.gitea/workflows/masterplan-lock.yml + scripts/check-masterplan-lock.sh) and a
repo-local PLAN/MASTERPLAN_INDEX.md. Copied from sax3l/st0re template (generic
POSIX sh check), stub PLAN index with locked architecture principles and
pointer to sax3l/siax-masterplan.
Refs: sax3l/siax-standard (conformance sweep 2026-09-10)
The `resolveStoreMode` kill switch (Master-DoD §3/§10) covered the newer
`src/repositories/*` stores but not two older paths, both of which are
live production execution paths today.
1. LEGACY NEXT.JS STORES. `lib/orders-store.ts` and
`lib/subscriptions-store.ts` predate `src/platform/dependency-mode.ts`
and called `getSql()` directly, silently falling back to module-global
`Map`s when no database is configured. For orders, payments and
subscriptions that is not a degraded mode, it is invisible loss of
money-bearing state: the API returns 201 and the row evaporates on the
next restart or on any other replica. The live 0pt deployment
(Coolify app 54) runs NODE_ENV=production with DATABASE_URL,
POSTGRES_URL and POSTGRES_PRISMA_URL all unset — so this is the
behavior in production right now, across 7 API routes.
These now go through `getSqlForDurableStore()`, which reuses the SAME
`resolveStoreMode` decision every `src/repositories/*` store already
uses rather than re-implementing a second production check. The reuse
is load-bearing: `isProductionRuntime` deliberately exempts
`NEXT_PHASE=phase-production-build`, because `next build` runs
page-data collection under NODE_ENV=production while serving nothing.
A naive `process.env.NODE_ENV === "production"` test here would have
failed the BUILD instead of protecting a request path.
2. LOCALBLOBSTORE. It writes document bytes to a container-local disk
while `document-capture-service` registers an artifact pointer
claiming the bytes are stored — durable object storage silently
swapped for ephemeral disk. It is the default blob store for both the
capture and extraction services. In production it now raises
DEPENDENCY_UNAVAILABLE (capability blocked: no ST0RE bytes path
exists yet) instead of fabricating success. Asserted per-operation,
not in the constructor, so merely wiring the services is not fatal —
only actually persisting or reading bytes is.
Evidence: full suite 956 passed / 184 skipped, typecheck clean,
dependency-cruiser 0 errors, lint identical to main's pre-existing
baseline (17 problems, none in changed files). Both guards asserted in
both directions and mutation-tested: bypassing resolveStoreMode -> 2
failed; dropping the write assert -> 2 failed; dropping the read assert
-> 1 failed; all restored -> 8 passed. 7 non-test call sites.
DEPLOYMENT NOTE: merging changes no runtime (no Gitea webhook exists on
any repo; deploys are manual). Deploying this build REQUIRES
provisioning DATABASE_URL for 0pt first, or the order/subscription
routes will correctly return DEPENDENCY_UNAVAILABLE instead of silently
accepting orders into memory. That provisioning is a Simon action.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
services/api/workers/{resolve,quality_bridge}.py anropar båda rutterna med
X-Internal-Token sedan langt tillbaka; rutterna (app/api/0pt/internal/er/
candidates + app/api/0pt/quality/bridge-run) har alltid existerat men
kontraktet shipade dem aldrig. Wire ar sanning — kontraktet matchar nu:
- POST /api/0pt/internal/er/candidates (full request/response-schema ur
src/er/request-validation.ts + entity-resolution-service.ts)
- POST /api/0pt/quality/bridge-run (flyttad fran ops-losen deklaration till
internal-gruppen med internalToken-security)
- components.securitySchemes.internalToken (X-Internal-Token header,
verifyInternalServiceToken)
The guard grepped vitest's HUMAN output:
grep -qE "skipped" && ! grep -qE "Tests [0-9]+ passed"
vitest colours that summary. The real line emitted on these runners is
ESC[2m Tests ESC[22m ESC[1mESC[32m97 passedESC[39m | ...
so ANSI escapes sit between "Tests " and the digits and the
two-spaces-then-digits pattern can never match. The first grep still
matched "skipped", so the condition was true whenever a single test
skipped -- no matter how many passed.
Measured on run 13911 job 31183 (this branch, 2026-09-08): 13 files /
97 tests PASSED, 1 file / 2 tests skipped, and the step still printed
"FATAL: integration suites skipped instead of running" and exited 1.
That message was false; the suites ran.
It survived review because it is correct locally -- redirecting vitest
to a file disables its colour, so the pattern matches on a developer
machine and misfires only under CI.
Replaced with vitest's JSON reporter: structured, colourless, and
fail-closed if the report is missing. A/B measured on real reports in
BOTH directions, so the replacement is not merely green:
all skipped (0 passed / 99 pending) -> exit 1 (protection kept)
945 passed + 184 skipped -> exit 0 (false red gone)
report file absent -> exit 1 (fail closed)
The anti-skip guarantee the original guard was written for is preserved:
a run where every suite skips still fails.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Branch protection on `main` requires exactly two status contexts:
CI (SIAX Cloud) / security (push)
CI (SIAX Cloud) / quality (push)
The `security` half could not fail. All three Trivy invocations in this
repo ran with `--exit-code 0`, so the scanner reported findings to stdout
and then exited 0 regardless.
Measured on main @4514d97, 2026-09-08, same scanner (Trivy 0.73.0), same
target, only the flag differing:
A --exit-code 0, clean tree -> exit 0
B --exit-code 0, planted AWS secret access key -> exit 0 (DETECTED
as CRITICAL in the report, gate still green)
C --exit-code 1, same planted secret -> exit 1
D --exit-code 1, probe removed -> exit 0
B is the defect: the required context reported success while the scanner
had a CRITICAL secret in hand. D is why this is safe to land now -- the
tree is clean today, so flipping the flag does not open the gate red.
(PR #19 measured 19 HIGH findings on 2026-08-11; those dependencies have
since been updated on main, 142 commits later.)
The other required context, `quality`, was verified real and left alone:
a planted type error fails `pnpm run typecheck` (exit 2) and a planted
failing assertion fails `pnpm run test` (exit 1, 945 passed baseline).
nightly.yml carried the same flag. A nightly scan exists to catch CVEs
published against code that did not change; with `--exit-code 0` it could
never raise that alarm.
Not fixed here, needs an owner decision: services/scrapers/requirements.txt
uses `>=` ranges only. Trivy's pip scanner cannot resolve unpinned
specifiers, so it omits the file from the scan entirely -- it never appears
as a target. Pinning those same floors with `==` surfaces 3 HIGH findings
(aiohttp CVE-2025-69223, aiohttp CVE-2026-69244, lxml CVE-2026-41066).
Deliberately NOT suppressed with `--skip-dirs`, which would turn a
measurable blind spot into declared policy.
Job names are unchanged, so both required context strings are preserved.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ny blockerande pytest-gate + Postgres/OpenSearch/ClickHouse-härdningen i
ci.yml ur PR #10 berör .gitea/workflows/ (estate-skydd). De nya real-infra-
testerna är exekverade lokalt (Postgres 5/5; OpenSearch/CH-gate behåller
main:s konfig tills beslut) — hålls utanför merge så koden landar.
_apply_matches ignorerar medvetet decided_by=score (defence in depth:
score/fuzzy får aldrig skapa canonical identity-länkar) — testernas
stub-bridge returnerade score-pairs och förväntade ändå merge → CI-fail var
implementationen som hade rätt. Pairs är nu decided_by=stable-identifiers.v3.
Verifierat: pytest services/api/tests 6+14 pass, full unit 945/945,
typecheck EXIT 0, real-Postgres 5/5.
CI-härdningen ur PR #10 (Trivy HIGH/CRITICAL blocking, Postgres-service i
integration, python-engine pytest-gate) berör .gitea/workflows/ och kräver
estate-policy-beslut — dokumenterad separat, hålls UTANFÖR denna merge så
koden kanoniseras utan blockering. Härdningens nya tester är exekverade
lokalt: real-Postgres 5/5 (DATABASE_URL-aktiverad outbox).
Both public 0PT hosts are dead as of 2026-09-07:
$ curl -sSI https://0pt.app/
HTTP/2 402 ... server: Vercel ... x-vercel-error: DEPLOYMENT_DISABLED
(identical on /api/health and /api/ready)
$ curl -sSI https://api.0pt.app/health
HTTP/2 404 ... server: Vercel ... x-vercel-error: DEPLOYMENT_NOT_FOUND
siax.repo.json claimed operationalState "running"; siax.repo.v3.json
claimed "degraded" on the strength of a 2026-09-04 probe where
/api/health still answered 200. Neither holds now, so both files move to
"down" and both domain entries record the probe that motivated it.
Also recorded: both names resolve into Vercel address space
(216.150.16.193 / 216.150.1.1 and 216.150.1.129 / 216.150.16.65), not
the Hetzner fleet.
No DNS, deploy or provider setting was touched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
0pt.app svarar i dag HTTP 402 DEPLOYMENT_DISABLED (Vercels betalningsspärr) och
har ingen självhostad ersättare igång. Det här gör appen faktiskt byggbar och
körbar på Coolify. Referens: sax3l/siax-io, som redan kör självhostat på flottan
— dess Dockerfile återanvänds i form i stället för att uppfinna ett eget mönster.
next.config.mjs:
* output: "standalone" — runtime-imagen kör `node server.js` i stället för att
släpa med pnpm, corepack och hela node_modules-trädet.
* images.unoptimized: true BORTTAGET. Det var v0-generatorns standardvärde, inte
ett val: next.config.mjs var orörd sedan "Initial commit from v0". Självhostat
är rätt lösning att låta Next optimera själv.
* poweredByHeader: false.
Dockerfile (fyra steg, samma form som siax-io):
* node:24-alpine -> node:22-alpine. Båda CI-workflowsen bygger på node 22;
att bygga och köra på samma major som grinden testar tar bort en version-skew.
* libc6-compat (sharp), icke-root `node`, HEALTHCHECK mot /api/health,
npm/npx strippat ur runtime-imagen (SIO-019-mönstret).
* --ignore-workspace borttaget: motiveringen i kommentaren var inaktuell sedan
P1-21 (pnpm-workspace.yaml har ett giltigt packages:-fält och
packages/schemas är en riktig medlem). Containerinstallationen blir därmed
bit för bit samma kommando som CI kör.
* --prod=false: Coolify injicerar NODE_ENV=production vid byggtid, och pnpm
hoppar då över devDependencies som next build kräver.
Sharp levereras i ett eget npm-steg, och det är inte godtyckligt:
next build spårar INTE sharp in i .next/standalone (verifierat: standalone-
utdatans node_modules innehöll bara next/, react/, react-dom/). Den uppenbara
lösningen outputFileTracingIncludes fungerar bevisligen inte — globben
"./node_modules/@img/**/*" får Turbopack att panika på pnpms katalogsymlänkar
("Is a directory (os error 21)") och bygget faller med exit 1. Reproducerat, inte
antaget. npm-steget lägger ett platt, symlänkfritt träd som runtime-steget kan
kopiera rakt av, pinnat till versionen i pnpm-lock.yaml, och faller hårt om
versionen inte går att läsa ut i stället för att tyst skeppa trasig optimering.
BEVISAT, inte påstått (fullständiga exitkoder i PR-beskrivningen):
* pnpm install --frozen-lockfile -> 0; next build med HELT TOM env -> 0
* docker build --no-cache -> 0; container startar, /api/health -> HTTP 200
* /api/ready utan DATABASE_URL -> HTTP 503 (faller stängt, som avsett)
* /api/0pt/* utan Bearer -> HTTP 401 (middleware kör självhostat)
* /_next/image -> HTTP 200 image/avif, 1 488 706 B PNG -> 51 161 B (29x)
* sharp laddar i runtime-imagen (libvips 8.18.6, musl-binärer)
* icke-root uid=1000, HEALTHCHECK "healthy", image 1,33 GB -> 423 MB
* typecheck 0, test 0 (930 passerade). lint/prettier faller identiskt på ren
main => pre-existerande, inte infört här. Ingen grind har rörts.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>