Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/workflows/release-docker-image.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,14 @@ on:

jobs:
Image:
permissions:
contents: write
packages: write
security-events: write
pull-requests: write
id-token: write
attestations: write
artifact-metadata: write
uses: IABTechLab/uid2-shared-actions/.github/workflows/shared-publish-java-to-docker-versioned.yaml@v3
with:
release_type: ${{ inputs.release_type }}
Expand Down
21 changes: 8 additions & 13 deletions .trivyignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,15 @@
# See https://aquasecurity.github.io/trivy/v0.35/docs/vulnerability/examples/filter/
# for more details

# gnutls DoS vulnerability via crafted ClientHello - not impactful as gnutls is not used by our Java service
# See: UID2-6655
CVE-2026-1584 exp:2026-08-27

# jackson-core async parser DoS - not exploitable, services only use synchronous ObjectMapper API
# See: UID2-6670
GHSA-72hv-8253-57qq exp:2026-09-01

# libexpat NULL pointer dereference in Alpine base image - not exploitable, our Java services do not use libexpat
# Fixed in libexpat 2.7.5, not yet available in eclipse-temurin Alpine 3.23 base image
# See: UID2-6806
CVE-2026-32776 exp:2026-04-25

# Trivy reports CVE-2026-32776 with transposed digits (32767 instead of 32776) - this is a known Trivy bug
# See: https://github.com/aquasecurity/trivy/discussions/10412 and UID2-6806
# This entry can be removed once Trivy fixes the typo
CVE-2026-32767 exp:2026-04-25
# CVE-2026-42577 — netty-transport-native-epoll DoS via RST on half-closed TCP connection.
# Advisory: https://github.com/netty/netty/security/advisories/GHSA-rwm7-x88c-3g2p
# Server-side bug; netty maintainers backported the fix only to 4.2.13.Final and we run on
# vert.x 4 / netty 4.1.x. This service sits behind authenticated load balancers (mTLS / API
# gateway) so anonymous external attackers cannot reach the netty epoll socket directly;
# LB-level connection limits and idle timeouts further cap the blast radius. CVSS impact is
# Availability only (C:N/I:N/A:H). Tracking via UID2-7035; revisit on vert.x 5 migration.
CVE-2026-42577 exp:2026-06-08
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ COPY ./target/${JAR_NAME}-${JAR_VERSION}-sources.jar /app
COPY ./conf/default-config.json /app/conf/
COPY ./conf/*.xml /app/conf/

RUN apk add --no-cache --upgrade libpng libcrypto3 libssl3 musl musl-utils && adduser -D uid2-core && mkdir -p /app && chmod 705 -R /app && mkdir -p /app/file-uploads && chmod 777 -R /app/file-uploads && mkdir -p /app/pod_terminating && chmod 777 -R /app/pod_terminating
RUN apk add --no-cache --upgrade libpng libcrypto3 libssl3 musl musl-utils gnutls && adduser -D uid2-core && mkdir -p /app && chmod 705 -R /app && mkdir -p /app/file-uploads && chmod 777 -R /app/file-uploads && mkdir -p /app/pod_terminating && chmod 777 -R /app/pod_terminating
USER uid2-core

CMD java \
Expand Down
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,25 @@ mvn clean compile exec:java -Dvertex-configpath=conf/local-config.json
```
mvn clean compile exec:java -Dvertx-config-path=conf/integ-config.json
```

## Verifying image provenance

Every non-snapshot image published by this repo's release workflow ships with a [SLSA v1.0](https://slsa.dev/spec/v1.0/) build-provenance attestation, signed by GitHub's [Sigstore](https://www.sigstore.dev/) instance via the OIDC identity of the [shared publish workflow](https://github.com/IABTechLab/uid2-shared-actions). The attestation cryptographically binds the image digest to the source commit, the signing workflow, and the runner that built it.

To verify an image, install [`gh`](https://cli.github.com/) (≥ 2.49) and run:

```bash
gh attestation verify oci://ghcr.io/iabtechlab/uid2-core:<tag> --owner IABTechLab --signer-repo IABTechLab/uid2-shared-actions
```

`<tag>` refers to the **Docker image tag** — bare semantic version, no `v` prefix (e.g. `2.30.120`). Note that the corresponding GitHub release and git tag for the same build are named with a `v` (e.g. `v2.30.120`); the registry tag drops it by OCI convention.

**Where to find a tag:**

- **GitHub Packages** for this repo — [`uid2-core` package](https://github.com/IABTechLab/uid2-core/pkgs/container/uid2-core) lists every published image tag and its digest.
- Or take a [release](https://github.com/IABTechLab/uid2-core/releases) name (e.g. `v2.30.120`) and drop the leading `v`.
- To pin to an exact manifest instead of a mutable tag, use the digest form: `oci://ghcr.io/iabtechlab/uid2-core@sha256:<digest>` (visible on the Packages page, or via `gh api /orgs/IABTechLab/packages/container/uid2-core/versions`).

A successful run prints `✓ Verification succeeded!` followed by the SLSA provenance fields — including `sourceRepositoryDigest` (the source commit), `workflow.path` (the signing workflow), and the runner identity.

Snapshot tags (`-SNAPSHOT` suffix) deliberately skip attestation. `gh attestation verify` returns `no attestations found` against a snapshot — that's expected.
4 changes: 2 additions & 2 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

<groupId>com.uid2</groupId>
<artifactId>uid2-core</artifactId>
<version>2.30.107</version>
<version>2.30.108-alpha-183-SNAPSHOT</version>

<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
Expand All @@ -25,7 +25,7 @@
<launcher.class>io.vertx.core.Launcher</launcher.class>

<uid2-shared.version>11.4.16</uid2-shared.version>
<netty.version>4.1.132.Final</netty.version>
<netty.version>4.1.133.Final</netty.version>
<image.version>${project.version}</image.version>
</properties>

Expand Down
35 changes: 33 additions & 2 deletions src/main/java/com/uid2/core/handler/GenericFailureHandler.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,14 @@
import io.vertx.core.Handler;
import io.vertx.core.http.HttpClosedException;
import io.vertx.core.http.HttpServerResponse;
import io.vertx.core.json.JsonObject;
import io.vertx.ext.web.RoutingContext;
import org.apache.http.impl.EnglishReasonPhraseCatalog;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.net.HttpURLConnection;

public class GenericFailureHandler implements Handler<RoutingContext> {
private static final Logger LOGGER = LoggerFactory.getLogger(GenericFailureHandler.class);

Expand Down Expand Up @@ -38,8 +41,36 @@ public void handle(RoutingContext ctx) {
}

if (!response.ended() && !response.closed()) {
response.setStatusCode(statusCode)
.end(EnglishReasonPhraseCatalog.INSTANCE.getReason(statusCode, null));
if (statusCode == HttpURLConnection.HTTP_UNAUTHORIZED) {
// Return the specific reason so the caller (e.g. a private operator at startup)
// gets an actionable message.
response.putHeader("Content-Type", "application/json")
.setStatusCode(statusCode)
.end(buildUnauthorizedBody(profile).encode());
} else {
response.setStatusCode(statusCode)
.end(EnglishReasonPhraseCatalog.INSTANCE.getReason(statusCode, null));
}
}
}

private static JsonObject buildUnauthorizedBody(IAuthorizable profile) {
final String reason;
final String message;
if (profile == null) {
// Key did not resolve to any record - the most common operator-onboarding mistake.
reason = "unrecognized_key";
message = "Operator key not recognized.";
} else if (profile.isDisabled()) {
reason = "key_disabled";
message = "Operator key is recognized but has been disabled.";
} else {
reason = "insufficient_role";
message = "Operator key is recognized but is not authorized for this operation.";
}
return new JsonObject()
.put("status", "unauthorized")
.put("reason", reason)
.put("message", message);
}
}
93 changes: 93 additions & 0 deletions src/test/java/com/uid2/core/handler/GenericFailureHandlerTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package com.uid2.core.handler;

import com.uid2.shared.auth.IAuthorizable;
import com.uid2.shared.middleware.AuthMiddleware;
import io.vertx.core.http.HttpServerResponse;
import io.vertx.core.json.JsonObject;
import io.vertx.ext.web.RoutingContext;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;

import java.net.HttpURLConnection;
import java.util.HashMap;
import java.util.Map;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

class GenericFailureHandlerTest {

// Drives the handler for a given status code + resolved auth profile, returning the response body written.
private static String handleAndCaptureBody(int statusCode, IAuthorizable profile) {
RoutingContext ctx = mock(RoutingContext.class);
HttpServerResponse response = mock(HttpServerResponse.class);

Map<String, Object> data = new HashMap<>();
if (profile != null) {
data.put(AuthMiddleware.API_CLIENT_PROP, profile);
}

when(ctx.statusCode()).thenReturn(statusCode);
when(ctx.response()).thenReturn(response);
when(ctx.normalizedPath()).thenReturn("/attest");
when(ctx.failure()).thenReturn(null);
when(ctx.data()).thenReturn(data);

when(response.ended()).thenReturn(false);
when(response.closed()).thenReturn(false);
when(response.putHeader(anyString(), anyString())).thenReturn(response);
when(response.setStatusCode(anyInt())).thenReturn(response);

new GenericFailureHandler().handle(ctx);

ArgumentCaptor<String> bodyCaptor = ArgumentCaptor.forClass(String.class);
verify(response).end(bodyCaptor.capture());
return bodyCaptor.getValue();
}

@Test
void unknownKeyReturnsUnrecognizedKeyReason() {
// No auth profile resolved -> key was not recognized (the 4eyes.ai transcription-error case).
JsonObject body = new JsonObject(handleAndCaptureBody(HttpURLConnection.HTTP_UNAUTHORIZED, null));

assertEquals("unauthorized", body.getString("status"));
assertEquals("unrecognized_key", body.getString("reason"));
assertTrue(body.getString("message").toLowerCase().contains("not recognized"));
}

@Test
void disabledKeyReturnsKeyDisabledReason() {
IAuthorizable disabledKey = mock(IAuthorizable.class);
when(disabledKey.isDisabled()).thenReturn(true);

JsonObject body = new JsonObject(handleAndCaptureBody(HttpURLConnection.HTTP_UNAUTHORIZED, disabledKey));

assertEquals("unauthorized", body.getString("status"));
assertEquals("key_disabled", body.getString("reason"));
assertTrue(body.getString("message").toLowerCase().contains("disabled"));
}

@Test
void recognizedButUnauthorizedKeyReturnsInsufficientRoleReason() {
IAuthorizable wrongRoleKey = mock(IAuthorizable.class);
when(wrongRoleKey.isDisabled()).thenReturn(false);

JsonObject body = new JsonObject(handleAndCaptureBody(HttpURLConnection.HTTP_UNAUTHORIZED, wrongRoleKey));

assertEquals("unauthorized", body.getString("status"));
assertEquals("insufficient_role", body.getString("reason"));
}

@Test
void nonUnauthorizedStatusKeepsPlainReasonPhrase() {
// Non-401 failures must keep the existing bare reason-phrase body (no behaviour change).
String body = handleAndCaptureBody(HttpURLConnection.HTTP_BAD_REQUEST, null);

assertEquals("Bad Request", body);
}
}