Skip to content
Merged
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
3 changes: 3 additions & 0 deletions compose.podman.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ services:
- TEMPORAL_TARGET=${TEMPORAL_TARGET:-host.containers.internal:7233}
- TEMPORAL_NAMESPACE=${TEMPORAL_NAMESPACE:-scan-platform}
- TEMPORAL_TLS_ENABLED=${TEMPORAL_TLS_ENABLED:-false}
- MP_JWT_VERIFY_PUBLICKEY_LOCATION=${MP_JWT_VERIFY_PUBLICKEY_LOCATION:-}
Comment thread
hoggmania marked this conversation as resolved.
Resolved
- MP_JWT_VERIFY_ISSUER=${MP_JWT_VERIFY_ISSUER:-https://autotriage.local}
ports:
- "8080:8080"

Expand All @@ -30,6 +32,7 @@ services:
- SUPPRESSION_SERVICE_URL=${SUPPRESSION_SERVICE_URL:-http://suppression-service-mock:8090}
- ARTIFACTS_DIR=/artifacts
- GIT_CLONE_TOKEN
- GIT_CLONE_ALLOWED_HOSTS=${GIT_CLONE_ALLOWED_HOSTS:-github.com}
volumes:
- artifacts:/artifacts
depends_on:
Expand Down
6 changes: 6 additions & 0 deletions develope.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,12 @@ mvn -B verify
- `TEMPORAL_TLS_ENABLED`
- `ARTIFACTS_DIR`
- `GIT_CLONE_TOKEN`
- `GIT_CLONE_ALLOWED_HOSTS`: comma-separated hosts the light worker may clone from (default `github.com`). Only `https://` and `ssh://`/`git@host:` locations on these hosts are accepted, so the clone token is never sent elsewhere.

### Scan API

- `MP_JWT_VERIFY_PUBLICKEY_LOCATION` or `MP_JWT_VERIFY_PUBLICKEY`, and `MP_JWT_VERIFY_ISSUER`
- `/scans` callers need a JWT whose `role` claim includes `security:scan:submitter`. `/health` stays unauthenticated.

### OpenGrep

Expand Down
1 change: 1 addition & 0 deletions k8s/base/configmap.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ data:
DB_NAME: "autotriage"
TRIAGE_SERVICE_URL: "http://triage-service:8095"
TRIAGE_REPOSITORY_ALLOWED_HOSTS: "github.com"
GIT_CLONE_ALLOWED_HOSTS: "github.com"
MP_JWT_VERIFY_ISSUER: "https://autotriage.local"
ARTIFACT_S3_ENDPOINT: "http://minio:9000"
ARTIFACT_S3_REGION: "us-east-1"
Expand Down
21 changes: 21 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,27 @@
</execution>
</executions>
</plugin>
<plugin>
<!-- Per-module coverage reports land in target/site/jacoco/index.html after `mvn verify`. -->
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.12</version>
<executions>
<execution>
<id>prepare-agent</id>
<goals>
<goal>prepare-agent</goal>
</goals>
</execution>
<execution>
<id>report</id>
<phase>verify</phase>
<goals>
<goal>report</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
<pluginManagement>
<plugins>
Expand Down
36 changes: 36 additions & 0 deletions scan-api/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@
<artifactId>quarkus-smallrye-health</artifactId>
<version>${quarkus.version}</version>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-smallrye-jwt</artifactId>
<version>${quarkus.version}</version>
</dependency>
<dependency>
<groupId>io.temporal</groupId>
<artifactId>temporal-sdk</artifactId>
Expand All @@ -39,6 +44,29 @@
<artifactId>scan-common</artifactId>
<version>${project.parent.version}</version>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-junit5</artifactId>
<version>${quarkus.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-junit5-mockito</artifactId>
<version>${quarkus.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.rest-assured</groupId>
<artifactId>rest-assured</artifactId>
<version>5.4.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.smallrye</groupId>
<artifactId>smallrye-jwt-build</artifactId>
<scope>test</scope>
</dependency>
</dependencies>

<build>
Expand All @@ -58,6 +86,14 @@
</execution>
</executions>
</plugin>
<plugin>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<systemPropertyVariables>
<net.bytebuddy.experimental>true</net.bytebuddy.experimental>
</systemPropertyVariables>
</configuration>
</plugin>
</plugins>
</build>
</project>
4 changes: 4 additions & 0 deletions scan-api/src/main/java/com/autotriage/api/ScanResource.java
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import io.temporal.client.WorkflowExecutionAlreadyStarted;
import io.temporal.client.WorkflowNotFoundException;
import io.temporal.client.WorkflowOptions;
import jakarta.annotation.security.RolesAllowed;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
Expand All @@ -23,8 +24,11 @@
@Path("/scans")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
@RolesAllowed(ScanResource.SUBMITTER_ROLE)
public class ScanResource {

/** Scan requests make workers clone with the platform token, so only trusted callers may submit them. */
public static final String SUBMITTER_ROLE = "security:scan:submitter";
private static final Logger log = Logger.getLogger(ScanResource.class);
private static final String TASK_QUEUE = "scan-workflows";

Expand Down
5 changes: 5 additions & 0 deletions scan-api/src/main/resources/application.properties
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,8 @@ temporal.tls.enabled=false
temporal.tls.client-cert-path=
temporal.tls.client-key-path=
temporal.tls.server-ca-path=

smallrye.jwt.path.groups=role
# /scans requires a JWT with role security:scan:submitter; /health stays open for probes.
# Configure MP_JWT_VERIFY_PUBLICKEY_LOCATION (file/classpath/URL) or MP_JWT_VERIFY_PUBLICKEY (PEM text).
mp.jwt.verify.issuer=${MP_JWT_VERIFY_ISSUER:https://autotriage.local}
Comment thread
hoggmania marked this conversation as resolved.
Resolved
137 changes: 137 additions & 0 deletions scan-api/src/test/java/com/autotriage/api/ScanResourceAuthTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
package com.autotriage.api;

import com.autotriage.common.model.ScanStatus;
import com.autotriage.common.workflow.OpenGrepPRScanWorkflow;
import io.quarkus.test.junit.mockito.InjectMock;
import io.quarkus.test.junit.QuarkusTest;
import io.smallrye.jwt.build.Jwt;
import io.temporal.client.WorkflowClient;
import io.temporal.client.WorkflowOptions;
import org.junit.jupiter.api.Test;

import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.security.KeyFactory;
import java.security.KeyPairGenerator;
import java.security.PrivateKey;
import java.security.spec.PKCS8EncodedKeySpec;
import java.util.Base64;
import java.util.List;
import java.util.Set;

import static io.restassured.RestAssured.given;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

/** Regression tests for the unauthenticated scan API: every /scans operation now requires the submitter role. */
@QuarkusTest
class ScanResourceAuthTest {

private static final String ISSUER = "https://autotriage.test";
private static final String SCAN_BODY = "{\"runId\":\"run-1\",\"repository\":\"https://github.com/example/repo.git\","
+ "\"commitSha\":\"0123456789abcdef0123456789abcdef01234567\"}";

@InjectMock
WorkflowClient workflowClient;

@Test
void rejectsScanSubmissionWithoutToken() {
given().contentType("application/json").body(SCAN_BODY)
.when().post("/scans")
.then().statusCode(401);

verify(workflowClient, never()).newWorkflowStub(eq(OpenGrepPRScanWorkflow.class),
any(WorkflowOptions.class));
}

@Test
void rejectsStatusAndCancelWithoutToken() {
given().when().get("/scans/run-1").then().statusCode(401);
given().contentType("application/json").when().post("/scans/run-1/cancel").then().statusCode(401);

verify(workflowClient, never()).newWorkflowStub(eq(OpenGrepPRScanWorkflow.class), anyString());
}

@Test
void rejectsTokenWithoutSubmitterRole() {
String token = buildToken("security:vuln_assessor:triager");

given().auth().oauth2(token).contentType("application/json").body(SCAN_BODY)
.when().post("/scans")
.then().statusCode(403);
given().auth().oauth2(token).when().get("/scans/run-1").then().statusCode(403);
given().auth().oauth2(token).contentType("application/json")
.when().post("/scans/run-1/cancel")
.then().statusCode(403);
}

@Test
void rejectsTokenFromUntrustedSigner() throws Exception {
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
generator.initialize(2048);
String forged = Jwt.issuer(ISSUER).upn("attacker")
.groups(Set.of(ScanResource.SUBMITTER_ROLE))
.claim("role", List.of(ScanResource.SUBMITTER_ROLE))
.sign(generator.generateKeyPair().getPrivate());

given().auth().oauth2(forged).when().get("/scans/run-1").then().statusCode(401);
}

@Test
void allowsStatusQueryWithSubmitterRole() {
OpenGrepPRScanWorkflow workflow = mock(OpenGrepPRScanWorkflow.class);
when(workflowClient.newWorkflowStub(OpenGrepPRScanWorkflow.class, "opengrep-scan-run-1")).thenReturn(workflow);
when(workflow.queryStatus("run-1")).thenReturn(ScanStatus.running("run-1"));

given().auth().oauth2(buildToken(ScanResource.SUBMITTER_ROLE))
.when().get("/scans/run-1")
.then().statusCode(200);
}

@Test
void submitterReachesRequestValidation() {
// An empty body fails validation before Temporal is touched, which proves the role check passed.
given().auth().oauth2(buildToken(ScanResource.SUBMITTER_ROLE)).contentType("application/json").body("{}")
.when().post("/scans")
.then().statusCode(400);
}

@Test
void healthStaysOpenForProbes() {
given().when().get("/health").then().statusCode(200);
}

private static String buildToken(String role) {
// smallrye.jwt.path.groups=role reads groups from the "role" claim; set both so the test
// does not depend on which claim the deployment is configured to read.
return Jwt.issuer(ISSUER)
.upn("tester")
.groups(Set.of(role))
.claim("role", List.of(role))
.sign(readPrivateKey());
}

private static PrivateKey readPrivateKey() {
try (InputStream input = Thread.currentThread()
.getContextClassLoader()
.getResourceAsStream("jwt-private-key.pem")) {
if (input == null) {
throw new IllegalStateException("jwt-private-key.pem not found in test resources");
}
String pem = new String(input.readAllBytes(), StandardCharsets.US_ASCII);
String normalized = pem
.replace("-----BEGIN PRIVATE KEY-----", "")
.replace("-----END PRIVATE KEY-----", "")
.replaceAll("\\s", "");
byte[] decoded = Base64.getDecoder().decode(normalized);
return KeyFactory.getInstance("RSA").generatePrivate(new PKCS8EncodedKeySpec(decoded));
} catch (Exception e) {
throw new IllegalStateException("Failed to load JWT private key", e);
}
}
}
7 changes: 7 additions & 0 deletions scan-api/src/test/resources/application.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
mp.jwt.verify.publickey.location=jwt-public-key.pem
mp.jwt.verify.issuer=https://autotriage.test
smallrye.jwt.path.groups=role
# Tests replace the WorkflowClient with a mock; these only satisfy the producer's config injection.
temporal.target=localhost:7233
temporal.namespace=scan-platform
temporal.tls.enabled=false
24 changes: 24 additions & 0 deletions scan-api/src/test/resources/jwt-private-key.pem
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCsYQmlFD+KPJ/Tva7nUGvgdQ0w
NrTBv3Nf0qU7aXOV/Zt3ZnUu/tuDAuGevz2MrOqQo9YcEwnNe31BB17nVrfMo0ViyrEn/n/nWqa3
MzUT8v6cxUAMyoKGm+m8l7ps2LtWHDU5mKd1JLJmORy9Dvwi+hoT0B+H95ZiCxr853n7Z9u9YQwp
LkF89uxioMTZLEGRfnZT0Num335Svl0FlpHfQn6CV4KuyuB4dD1nftXpR+VjqF43eIgajbgC7KJT
Kntz3aO4zPAJ9evw3T/5PhOvp5ypLkZlr5hUna72Tdnm2cRRqduKkx7sKbJ0LksvrZM4OZXOBEeJ
VO0m+GjBy+bhAgMBAAECggEADCgz0+6fpJIt+3LvOOUVFplqurHBAcEM0/85I/2F51ghdMRHE29p
781uOBHot1kimdzUJtq44EMLTGDGsq4M/GOvaw48fEOYpaphA56a6bNDfa7zm3NUzUWFsIVK4o5O
d/45SryXYs2Zv1fbzQGXiqf02tzcvGwbsQJzfs6cbbyBwFDPhdcvAgwgTlwAzyqa/3ifCc8OImIz
glOTTPvpvv0/E+KMTYcZ/4cf+QGdQJMdy9H1+FLyw9k/i4PtxwjunBEacKIDuBnv2hVOLH+K7NPe
qGSB92Fiad8rlPhZ9eQSUGYNeDxj87GgohXv75/PcaWxNms6mLBVbbe3zSE+GQKBgQDMM0RCyWkJ
/ePcSS/Tny7EsWzDQNyjVG732mkpUg3ZDL7Nq0z0yt/LZtM965Jx3x9m1jV0SDqScmLSRWh0qw5A
+YjZxO/LIqflrBBtHDAuHJNPgtm4ooiRWCWCsldWQyjzbnknvtWhvFqiALf3ejdpVDIZ87gqlqj/
jgJ8iQqs6wKBgQDYG06IiPB7jfdZhBmWU5yNC0rUucZ5J/6LUJ2O8tRzicqVMclMMmExuYA8g3qc
adjTr7ER6ItxA/+6m1DtzBKcWOSoLlPf/s7bnpL8MXruTiItO2x2EBDiQBChA5tLkvZCoHvQlM3D
MQz4GLTNeW+RmxOfEqg4pWkDhyxW9h8YYwKBgFdK0zN5xl1vG6lf4AInWmbbMhOF9swbnBR4WdxA
J4tR1380a4F5+JGLM2KVAdWL/HCJwfAjsGZKX9Rjt3OobD96bPYrKhc8iWm1VJBQLIiJzq9+0HQN
hzQa6m4krxFBRJBL+Z70BX9OdbArfnlc1bNBOum9Nk1+emBE5Os36khhAoGBAJbs6zn7uUfw7BB+
a4hLOIhgnJBYdj5jKiiN0pvlB27xiqQLszQd8zdRADhC8ryOR7n/d8C0Eb6KNm1Sz4BkB5NdmcPD
JxzGzh0fcDPlMp9/veqtF6CUs9XvPC3XYPu01XvOrOoNIm7xsMcMKileaAbesSNlWkrFasPmAZdy
KGFVAoGAd+OKgBPeO5TqVPQSyuGIM+qqkL6hMC0ds11LUT9t+MwiN0zWCwnXHsN4SeipJgQEcEoB
Q8gJuOaR1zN9zXnpr+mcMTlruILPHpreKI/sLZT/MUqAaggTMPE36bttpZJ/hjwdtJLVC4Bd7dap
CC6MU/NRLkUCWVGW2N6YlleaPo4=
-----END PRIVATE KEY-----
8 changes: 8 additions & 0 deletions scan-api/src/test/resources/jwt-public-key.pem
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEArGEJpRQ/ijyf072u51Br4HUNMDa0wb9z
X9KlO2lzlf2bd2Z1Lv7bgwLhnr89jKzqkKPWHBMJzXt9QQde51a3zKNFYsqxJ/5/51qmtzM1E/L+
nMVADMqChpvpvJe6bNi7Vhw1OZindSSyZjkcvQ78IvoaE9Afh/eWYgsa/Od5+2fbvWEMKS5BfPbs
YqDE2SxBkX52U9Dbpt9+Ur5dBZaR30J+gleCrsrgeHQ9Z37V6UflY6heN3iIGo24AuyiUyp7c92j
uMzwCfXr8N0/+T4Tr6ecqS5GZa+YVJ2u9k3Z5tnEUanbipMe7CmydC5LL62TODmVzgRHiVTtJvho
wcvm4QIDAQAB
-----END PUBLIC KEY-----
Loading
Loading