Skip to content

Commit 185d480

Browse files
committed
Persist immutable remote review bindings for scan attempts
1 parent 573f4b1 commit 185d480

5 files changed

Lines changed: 132 additions & 0 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package net.modtale.model.project;
2+
3+
public record RemoteReviewBinding(String projectId, String versionId, String requestId, int attempt,
4+
String filePath, String artifactSha256, String contextSha256, String policyVersion,
5+
String reviewConfigSha256, String jobId) {
6+
public RemoteReviewBinding {
7+
if (!text(projectId,128) || !text(versionId,128) || !uuid(requestId) || attempt < 1
8+
|| !text(filePath,4096) || !digest(artifactSha256) || !digest(contextSha256)
9+
|| policyVersion == null || !policyVersion.matches("warden-3\\.0\\.0:[0-9a-f]{64}")
10+
|| !digest(reviewConfigSha256) || jobId != null && !uuid(jobId))
11+
throw new IllegalArgumentException("Invalid remote review binding");
12+
}
13+
public RemoteReviewBinding withJobId(String value) {
14+
if (!uuid(value) || jobId != null && !jobId.equals(value)) throw new IllegalArgumentException("Remote job identity cannot change");
15+
return new RemoteReviewBinding(projectId,versionId,requestId,attempt,filePath,artifactSha256,contextSha256,policyVersion,reviewConfigSha256,value);
16+
}
17+
private static boolean text(String value,int max) { return value != null && !value.isBlank() && value.length() <= max && value.chars().noneMatch(Character::isISOControl); }
18+
private static boolean digest(String value) { return value != null && value.matches("[0-9a-f]{64}"); }
19+
private static boolean uuid(String value) { return value != null && value.matches("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"); }
20+
}

‎backend/src/main/java/net/modtale/model/project/ScanResult.java‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@
66

77
@JsonIgnoreProperties(ignoreUnknown = true)
88
public class ScanResult {
9+
private RemoteReviewBinding remoteReview;
10+
@com.fasterxml.jackson.annotation.JsonIgnore
11+
public RemoteReviewBinding getRemoteReview() { return remoteReview; }
12+
@com.fasterxml.jackson.annotation.JsonIgnore
13+
public void setRemoteReview(RemoteReviewBinding value) { remoteReview = value; }
914
private SecurityEvidence securityEvidence;
1015
private boolean artifactVerified;
1116
private String reviewedContextSha256;
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package net.modtale.service.security.scan;
2+
3+
import net.modtale.model.project.*;
4+
import org.springframework.data.mongodb.core.MongoTemplate;
5+
import org.springframework.data.mongodb.core.query.*;
6+
import org.springframework.stereotype.Service;
7+
import java.util.Objects;
8+
9+
@Service
10+
public class RemoteReviewPersistence {
11+
private final MongoTemplate mongo;
12+
public RemoteReviewPersistence(MongoTemplate mongo) {
13+
this.mongo = new MongoTemplate(mongo.getMongoDatabaseFactory(),mongo.getConverter());
14+
this.mongo.setWriteConcern(com.mongodb.WriteConcern.MAJORITY.withJournal(true)
15+
.withWTimeout(10,java.util.concurrent.TimeUnit.SECONDS));
16+
}
17+
18+
public boolean bind(ProjectVersion observed, RemoteReviewBinding binding) {
19+
if (binding == null || binding.jobId() != null || !matches(observed,binding)) return false;
20+
var version = target(observed,binding).and("scanResult.scanState").in("SCANNING","REMOTE_REVIEW")
21+
.orOperator(Criteria.where("scanResult.remoteReview").is(null),Criteria.where("scanResult.remoteReview").is(binding));
22+
return mongo.updateFirst(query(binding,version), new Update()
23+
.set("versions.$.scanResult.remoteReview",binding)
24+
.set("versions.$.scanResult.scanState","REMOTE_REVIEW"),Project.class).getMatchedCount() == 1;
25+
}
26+
27+
public boolean attachJob(ProjectVersion observed, RemoteReviewBinding binding, String jobId) {
28+
if (binding == null || !matches(observed,binding)) return false;
29+
var attached = binding.withJobId(jobId);
30+
var version = target(observed,binding).and("scanResult.scanState").is("REMOTE_REVIEW")
31+
.orOperator(Criteria.where("scanResult.remoteReview").is(binding),Criteria.where("scanResult.remoteReview").is(attached));
32+
return mongo.updateFirst(query(binding,version),new Update()
33+
.set("versions.$.scanResult.remoteReview",attached),Project.class).getMatchedCount() == 1;
34+
}
35+
36+
private static boolean matches(ProjectVersion observed,RemoteReviewBinding binding) {
37+
if (observed == null || observed.getScanResult() == null) return false;
38+
var scan=observed.getScanResult();
39+
return Objects.equals(observed.getId(),binding.versionId()) && Objects.equals(observed.getHash(),binding.artifactSha256())
40+
&& Objects.equals(observed.getFileUrl(),binding.filePath())
41+
&& Objects.equals(ArtifactReviewContext.automaticallyReviewableFingerprint(observed),binding.contextSha256())
42+
&& Objects.equals(scan.getScanRequestId(),binding.requestId()) && scan.getScanAttempt() == binding.attempt();
43+
}
44+
private static Criteria target(ProjectVersion observed,RemoteReviewBinding binding) {
45+
return ArtifactReviewContext.bindSnapshot(Criteria.where("_id").is(binding.versionId())
46+
.and("hash").is(binding.artifactSha256()).and("fileUrl").is(binding.filePath())
47+
.and("reviewStatus").is(ProjectVersion.ReviewStatus.PENDING)
48+
.and("scanResult.status").is(ScanStatus.SCANNING)
49+
.and("scanResult.scanRequestId").is(binding.requestId())
50+
.and("scanResult.scanAttempt").is(binding.attempt()),observed);
51+
}
52+
private static Query query(RemoteReviewBinding binding,Criteria version) {
53+
return Query.query(Criteria.where("_id").is(binding.projectId()).and("versions").elemMatch(version));
54+
}
55+
}

‎backend/src/test/java/net/modtale/service/admin/review/VersionReviewPersistenceIntegrationTest.java‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,52 @@
1818

1919
@EnabledIfEnvironmentVariable(named="WARDEN_REVIEW_DB_TEST",matches="true")
2020
class VersionReviewPersistenceIntegrationTest {
21+
private net.modtale.model.project.RemoteReviewBinding prepareRemote() {
22+
var scan=new ScanResult();scan.setStatus(ScanStatus.SCANNING);scan.setScanState("SCANNING");scan.setScanAttempt(1);
23+
scan.setScanRequestId(UUID.randomUUID().toString());version.setScanResult(scan);version.setFileUrl("original.zip");
24+
mongo.updateFirst(Query.query(Criteria.where("_id").is(id)),new Update().set("versions.0",version),Project.class);
25+
return new RemoteReviewBinding(id,version.getId(),scan.getScanRequestId(),1,version.getFileUrl(),version.getHash(),
26+
net.modtale.service.security.scan.ArtifactReviewContext.automaticallyReviewableFingerprint(version),
27+
"warden-3.0.0:"+"a".repeat(64),"b".repeat(64),null);
28+
}
29+
@Test void remoteBindingSurvivesRestartAndAttachesOnlyOneJob() {
30+
var binding=prepareRemote();var remote=new net.modtale.service.security.scan.RemoteReviewPersistence(mongo);
31+
assertTrue(remote.bind(version,binding));assertTrue(remote.bind(version,binding));
32+
var restored=mongo.findById(id,Project.class).getVersions().getFirst();assertEquals(binding,restored.getScanResult().getRemoteReview());
33+
var restarted=new net.modtale.service.security.scan.RemoteReviewPersistence(mongo);String job=UUID.randomUUID().toString();
34+
assertTrue(restarted.attachJob(restored,binding,job));assertTrue(restarted.attachJob(restored,binding,job));
35+
assertFalse(restarted.attachJob(restored,binding,UUID.randomUUID().toString()));assertFalse(restarted.bind(restored,binding));
36+
assertEquals(binding.withJobId(job),mongo.findById(id,Project.class).getVersions().getFirst().getScanResult().getRemoteReview());
37+
}
38+
@Test void changedContextCannotBindOrAttachRemoteJob() {
39+
var binding=prepareRemote();var remote=new net.modtale.service.security.scan.RemoteReviewPersistence(mongo);
40+
mongo.updateFirst(Query.query(Criteria.where("_id").is(id)),new Update().set("versions.0.manifestVersion","changed"),Project.class);
41+
assertFalse(remote.bind(version,binding));
42+
mongo.updateFirst(Query.query(Criteria.where("_id").is(id)),new Update().set("versions.0.manifestVersion",version.getManifestVersion()),Project.class);
43+
assertTrue(remote.bind(version,binding));
44+
mongo.updateFirst(Query.query(Criteria.where("_id").is(id)),new Update().set("versions.0.hash","c".repeat(64)),Project.class);
45+
assertFalse(remote.attachJob(version,binding,UUID.randomUUID().toString()));
46+
}
47+
@Test void newRequestAndCompetingConfigurationCannotReplaceBinding() {
48+
var binding=prepareRemote();var remote=new net.modtale.service.security.scan.RemoteReviewPersistence(mongo);
49+
assertTrue(remote.bind(version,binding));
50+
var other=new RemoteReviewBinding(binding.projectId(),binding.versionId(),binding.requestId(),binding.attempt(),binding.filePath(),
51+
binding.artifactSha256(),binding.contextSha256(),binding.policyVersion(),"c".repeat(64),null);
52+
assertFalse(remote.bind(version,other));
53+
mongo.updateFirst(Query.query(Criteria.where("_id").is(id)),new Update().set("versions.0.scanResult.scanRequestId",UUID.randomUUID().toString()),Project.class);
54+
assertFalse(remote.attachJob(version,binding,UUID.randomUUID().toString()));assertFalse(remote.bind(version,binding));
55+
}
56+
@Test void legacyRecoveryCannotReplaceRemoteReviewEvenWithOldSnapshot() {
57+
var binding=prepareRemote();var observed=version.getScanResult();var remote=new net.modtale.service.security.scan.RemoteReviewPersistence(mongo);
58+
assertTrue(remote.bind(version,binding));
59+
var scans=new net.modtale.service.security.scan.ScanPersistenceService(mongo,
60+
org.mockito.Mockito.mock(net.modtale.repository.project.ProjectRepository.class),org.mockito.Mockito.mock(net.modtale.service.project.query.ProjectService.class));
61+
assertFalse(scans.queueRetryAttempt(id,version.getId(),1,new ScanResult(),observed,1));
62+
assertFalse(scans.updateTimedOutScan(id,version.getId(),new ScanResult(),1,observed,1));
63+
var restored=mongo.findById(id,Project.class).getVersions().getFirst();
64+
assertFalse(scans.queueRetryAttempt(id,version.getId(),1,new ScanResult(),restored.getScanResult(),1));
65+
assertEquals(binding,restored.getScanResult().getRemoteReview());
66+
}
2167
@Test void staleRecoveryCannotReplaceAnAttemptThatStartedAfterItsSnapshot() {
2268
var scan = new ScanResult(); scan.setStatus(ScanStatus.SCANNING); scan.setScanState("QUEUED");
2369
scan.setScanAttempt(1); scan.setScanTimestamp(System.currentTimeMillis()-120_000);

‎backend/src/test/java/net/modtale/service/security/scan/ScanEvidencePersistenceTest.java‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,17 +17,23 @@ class ScanEvidencePersistenceTest {
1717
converter.setCustomConversions(new net.modtale.config.db.MongoConfig().mongoCustomConversions());
1818
converter.afterPropertiesSet();
1919
var original = ScanEvidenceFixtures.complete(true);
20+
var remote=new net.modtale.model.project.RemoteReviewBinding("project","version",java.util.UUID.randomUUID().toString(),1,
21+
"original.zip","a".repeat(64),"b".repeat(64),"warden-3.0.0:"+"c".repeat(64),"d".repeat(64),null);
22+
original.setRemoteReview(remote);
2023
original.setReviewedContextSha256("c".repeat(64));
2124
var stored = new Document();
2225
converter.write(original, stored);
2326
var restored = converter.read(ScanResult.class, stored);
2427
assertTrue(ArtifactClearancePolicy.complete(restored));
28+
assertEquals(remote,restored.getRemoteReview());
2529
assertEquals(original.getReviewedContextSha256(), restored.getReviewedContextSha256());
2630
var mapper = new ObjectMapper();
2731
var forged = mapper.readValue("{\"artifactVerified\":true,\"reviewedContextSha256\":\"forged\"}", ScanResult.class);
2832
assertFalse(forged.isArtifactVerified());
2933
assertNull(forged.getReviewedContextSha256());
3034
String json = mapper.writeValueAsString(original);
35+
assertFalse(json.contains("remoteReview"));
36+
assertNull(mapper.readValue("{\"remoteReview\":"+mapper.writeValueAsString(remote)+"}",ScanResult.class).getRemoteReview());
3137
assertFalse(json.contains("artifactVerified"));
3238
assertFalse(json.contains("reviewedContextSha256"));
3339
}

0 commit comments

Comments
 (0)