From db5a09bafc30fc838949c50ac756fc9e48d6058b Mon Sep 17 00:00:00 2001 From: nurlanakberli9-pixel Date: Fri, 26 Jun 2026 12:33:08 -0700 Subject: [PATCH 01/23] feat(scanning): audit GitHub Actions workflows across repositories - Add V2 Flyway migration for workflow_scan table with FK to repo - Add WorkflowScanEntity and WorkflowScanRepository - Add RepoRepository - Implement WorkflowParser to flag unpinned actions, broad permissions and missing OIDC with 0-100 risk scoring - Implement WorkflowScanService to fetch workflow YAMLs from GitHub and persist scan results - Add WorkflowScanJob scheduled worker to scan all repos hourly - Expose GET /api/repos/{repoId}/workflow-scans endpoint - Add unit tests for WorkflowParser - Add integration tests for WorkflowScanRepository and WorkflowScanController --- .../dev/cleat/api/CleatApiApplication.java | 2 + .../controller/WorkflowScanController.java | 50 ++++++++ .../cleat/api/AbstractIntegrationTest.java | 2 +- .../cleat/api/WorkflowScanControllerTest.java | 73 +++++++++++ .../cleat/worker/CleatWorkerApplication.java | 6 + .../dev/cleat/worker/WorkflowScanJob.java | 42 ++++++ .../worker/src/main/resources/application.yml | 5 +- .../{ => entity}/AccountEntity.java | 4 +- .../persistence/{ => entity}/RepoEntity.java | 3 +- .../entity/WorkflowScanEntity.java | 118 +++++++++++++++++ .../persistence/{ => enums}/AccountType.java | 2 +- .../cleat/persistence/{ => enums}/Plan.java | 2 +- .../persistence/{ => enums}/Visibility.java | 2 +- .../{ => repo}/AccountRepository.java | 3 +- .../persistence/repo/RepoRepository.java | 11 ++ .../repo/WorkflowScanRepository.java | 13 ++ .../V2__create_workflow_scan_table.sql | 17 +++ .../persistence/AccountRepositoryTest.java | 4 + .../WorkflowScanRepositoryTest.java | 120 ++++++++++++++++++ backend/libs/scanning/build.gradle.kts | 1 + .../dev/cleat/scanning/WorkflowAnalysis.java | 10 ++ .../dev/cleat/scanning/WorkflowParser.java | 81 ++++++++++++ .../cleat/scanning/WorkflowScanService.java | 90 +++++++++++++ .../cleat/scanning/WorkflowParserTest.java | 83 ++++++++++++ 24 files changed, 736 insertions(+), 8 deletions(-) create mode 100644 backend/apps/api/src/main/java/dev/cleat/api/controller/WorkflowScanController.java create mode 100644 backend/apps/api/src/test/java/dev/cleat/api/WorkflowScanControllerTest.java create mode 100644 backend/apps/worker/src/main/java/dev/cleat/worker/WorkflowScanJob.java rename backend/libs/persistence/src/main/java/dev/cleat/persistence/{ => entity}/AccountEntity.java (97%) rename backend/libs/persistence/src/main/java/dev/cleat/persistence/{ => entity}/RepoEntity.java (98%) create mode 100644 backend/libs/persistence/src/main/java/dev/cleat/persistence/entity/WorkflowScanEntity.java rename backend/libs/persistence/src/main/java/dev/cleat/persistence/{ => enums}/AccountType.java (55%) rename backend/libs/persistence/src/main/java/dev/cleat/persistence/{ => enums}/Plan.java (52%) rename backend/libs/persistence/src/main/java/dev/cleat/persistence/{ => enums}/Visibility.java (64%) rename backend/libs/persistence/src/main/java/dev/cleat/persistence/{ => repo}/AccountRepository.java (65%) create mode 100644 backend/libs/persistence/src/main/java/dev/cleat/persistence/repo/RepoRepository.java create mode 100644 backend/libs/persistence/src/main/java/dev/cleat/persistence/repo/WorkflowScanRepository.java create mode 100644 backend/libs/persistence/src/main/resources/db/migration/V2__create_workflow_scan_table.sql create mode 100644 backend/libs/persistence/src/test/java/dev/cleat/persistence/WorkflowScanRepositoryTest.java create mode 100644 backend/libs/scanning/src/main/java/dev/cleat/scanning/WorkflowAnalysis.java create mode 100644 backend/libs/scanning/src/main/java/dev/cleat/scanning/WorkflowParser.java create mode 100644 backend/libs/scanning/src/main/java/dev/cleat/scanning/WorkflowScanService.java create mode 100644 backend/libs/scanning/src/test/java/dev/cleat/scanning/WorkflowParserTest.java diff --git a/backend/apps/api/src/main/java/dev/cleat/api/CleatApiApplication.java b/backend/apps/api/src/main/java/dev/cleat/api/CleatApiApplication.java index 0ed835e..bf9f15e 100644 --- a/backend/apps/api/src/main/java/dev/cleat/api/CleatApiApplication.java +++ b/backend/apps/api/src/main/java/dev/cleat/api/CleatApiApplication.java @@ -2,8 +2,10 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; @SpringBootApplication +@EnableJpaRepositories(basePackages = "dev.cleat.persistence") public class CleatApiApplication { public static void main(String[] args) { SpringApplication.run(CleatApiApplication.class); diff --git a/backend/apps/api/src/main/java/dev/cleat/api/controller/WorkflowScanController.java b/backend/apps/api/src/main/java/dev/cleat/api/controller/WorkflowScanController.java new file mode 100644 index 0000000..7095a1d --- /dev/null +++ b/backend/apps/api/src/main/java/dev/cleat/api/controller/WorkflowScanController.java @@ -0,0 +1,50 @@ +package dev.cleat.api.controller; + +import dev.cleat.persistence.entity.WorkflowScanEntity; +import dev.cleat.persistence.repo.WorkflowScanRepository; +import java.time.OffsetDateTime; +import java.util.List; +import java.util.UUID; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/repos") +public class WorkflowScanController { + + private final WorkflowScanRepository workflowScanRepository; + + public WorkflowScanController(WorkflowScanRepository workflowScanRepository) { + this.workflowScanRepository = workflowScanRepository; + } + + @GetMapping("/{repoId}/workflow-scans") + public List getWorkflowScans(@PathVariable UUID repoId) { + return workflowScanRepository.findByRepoIdOrderByScannedAtDesc(repoId).stream() + .map(WorkflowScanResponse::from) + .toList(); + } + + public record WorkflowScanResponse( + UUID id, + String workflowPath, + int unpinnedActions, + boolean broadPermissions, + boolean missingOidc, + int riskScore, + OffsetDateTime scannedAt) { + + public static WorkflowScanResponse from(WorkflowScanEntity entity) { + return new WorkflowScanResponse( + entity.getId(), + entity.getWorkflowPath(), + entity.getUnpinnedActions(), + entity.getBroadPermissions(), + entity.getMissingOidc(), + entity.getRiskScore(), + entity.getScannedAt()); + } + } +} diff --git a/backend/apps/api/src/test/java/dev/cleat/api/AbstractIntegrationTest.java b/backend/apps/api/src/test/java/dev/cleat/api/AbstractIntegrationTest.java index 7af426b..24d6959 100644 --- a/backend/apps/api/src/test/java/dev/cleat/api/AbstractIntegrationTest.java +++ b/backend/apps/api/src/test/java/dev/cleat/api/AbstractIntegrationTest.java @@ -8,7 +8,7 @@ import org.testcontainers.junit.jupiter.Testcontainers; @Testcontainers -@SpringBootTest +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) public abstract class AbstractIntegrationTest { @Container diff --git a/backend/apps/api/src/test/java/dev/cleat/api/WorkflowScanControllerTest.java b/backend/apps/api/src/test/java/dev/cleat/api/WorkflowScanControllerTest.java new file mode 100644 index 0000000..5f5e5cc --- /dev/null +++ b/backend/apps/api/src/test/java/dev/cleat/api/WorkflowScanControllerTest.java @@ -0,0 +1,73 @@ +package dev.cleat.api; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.cleat.persistence.entity.AccountEntity; +import dev.cleat.persistence.entity.RepoEntity; +import dev.cleat.persistence.entity.WorkflowScanEntity; +import dev.cleat.persistence.enums.AccountType; +import dev.cleat.persistence.repo.AccountRepository; +import dev.cleat.persistence.repo.RepoRepository; +import dev.cleat.persistence.repo.WorkflowScanRepository; +import java.time.OffsetDateTime; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.web.client.TestRestTemplate; + +class WorkflowScanControllerTest extends AbstractIntegrationTest { + + @Autowired + private AccountRepository accountRepository; + + @Autowired + private RepoRepository repoRepository; + + @Autowired + private WorkflowScanRepository workflowScanRepository; + + @Autowired + private TestRestTemplate restTemplate; + + @Test + void returnsWorkflowScansForRepo() { + // Arrange — DB-yə account, repo, scan yaz + AccountEntity account = + new AccountEntity().setLogin("octocat").setName("Octocat").setType(AccountType.USER); + accountRepository.saveAndFlush(account); + + RepoEntity repo = new RepoEntity().setAccount(account).setName("hello-world"); + repoRepository.saveAndFlush(repo); + + workflowScanRepository.saveAndFlush(new WorkflowScanEntity() + .setRepo(repo) + .setWorkflowPath(".github/workflows/ci.yml") + .setUnpinnedActions(2) + .setBroadPermissions(false) + .setMissingOidc(true) + .setRiskScore(80) + .setScannedAt(OffsetDateTime.now())); + + // Act — endpoint-ə sorğu at + var response = restTemplate.getForEntity("/api/repos/" + repo.getId() + "/workflow-scans", List.class); + + // Assert + assertThat(response.getStatusCode().is2xxSuccessful()).isTrue(); + assertThat(response.getBody()).hasSize(1); + } + + @Test + void returnsEmptyListWhenNoScans() { + AccountEntity account = + new AccountEntity().setLogin("octocat3").setName("Octocat3").setType(AccountType.USER); + accountRepository.saveAndFlush(account); + + RepoEntity repo = new RepoEntity().setAccount(account).setName("empty-repo"); + repoRepository.saveAndFlush(repo); + + var response = restTemplate.getForEntity("/api/repos/" + repo.getId() + "/workflow-scans", List.class); + + assertThat(response.getStatusCode().is2xxSuccessful()).isTrue(); + assertThat(response.getBody()).isEmpty(); + } +} diff --git a/backend/apps/worker/src/main/java/dev/cleat/worker/CleatWorkerApplication.java b/backend/apps/worker/src/main/java/dev/cleat/worker/CleatWorkerApplication.java index c28eff7..ddec165 100644 --- a/backend/apps/worker/src/main/java/dev/cleat/worker/CleatWorkerApplication.java +++ b/backend/apps/worker/src/main/java/dev/cleat/worker/CleatWorkerApplication.java @@ -2,10 +2,16 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication @EnableScheduling +@EntityScan(basePackages = "dev.cleat.persistence") +@EnableJpaRepositories(basePackages = "dev.cleat.persistence") +@ComponentScan(basePackages = "dev.cleat.scanning") public class CleatWorkerApplication { public static void main(String[] args) { diff --git a/backend/apps/worker/src/main/java/dev/cleat/worker/WorkflowScanJob.java b/backend/apps/worker/src/main/java/dev/cleat/worker/WorkflowScanJob.java new file mode 100644 index 0000000..8388ebd --- /dev/null +++ b/backend/apps/worker/src/main/java/dev/cleat/worker/WorkflowScanJob.java @@ -0,0 +1,42 @@ +package dev.cleat.worker; + +import dev.cleat.persistence.entity.RepoEntity; +import dev.cleat.persistence.repo.AccountRepository; +import dev.cleat.scanning.WorkflowScanService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Component +public class WorkflowScanJob { + + private static final Logger log = LoggerFactory.getLogger(WorkflowScanJob.class); + + private final AccountRepository accountRepository; + private final WorkflowScanService workflowScanService; + + public WorkflowScanJob(AccountRepository accountRepository, WorkflowScanService workflowScanService) { + this.accountRepository = accountRepository; + this.workflowScanService = workflowScanService; + } + + @Scheduled(fixedDelayString = "${cleat.workflow-scan.interval-ms:3600000}") + public void run() { + log.info("Starting workflow scan job"); + + accountRepository.findAll().forEach(account -> { + String installationId = account.getInstallationId(); + + for (RepoEntity repo : account.getRepos()) { + try { + workflowScanService.scanRepo(repo, installationId); + } catch (Exception e) { + log.error("Failed to scan workflows for repo {}/{}", account.getLogin(), repo.getName(), e); + } + } + }); + + log.info("Workflow scan job completed"); + } +} diff --git a/backend/apps/worker/src/main/resources/application.yml b/backend/apps/worker/src/main/resources/application.yml index ce6f2f6..f5a3cef 100644 --- a/backend/apps/worker/src/main/resources/application.yml +++ b/backend/apps/worker/src/main/resources/application.yml @@ -28,4 +28,7 @@ management: db: enabled: true redis: - enabled: true \ No newline at end of file + enabled: true +cleat: + workflow-scan: + interval-ms: 3600000 \ No newline at end of file diff --git a/backend/libs/persistence/src/main/java/dev/cleat/persistence/AccountEntity.java b/backend/libs/persistence/src/main/java/dev/cleat/persistence/entity/AccountEntity.java similarity index 97% rename from backend/libs/persistence/src/main/java/dev/cleat/persistence/AccountEntity.java rename to backend/libs/persistence/src/main/java/dev/cleat/persistence/entity/AccountEntity.java index 33cb645..b18f95d 100644 --- a/backend/libs/persistence/src/main/java/dev/cleat/persistence/AccountEntity.java +++ b/backend/libs/persistence/src/main/java/dev/cleat/persistence/entity/AccountEntity.java @@ -1,5 +1,7 @@ -package dev.cleat.persistence; +package dev.cleat.persistence.entity; +import dev.cleat.persistence.enums.AccountType; +import dev.cleat.persistence.enums.Plan; import jakarta.persistence.CascadeType; import jakarta.persistence.Column; import jakarta.persistence.Entity; diff --git a/backend/libs/persistence/src/main/java/dev/cleat/persistence/RepoEntity.java b/backend/libs/persistence/src/main/java/dev/cleat/persistence/entity/RepoEntity.java similarity index 98% rename from backend/libs/persistence/src/main/java/dev/cleat/persistence/RepoEntity.java rename to backend/libs/persistence/src/main/java/dev/cleat/persistence/entity/RepoEntity.java index 1cebae4..9ab4d43 100644 --- a/backend/libs/persistence/src/main/java/dev/cleat/persistence/RepoEntity.java +++ b/backend/libs/persistence/src/main/java/dev/cleat/persistence/entity/RepoEntity.java @@ -1,5 +1,6 @@ -package dev.cleat.persistence; +package dev.cleat.persistence.entity; +import dev.cleat.persistence.enums.Visibility; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; diff --git a/backend/libs/persistence/src/main/java/dev/cleat/persistence/entity/WorkflowScanEntity.java b/backend/libs/persistence/src/main/java/dev/cleat/persistence/entity/WorkflowScanEntity.java new file mode 100644 index 0000000..c3167a4 --- /dev/null +++ b/backend/libs/persistence/src/main/java/dev/cleat/persistence/entity/WorkflowScanEntity.java @@ -0,0 +1,118 @@ +package dev.cleat.persistence.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import java.time.OffsetDateTime; +import java.util.UUID; + +@Entity +@Table(name = "workflow_scan") +public class WorkflowScanEntity { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "repo_id", nullable = false) + private RepoEntity repo; + + @Column(name = "workflow_path", nullable = false) + private String workflowPath; + + @Column(name = "unpinned_actions", nullable = false) + private Integer unpinnedActions; + + @Column(name = "broad_permissions", nullable = false) + private Boolean broadPermissions; + + @Column(name = "missing_oidc", nullable = false) + private Boolean missingOidc; + + @Column(name = "risk_score", nullable = false) + private Integer riskScore; + + @Column(name = "scanned_at", nullable = false) + private OffsetDateTime scannedAt; + + public WorkflowScanEntity() {} + + public UUID getId() { + return id; + } + + public WorkflowScanEntity setId(UUID id) { + this.id = id; + return this; + } + + public RepoEntity getRepo() { + return repo; + } + + public WorkflowScanEntity setRepo(RepoEntity repo) { + this.repo = repo; + return this; + } + + public String getWorkflowPath() { + return workflowPath; + } + + public WorkflowScanEntity setWorkflowPath(String workflowPath) { + this.workflowPath = workflowPath; + return this; + } + + public Integer getUnpinnedActions() { + return unpinnedActions; + } + + public WorkflowScanEntity setUnpinnedActions(Integer unpinnedActions) { + this.unpinnedActions = unpinnedActions; + return this; + } + + public Boolean getBroadPermissions() { + return broadPermissions; + } + + public WorkflowScanEntity setBroadPermissions(Boolean broadPermissions) { + this.broadPermissions = broadPermissions; + return this; + } + + public Boolean getMissingOidc() { + return missingOidc; + } + + public WorkflowScanEntity setMissingOidc(Boolean missingOidc) { + this.missingOidc = missingOidc; + return this; + } + + public Integer getRiskScore() { + return riskScore; + } + + public WorkflowScanEntity setRiskScore(Integer riskScore) { + this.riskScore = riskScore; + return this; + } + + public OffsetDateTime getScannedAt() { + return scannedAt; + } + + public WorkflowScanEntity setScannedAt(OffsetDateTime scannedAt) { + this.scannedAt = scannedAt; + return this; + } +} diff --git a/backend/libs/persistence/src/main/java/dev/cleat/persistence/AccountType.java b/backend/libs/persistence/src/main/java/dev/cleat/persistence/enums/AccountType.java similarity index 55% rename from backend/libs/persistence/src/main/java/dev/cleat/persistence/AccountType.java rename to backend/libs/persistence/src/main/java/dev/cleat/persistence/enums/AccountType.java index d792f6a..55c0e9a 100644 --- a/backend/libs/persistence/src/main/java/dev/cleat/persistence/AccountType.java +++ b/backend/libs/persistence/src/main/java/dev/cleat/persistence/enums/AccountType.java @@ -1,4 +1,4 @@ -package dev.cleat.persistence; +package dev.cleat.persistence.enums; public enum AccountType { USER, diff --git a/backend/libs/persistence/src/main/java/dev/cleat/persistence/Plan.java b/backend/libs/persistence/src/main/java/dev/cleat/persistence/enums/Plan.java similarity index 52% rename from backend/libs/persistence/src/main/java/dev/cleat/persistence/Plan.java rename to backend/libs/persistence/src/main/java/dev/cleat/persistence/enums/Plan.java index 752828f..aa4fe41 100644 --- a/backend/libs/persistence/src/main/java/dev/cleat/persistence/Plan.java +++ b/backend/libs/persistence/src/main/java/dev/cleat/persistence/enums/Plan.java @@ -1,4 +1,4 @@ -package dev.cleat.persistence; +package dev.cleat.persistence.enums; public enum Plan { FREE, diff --git a/backend/libs/persistence/src/main/java/dev/cleat/persistence/Visibility.java b/backend/libs/persistence/src/main/java/dev/cleat/persistence/enums/Visibility.java similarity index 64% rename from backend/libs/persistence/src/main/java/dev/cleat/persistence/Visibility.java rename to backend/libs/persistence/src/main/java/dev/cleat/persistence/enums/Visibility.java index d5920b6..2725601 100644 --- a/backend/libs/persistence/src/main/java/dev/cleat/persistence/Visibility.java +++ b/backend/libs/persistence/src/main/java/dev/cleat/persistence/enums/Visibility.java @@ -1,4 +1,4 @@ -package dev.cleat.persistence; +package dev.cleat.persistence.enums; public enum Visibility { PUBLIC, diff --git a/backend/libs/persistence/src/main/java/dev/cleat/persistence/AccountRepository.java b/backend/libs/persistence/src/main/java/dev/cleat/persistence/repo/AccountRepository.java similarity index 65% rename from backend/libs/persistence/src/main/java/dev/cleat/persistence/AccountRepository.java rename to backend/libs/persistence/src/main/java/dev/cleat/persistence/repo/AccountRepository.java index 822d78a..a062add 100644 --- a/backend/libs/persistence/src/main/java/dev/cleat/persistence/AccountRepository.java +++ b/backend/libs/persistence/src/main/java/dev/cleat/persistence/repo/AccountRepository.java @@ -1,5 +1,6 @@ -package dev.cleat.persistence; +package dev.cleat.persistence.repo; +import dev.cleat.persistence.entity.AccountEntity; import java.util.UUID; import org.springframework.data.jpa.repository.JpaRepository; diff --git a/backend/libs/persistence/src/main/java/dev/cleat/persistence/repo/RepoRepository.java b/backend/libs/persistence/src/main/java/dev/cleat/persistence/repo/RepoRepository.java new file mode 100644 index 0000000..5ad465a --- /dev/null +++ b/backend/libs/persistence/src/main/java/dev/cleat/persistence/repo/RepoRepository.java @@ -0,0 +1,11 @@ +package dev.cleat.persistence.repo; + +import dev.cleat.persistence.entity.RepoEntity; +import java.util.List; +import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface RepoRepository extends JpaRepository { + + List findByAccountId(UUID accountId); +} diff --git a/backend/libs/persistence/src/main/java/dev/cleat/persistence/repo/WorkflowScanRepository.java b/backend/libs/persistence/src/main/java/dev/cleat/persistence/repo/WorkflowScanRepository.java new file mode 100644 index 0000000..2d91f29 --- /dev/null +++ b/backend/libs/persistence/src/main/java/dev/cleat/persistence/repo/WorkflowScanRepository.java @@ -0,0 +1,13 @@ +package dev.cleat.persistence.repo; + +import dev.cleat.persistence.entity.WorkflowScanEntity; +import java.util.List; +import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface WorkflowScanRepository extends JpaRepository { + + List findByRepoIdOrderByScannedAtDesc(UUID repoId); + + void deleteByRepoId(UUID repoId); +} diff --git a/backend/libs/persistence/src/main/resources/db/migration/V2__create_workflow_scan_table.sql b/backend/libs/persistence/src/main/resources/db/migration/V2__create_workflow_scan_table.sql new file mode 100644 index 0000000..ad1357b --- /dev/null +++ b/backend/libs/persistence/src/main/resources/db/migration/V2__create_workflow_scan_table.sql @@ -0,0 +1,17 @@ +CREATE TABLE workflow_scan ( + id UUID PRIMARY KEY, + repo_id UUID NOT NULL, + workflow_path VARCHAR(500) NOT NULL, + unpinned_actions INTEGER NOT NULL DEFAULT 0, + broad_permissions BOOLEAN NOT NULL DEFAULT FALSE, + missing_oidc BOOLEAN NOT NULL DEFAULT FALSE, + risk_score INTEGER NOT NULL DEFAULT 0, + scanned_at TIMESTAMP WITH TIME ZONE NOT NULL, + + CONSTRAINT fk_workflow_scan_repo + FOREIGN KEY (repo_id) + REFERENCES repo(id) + ON DELETE CASCADE +); + +CREATE INDEX idx_workflow_scan_repo_id ON workflow_scan(repo_id); \ No newline at end of file diff --git a/backend/libs/persistence/src/test/java/dev/cleat/persistence/AccountRepositoryTest.java b/backend/libs/persistence/src/test/java/dev/cleat/persistence/AccountRepositoryTest.java index 2cf33ca..6b1e5eb 100644 --- a/backend/libs/persistence/src/test/java/dev/cleat/persistence/AccountRepositoryTest.java +++ b/backend/libs/persistence/src/test/java/dev/cleat/persistence/AccountRepositoryTest.java @@ -1,5 +1,9 @@ package dev.cleat.persistence; +import dev.cleat.persistence.entity.AccountEntity; +import dev.cleat.persistence.entity.RepoEntity; +import dev.cleat.persistence.enums.AccountType; +import dev.cleat.persistence.repo.AccountRepository; import java.util.List; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; diff --git a/backend/libs/persistence/src/test/java/dev/cleat/persistence/WorkflowScanRepositoryTest.java b/backend/libs/persistence/src/test/java/dev/cleat/persistence/WorkflowScanRepositoryTest.java new file mode 100644 index 0000000..59bce1b --- /dev/null +++ b/backend/libs/persistence/src/test/java/dev/cleat/persistence/WorkflowScanRepositoryTest.java @@ -0,0 +1,120 @@ +package dev.cleat.persistence; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.cleat.persistence.entity.AccountEntity; +import dev.cleat.persistence.entity.RepoEntity; +import dev.cleat.persistence.entity.WorkflowScanEntity; +import dev.cleat.persistence.enums.AccountType; +import dev.cleat.persistence.repo.AccountRepository; +import dev.cleat.persistence.repo.RepoRepository; +import dev.cleat.persistence.repo.WorkflowScanRepository; +import java.time.OffsetDateTime; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +@DataJpaTest +@Testcontainers +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@ContextConfiguration(classes = TestPersistenceConfig.class) +class WorkflowScanRepositoryTest { + + @Container + static PostgreSQLContainer postgres = new PostgreSQLContainer<>("postgres:16"); + + @DynamicPropertySource + static void props(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", postgres::getJdbcUrl); + registry.add("spring.datasource.username", postgres::getUsername); + registry.add("spring.datasource.password", postgres::getPassword); + } + + @Autowired + private TestEntityManager testEntityManager; + + @Autowired + private AccountRepository accountRepository; + + @Autowired + private RepoRepository repoRepository; + + @Autowired + private WorkflowScanRepository workflowScanRepository; + + @Test + void savesAndFindsWorkflowScanByRepoId() { + // Arrange + AccountEntity account = + new AccountEntity().setLogin("octocat").setName("Octocat").setType(AccountType.USER); + accountRepository.saveAndFlush(account); + + RepoEntity repo = new RepoEntity().setAccount(account).setName("hello-world"); + repoRepository.saveAndFlush(repo); + + WorkflowScanEntity scan = new WorkflowScanEntity() + .setRepo(repo) + .setWorkflowPath(".github/workflows/ci.yml") + .setUnpinnedActions(2) + .setBroadPermissions(false) + .setMissingOidc(true) + .setRiskScore(80) + .setScannedAt(OffsetDateTime.now()); + workflowScanRepository.saveAndFlush(scan); + testEntityManager.clear(); + + // Assert + List results = workflowScanRepository.findByRepoIdOrderByScannedAtDesc(repo.getId()); + + assertThat(results).hasSize(1); + assertThat(results.get(0).getWorkflowPath()).isEqualTo(".github/workflows/ci.yml"); + assertThat(results.get(0).getRiskScore()).isEqualTo(80); + assertThat(results.get(0).getMissingOidc()).isTrue(); + } + + @Test + void deleteByRepoIdRemovesAllScans() { + AccountEntity account = + new AccountEntity().setLogin("octocat2").setName("Octocat2").setType(AccountType.USER); + accountRepository.saveAndFlush(account); + + RepoEntity repo = new RepoEntity().setAccount(account).setName("my-repo"); + repoRepository.saveAndFlush(repo); + + workflowScanRepository.saveAndFlush(new WorkflowScanEntity() + .setRepo(repo) + .setWorkflowPath(".github/workflows/ci.yml") + .setUnpinnedActions(1) + .setBroadPermissions(false) + .setMissingOidc(false) + .setRiskScore(30) + .setScannedAt(OffsetDateTime.now())); + + workflowScanRepository.saveAndFlush(new WorkflowScanEntity() + .setRepo(repo) + .setWorkflowPath(".github/workflows/deploy.yml") + .setUnpinnedActions(0) + .setBroadPermissions(true) + .setMissingOidc(true) + .setRiskScore(60) + .setScannedAt(OffsetDateTime.now())); + + testEntityManager.clear(); + + // Act + workflowScanRepository.deleteByRepoId(repo.getId()); + + // Assert + assertThat(workflowScanRepository.findByRepoIdOrderByScannedAtDesc(repo.getId())) + .isEmpty(); + } +} diff --git a/backend/libs/scanning/build.gradle.kts b/backend/libs/scanning/build.gradle.kts index 0d4d4ac..e1c5dde 100644 --- a/backend/libs/scanning/build.gradle.kts +++ b/backend/libs/scanning/build.gradle.kts @@ -11,4 +11,5 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter") testImplementation("org.springframework.boot:spring-boot-starter-test") + implementation("org.yaml:snakeyaml") } diff --git a/backend/libs/scanning/src/main/java/dev/cleat/scanning/WorkflowAnalysis.java b/backend/libs/scanning/src/main/java/dev/cleat/scanning/WorkflowAnalysis.java new file mode 100644 index 0000000..8f236cf --- /dev/null +++ b/backend/libs/scanning/src/main/java/dev/cleat/scanning/WorkflowAnalysis.java @@ -0,0 +1,10 @@ +package dev.cleat.scanning; + +import java.util.List; + +public record WorkflowAnalysis( + String workflowPath, + List unpinnedActions, + boolean broadPermissions, + boolean missingOidc, + int riskScore) {} diff --git a/backend/libs/scanning/src/main/java/dev/cleat/scanning/WorkflowParser.java b/backend/libs/scanning/src/main/java/dev/cleat/scanning/WorkflowParser.java new file mode 100644 index 0000000..2e98a7f --- /dev/null +++ b/backend/libs/scanning/src/main/java/dev/cleat/scanning/WorkflowParser.java @@ -0,0 +1,81 @@ +package dev.cleat.scanning; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.regex.Pattern; +import org.springframework.stereotype.Component; +import org.yaml.snakeyaml.Yaml; + +@Component +public class WorkflowParser { + + // Pinned action: owner/repo@<40-char SHA> + private static final Pattern PINNED = Pattern.compile("^[^@]+@[0-9a-f]{40}$"); + + private static final List BROAD_VALUES = List.of("write-all", "read-all"); + + @SuppressWarnings("unchecked") + public WorkflowAnalysis parse(String workflowPath, String yamlContent) { + Yaml yaml = new Yaml(); + Map workflow = yaml.load(yamlContent); + + List unpinnedActions = new ArrayList<>(); + boolean broadPermissions = false; + boolean missingOidc = true; + + if (workflow == null) { + return scored(workflowPath, unpinnedActions, broadPermissions, missingOidc); + } + + // Check top-level permissions block + Object perms = workflow.get("permissions"); + if (perms instanceof String s) { + broadPermissions = BROAD_VALUES.contains(s); + } else if (perms instanceof Map permsMap) { + broadPermissions = permsMap.values().stream().anyMatch(v -> "write".equals(v) && permsMap.size() > 3); + missingOidc = !"write".equals(permsMap.get("id-token")); + } + + // Walk jobs → steps → uses + Object jobsObj = workflow.get("jobs"); + if (jobsObj instanceof Map jobs) { + for (Object jobVal : jobs.values()) { + if (!(jobVal instanceof Map job)) continue; + + // Per-job permissions can also grant OIDC + Object jobPerms = job.get("permissions"); + if (jobPerms instanceof Map jp && "write".equals(jp.get("id-token"))) { + missingOidc = false; + } + + Object stepsObj = job.get("steps"); + if (!(stepsObj instanceof List steps)) continue; + + for (Object stepObj : steps) { + if (!(stepObj instanceof Map step)) continue; + Object uses = step.get("uses"); + if (uses instanceof String action && !action.isBlank()) { + if (!PINNED.matcher(action).matches()) { + unpinnedActions.add(action); + } + } + } + } + } + + return scored(workflowPath, unpinnedActions, broadPermissions, missingOidc); + } + + private WorkflowAnalysis scored( + String workflowPath, List unpinnedActions, boolean broadPermissions, boolean missingOidc) { + + int score = 0; + score += unpinnedActions.size() * 30; + if (broadPermissions) score += 40; + if (missingOidc) score += 20; + score = Math.min(score, 100); + + return new WorkflowAnalysis(workflowPath, unpinnedActions, broadPermissions, missingOidc, score); + } +} diff --git a/backend/libs/scanning/src/main/java/dev/cleat/scanning/WorkflowScanService.java b/backend/libs/scanning/src/main/java/dev/cleat/scanning/WorkflowScanService.java new file mode 100644 index 0000000..5c371fa --- /dev/null +++ b/backend/libs/scanning/src/main/java/dev/cleat/scanning/WorkflowScanService.java @@ -0,0 +1,90 @@ +package dev.cleat.scanning; + +import dev.cleat.githubclient.service.GitHubClient; +import dev.cleat.persistence.entity.RepoEntity; +import dev.cleat.persistence.entity.WorkflowScanEntity; +import dev.cleat.persistence.repo.WorkflowScanRepository; +import java.time.OffsetDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class WorkflowScanService { + + private static final Logger log = LoggerFactory.getLogger(WorkflowScanService.class); + + private final GitHubClient gitHubClient; + private final WorkflowParser workflowParser; + private final WorkflowScanRepository workflowScanRepository; + + public WorkflowScanService( + GitHubClient gitHubClient, WorkflowParser workflowParser, WorkflowScanRepository workflowScanRepository) { + this.gitHubClient = gitHubClient; + this.workflowParser = workflowParser; + this.workflowScanRepository = workflowScanRepository; + } + + @Transactional + @SuppressWarnings("unchecked") + public List scanRepo(RepoEntity repo, String installationId) { + String owner = repo.getAccount().getLogin(); + String repoName = repo.getName(); + + // 1. Fetch list of workflow files from GitHub + List> files = gitHubClient.get( + "/repos/" + owner + "/" + repoName + "/contents/.github/workflows", installationId, List.class); + + if (files == null || files.isEmpty()) { + log.debug("No workflows found for {}/{}", owner, repoName); + return List.of(); + } + + // 2. Delete previous scans for this repo before inserting fresh ones + workflowScanRepository.deleteByRepoId(repo.getId()); + + List results = new ArrayList<>(); + + for (Map file : files) { + String path = (String) file.get("path"); + if (path == null || (!path.endsWith(".yml") && !path.endsWith(".yaml"))) continue; + + // 3. Fetch raw YAML content + String downloadUrl = (String) file.get("download_url"); + if (downloadUrl == null) continue; + + String yamlContent = gitHubClient.get(downloadUrl, installationId, String.class); + if (yamlContent == null) continue; + + // 4. Parse and score + WorkflowAnalysis analysis = workflowParser.parse(path, yamlContent); + log.info( + "Scanned {}/{}/{} — score={} unpinned={} broadPerms={} missingOidc={}", + owner, + repoName, + path, + analysis.riskScore(), + analysis.unpinnedActions().size(), + analysis.broadPermissions(), + analysis.missingOidc()); + + // 5. Persist + WorkflowScanEntity entity = new WorkflowScanEntity() + .setRepo(repo) + .setWorkflowPath(path) + .setUnpinnedActions(analysis.unpinnedActions().size()) + .setBroadPermissions(analysis.broadPermissions()) + .setMissingOidc(analysis.missingOidc()) + .setRiskScore(analysis.riskScore()) + .setScannedAt(OffsetDateTime.now()); + + results.add(workflowScanRepository.save(entity)); + } + + return results; + } +} diff --git a/backend/libs/scanning/src/test/java/dev/cleat/scanning/WorkflowParserTest.java b/backend/libs/scanning/src/test/java/dev/cleat/scanning/WorkflowParserTest.java new file mode 100644 index 0000000..1133ded --- /dev/null +++ b/backend/libs/scanning/src/test/java/dev/cleat/scanning/WorkflowParserTest.java @@ -0,0 +1,83 @@ +package dev.cleat.scanning; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +class WorkflowParserTest { + + private final WorkflowParser parser = new WorkflowParser(); + + @Test + void flagsUnpinnedActionsAndMissingOidc() { + String yaml = + """ + permissions: + contents: read + + jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-java@v4 + """; + + WorkflowAnalysis result = parser.parse(".github/workflows/ci.yml", yaml); + + assertThat(result.unpinnedActions()).hasSize(2); + assertThat(result.broadPermissions()).isFalse(); + assertThat(result.missingOidc()).isTrue(); + assertThat(result.riskScore()).isGreaterThan(0); + } + + @Test + void flagsBroadPermissions() { + String yaml = + """ + permissions: write-all + + jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@abc1234567890123456789012345678901234567890 + """; + + WorkflowAnalysis result = parser.parse(".github/workflows/ci.yml", yaml); + + assertThat(result.broadPermissions()).isTrue(); + assertThat(result.unpinnedActions()).isEmpty(); + } + + @Test + void cleanWorkflowHasZeroScore() { + String yaml = + """ + permissions: + contents: read + id-token: write + + jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@abc1234567890123456789012345678901234567890 + """; + + WorkflowAnalysis result = parser.parse(".github/workflows/ci.yml", yaml); + + assertThat(result.unpinnedActions()).isEmpty(); + assertThat(result.broadPermissions()).isFalse(); + assertThat(result.missingOidc()).isFalse(); + assertThat(result.riskScore()).isZero(); + } + + @Test + void emptyYamlReturnsZeroScore() { + WorkflowAnalysis result = parser.parse(".github/workflows/empty.yml", ""); + + assertThat(result.unpinnedActions()).isEmpty(); + assertThat(result.riskScore()).isEqualTo(20); // only missingOidc penalty + } +} From 3fdd3f4f06233c6e8af3053f405efa1b0e863321 Mon Sep 17 00:00:00 2001 From: nurlanakberli9-pixel Date: Fri, 26 Jun 2026 13:47:25 -0700 Subject: [PATCH 02/23] fix(backend): merge conflict --- .../java/dev/cleat/persistence/entity/AccountEntity.java | 2 -- .../main/java/dev/cleat/persistence/entity/RepoEntity.java | 5 ----- .../java/dev/cleat/persistence/AccountRepositoryTest.java | 1 - 3 files changed, 8 deletions(-) diff --git a/backend/libs/persistence/src/main/java/dev/cleat/persistence/entity/AccountEntity.java b/backend/libs/persistence/src/main/java/dev/cleat/persistence/entity/AccountEntity.java index c5bea17..6035d3a 100644 --- a/backend/libs/persistence/src/main/java/dev/cleat/persistence/entity/AccountEntity.java +++ b/backend/libs/persistence/src/main/java/dev/cleat/persistence/entity/AccountEntity.java @@ -119,7 +119,6 @@ public AccountEntity setName(String name) { return this; } - public dev.cleat.common.enums.AccountType getType() { return type; } @@ -129,7 +128,6 @@ public AccountEntity setType(AccountType type) { return this; } - public dev.cleat.common.enums.Plan getPlan() { return plan; } diff --git a/backend/libs/persistence/src/main/java/dev/cleat/persistence/entity/RepoEntity.java b/backend/libs/persistence/src/main/java/dev/cleat/persistence/entity/RepoEntity.java index 0e10f3c..28a5815 100644 --- a/backend/libs/persistence/src/main/java/dev/cleat/persistence/entity/RepoEntity.java +++ b/backend/libs/persistence/src/main/java/dev/cleat/persistence/entity/RepoEntity.java @@ -1,6 +1,5 @@ package dev.cleat.persistence.entity; - import dev.cleat.common.enums.Visibility; import jakarta.persistence.CascadeType; import jakarta.persistence.CollectionTable; @@ -95,7 +94,6 @@ public class RepoEntity { @Column(name = "hygiene_score") private Integer hygieneScore; - @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true) @JoinColumn(name = "repo_id") private List scorecard; @@ -109,7 +107,6 @@ public class RepoEntity { @Column(name = "created_at", nullable = false, updatable = false) private OffsetDateTime createdAt; - public RepoEntity() {} public RepoEntity( @@ -135,7 +132,6 @@ public RepoEntity( Integer staleBranches, Integer openPRs, Integer hygieneScore, - List scorecard, List topics, OffsetDateTime createdAt) { @@ -365,7 +361,6 @@ public RepoEntity setHygieneScore(Integer hygieneScore) { return this; } - public List getScorecard() { return scorecard; } diff --git a/backend/libs/persistence/src/test/java/dev/cleat/persistence/AccountRepositoryTest.java b/backend/libs/persistence/src/test/java/dev/cleat/persistence/AccountRepositoryTest.java index 9b39f74..024f003 100644 --- a/backend/libs/persistence/src/test/java/dev/cleat/persistence/AccountRepositoryTest.java +++ b/backend/libs/persistence/src/test/java/dev/cleat/persistence/AccountRepositoryTest.java @@ -1,6 +1,5 @@ package dev.cleat.persistence; - import dev.cleat.common.enums.AccountType; import dev.cleat.persistence.entity.AccountEntity; import dev.cleat.persistence.entity.RepoEntity; From 86e40b766c8ddc39c2528e1b337fb9528365d0a2 Mon Sep 17 00:00:00 2001 From: Nurlan Akbarov Date: Fri, 26 Jun 2026 13:53:14 -0700 Subject: [PATCH 03/23] Update WorkflowScanRepositoryTest.java --- .../java/dev/cleat/persistence/WorkflowScanRepositoryTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/libs/persistence/src/test/java/dev/cleat/persistence/WorkflowScanRepositoryTest.java b/backend/libs/persistence/src/test/java/dev/cleat/persistence/WorkflowScanRepositoryTest.java index 59bce1b..3e74d95 100644 --- a/backend/libs/persistence/src/test/java/dev/cleat/persistence/WorkflowScanRepositoryTest.java +++ b/backend/libs/persistence/src/test/java/dev/cleat/persistence/WorkflowScanRepositoryTest.java @@ -5,7 +5,7 @@ import dev.cleat.persistence.entity.AccountEntity; import dev.cleat.persistence.entity.RepoEntity; import dev.cleat.persistence.entity.WorkflowScanEntity; -import dev.cleat.persistence.enums.AccountType; +import dev.cleat.common.enums.AccountType; import dev.cleat.persistence.repo.AccountRepository; import dev.cleat.persistence.repo.RepoRepository; import dev.cleat.persistence.repo.WorkflowScanRepository; From 7b87695cb8f828ae2b4bb40735bc60009a36972e Mon Sep 17 00:00:00 2001 From: Nurlan Akbarov Date: Fri, 26 Jun 2026 13:57:56 -0700 Subject: [PATCH 04/23] Update WorkflowScanRepositoryTest.java --- .../java/dev/cleat/persistence/WorkflowScanRepositoryTest.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/backend/libs/persistence/src/test/java/dev/cleat/persistence/WorkflowScanRepositoryTest.java b/backend/libs/persistence/src/test/java/dev/cleat/persistence/WorkflowScanRepositoryTest.java index 3e74d95..4f8ba48 100644 --- a/backend/libs/persistence/src/test/java/dev/cleat/persistence/WorkflowScanRepositoryTest.java +++ b/backend/libs/persistence/src/test/java/dev/cleat/persistence/WorkflowScanRepositoryTest.java @@ -1,11 +1,10 @@ package dev.cleat.persistence; import static org.assertj.core.api.Assertions.assertThat; - +import dev.cleat.common.enums.AccountType; import dev.cleat.persistence.entity.AccountEntity; import dev.cleat.persistence.entity.RepoEntity; import dev.cleat.persistence.entity.WorkflowScanEntity; -import dev.cleat.common.enums.AccountType; import dev.cleat.persistence.repo.AccountRepository; import dev.cleat.persistence.repo.RepoRepository; import dev.cleat.persistence.repo.WorkflowScanRepository; From 300d4d0f0455d961eabdb77fc51f77375a6d8c9b Mon Sep 17 00:00:00 2001 From: Nurlan Akbarov Date: Fri, 26 Jun 2026 14:00:41 -0700 Subject: [PATCH 05/23] Update WorkflowScanRepositoryTest.java --- .../java/dev/cleat/persistence/WorkflowScanRepositoryTest.java | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/libs/persistence/src/test/java/dev/cleat/persistence/WorkflowScanRepositoryTest.java b/backend/libs/persistence/src/test/java/dev/cleat/persistence/WorkflowScanRepositoryTest.java index 4f8ba48..b395267 100644 --- a/backend/libs/persistence/src/test/java/dev/cleat/persistence/WorkflowScanRepositoryTest.java +++ b/backend/libs/persistence/src/test/java/dev/cleat/persistence/WorkflowScanRepositoryTest.java @@ -1,6 +1,7 @@ package dev.cleat.persistence; import static org.assertj.core.api.Assertions.assertThat; + import dev.cleat.common.enums.AccountType; import dev.cleat.persistence.entity.AccountEntity; import dev.cleat.persistence.entity.RepoEntity; From a2bcfed67907269be65c49faa0961a40db1ed857 Mon Sep 17 00:00:00 2001 From: nurlanakberli9-pixel Date: Sat, 27 Jun 2026 04:52:47 -0700 Subject: [PATCH 06/23] fix(worker): add githubclient package to component scan in CleatWorkerApplication --- .../cleat/api/controller/WorkflowScanController.java | 2 +- .../dev/cleat/api/WorkflowScanControllerTest.java | 2 +- .../java/dev/cleat/worker/CleatWorkerApplication.java | 2 +- .../java/dev/cleat/persistence/enums/AccountType.java | 6 ------ .../main/java/dev/cleat/persistence/enums/Plan.java | 6 ------ .../java/dev/cleat/persistence/enums/Visibility.java | 7 ------- .../dev/cleat/persistence/repo/AccountRepository.java | 7 ------- .../dev/cleat/persistence/repo/RepoRepository.java | 11 ----------- .../{repo => repository}/WorkflowScanRepository.java | 2 +- .../cleat/persistence/WorkflowScanRepositoryTest.java | 2 +- .../java/dev/cleat/scanning/WorkflowScanService.java | 2 +- 11 files changed, 6 insertions(+), 43 deletions(-) delete mode 100644 backend/libs/persistence/src/main/java/dev/cleat/persistence/enums/AccountType.java delete mode 100644 backend/libs/persistence/src/main/java/dev/cleat/persistence/enums/Plan.java delete mode 100644 backend/libs/persistence/src/main/java/dev/cleat/persistence/enums/Visibility.java delete mode 100644 backend/libs/persistence/src/main/java/dev/cleat/persistence/repo/AccountRepository.java delete mode 100644 backend/libs/persistence/src/main/java/dev/cleat/persistence/repo/RepoRepository.java rename backend/libs/persistence/src/main/java/dev/cleat/persistence/{repo => repository}/WorkflowScanRepository.java (89%) diff --git a/backend/apps/api/src/main/java/dev/cleat/api/controller/WorkflowScanController.java b/backend/apps/api/src/main/java/dev/cleat/api/controller/WorkflowScanController.java index 7095a1d..7c20efe 100644 --- a/backend/apps/api/src/main/java/dev/cleat/api/controller/WorkflowScanController.java +++ b/backend/apps/api/src/main/java/dev/cleat/api/controller/WorkflowScanController.java @@ -1,7 +1,7 @@ package dev.cleat.api.controller; import dev.cleat.persistence.entity.WorkflowScanEntity; -import dev.cleat.persistence.repo.WorkflowScanRepository; +import dev.cleat.persistence.repository.WorkflowScanRepository; import java.time.OffsetDateTime; import java.util.List; import java.util.UUID; diff --git a/backend/apps/api/src/test/java/dev/cleat/api/WorkflowScanControllerTest.java b/backend/apps/api/src/test/java/dev/cleat/api/WorkflowScanControllerTest.java index 5f5e5cc..a2b36c8 100644 --- a/backend/apps/api/src/test/java/dev/cleat/api/WorkflowScanControllerTest.java +++ b/backend/apps/api/src/test/java/dev/cleat/api/WorkflowScanControllerTest.java @@ -8,7 +8,7 @@ import dev.cleat.persistence.enums.AccountType; import dev.cleat.persistence.repo.AccountRepository; import dev.cleat.persistence.repo.RepoRepository; -import dev.cleat.persistence.repo.WorkflowScanRepository; +import dev.cleat.persistence.repository.WorkflowScanRepository; import java.time.OffsetDateTime; import java.util.List; import org.junit.jupiter.api.Test; diff --git a/backend/apps/worker/src/main/java/dev/cleat/worker/CleatWorkerApplication.java b/backend/apps/worker/src/main/java/dev/cleat/worker/CleatWorkerApplication.java index ddec165..2285faa 100644 --- a/backend/apps/worker/src/main/java/dev/cleat/worker/CleatWorkerApplication.java +++ b/backend/apps/worker/src/main/java/dev/cleat/worker/CleatWorkerApplication.java @@ -11,7 +11,7 @@ @EnableScheduling @EntityScan(basePackages = "dev.cleat.persistence") @EnableJpaRepositories(basePackages = "dev.cleat.persistence") -@ComponentScan(basePackages = "dev.cleat.scanning") +@ComponentScan(basePackages = {"dev.cleat.worker", "dev.cleat.scanning", "dev.cleat.githubclient"}) public class CleatWorkerApplication { public static void main(String[] args) { diff --git a/backend/libs/persistence/src/main/java/dev/cleat/persistence/enums/AccountType.java b/backend/libs/persistence/src/main/java/dev/cleat/persistence/enums/AccountType.java deleted file mode 100644 index 55c0e9a..0000000 --- a/backend/libs/persistence/src/main/java/dev/cleat/persistence/enums/AccountType.java +++ /dev/null @@ -1,6 +0,0 @@ -package dev.cleat.persistence.enums; - -public enum AccountType { - USER, - ORG -} diff --git a/backend/libs/persistence/src/main/java/dev/cleat/persistence/enums/Plan.java b/backend/libs/persistence/src/main/java/dev/cleat/persistence/enums/Plan.java deleted file mode 100644 index aa4fe41..0000000 --- a/backend/libs/persistence/src/main/java/dev/cleat/persistence/enums/Plan.java +++ /dev/null @@ -1,6 +0,0 @@ -package dev.cleat.persistence.enums; - -public enum Plan { - FREE, - TEAM -} diff --git a/backend/libs/persistence/src/main/java/dev/cleat/persistence/enums/Visibility.java b/backend/libs/persistence/src/main/java/dev/cleat/persistence/enums/Visibility.java deleted file mode 100644 index 2725601..0000000 --- a/backend/libs/persistence/src/main/java/dev/cleat/persistence/enums/Visibility.java +++ /dev/null @@ -1,7 +0,0 @@ -package dev.cleat.persistence.enums; - -public enum Visibility { - PUBLIC, - PRIVATE, - INTERNAL -} diff --git a/backend/libs/persistence/src/main/java/dev/cleat/persistence/repo/AccountRepository.java b/backend/libs/persistence/src/main/java/dev/cleat/persistence/repo/AccountRepository.java deleted file mode 100644 index a062add..0000000 --- a/backend/libs/persistence/src/main/java/dev/cleat/persistence/repo/AccountRepository.java +++ /dev/null @@ -1,7 +0,0 @@ -package dev.cleat.persistence.repo; - -import dev.cleat.persistence.entity.AccountEntity; -import java.util.UUID; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface AccountRepository extends JpaRepository {} diff --git a/backend/libs/persistence/src/main/java/dev/cleat/persistence/repo/RepoRepository.java b/backend/libs/persistence/src/main/java/dev/cleat/persistence/repo/RepoRepository.java deleted file mode 100644 index 5ad465a..0000000 --- a/backend/libs/persistence/src/main/java/dev/cleat/persistence/repo/RepoRepository.java +++ /dev/null @@ -1,11 +0,0 @@ -package dev.cleat.persistence.repo; - -import dev.cleat.persistence.entity.RepoEntity; -import java.util.List; -import java.util.UUID; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface RepoRepository extends JpaRepository { - - List findByAccountId(UUID accountId); -} diff --git a/backend/libs/persistence/src/main/java/dev/cleat/persistence/repo/WorkflowScanRepository.java b/backend/libs/persistence/src/main/java/dev/cleat/persistence/repository/WorkflowScanRepository.java similarity index 89% rename from backend/libs/persistence/src/main/java/dev/cleat/persistence/repo/WorkflowScanRepository.java rename to backend/libs/persistence/src/main/java/dev/cleat/persistence/repository/WorkflowScanRepository.java index 2d91f29..c33d859 100644 --- a/backend/libs/persistence/src/main/java/dev/cleat/persistence/repo/WorkflowScanRepository.java +++ b/backend/libs/persistence/src/main/java/dev/cleat/persistence/repository/WorkflowScanRepository.java @@ -1,4 +1,4 @@ -package dev.cleat.persistence.repo; +package dev.cleat.persistence.repository; import dev.cleat.persistence.entity.WorkflowScanEntity; import java.util.List; diff --git a/backend/libs/persistence/src/test/java/dev/cleat/persistence/WorkflowScanRepositoryTest.java b/backend/libs/persistence/src/test/java/dev/cleat/persistence/WorkflowScanRepositoryTest.java index b395267..ae08540 100644 --- a/backend/libs/persistence/src/test/java/dev/cleat/persistence/WorkflowScanRepositoryTest.java +++ b/backend/libs/persistence/src/test/java/dev/cleat/persistence/WorkflowScanRepositoryTest.java @@ -8,7 +8,7 @@ import dev.cleat.persistence.entity.WorkflowScanEntity; import dev.cleat.persistence.repo.AccountRepository; import dev.cleat.persistence.repo.RepoRepository; -import dev.cleat.persistence.repo.WorkflowScanRepository; +import dev.cleat.persistence.repository.WorkflowScanRepository; import java.time.OffsetDateTime; import java.util.List; import org.junit.jupiter.api.Test; diff --git a/backend/libs/scanning/src/main/java/dev/cleat/scanning/WorkflowScanService.java b/backend/libs/scanning/src/main/java/dev/cleat/scanning/WorkflowScanService.java index 5c371fa..7d208ae 100644 --- a/backend/libs/scanning/src/main/java/dev/cleat/scanning/WorkflowScanService.java +++ b/backend/libs/scanning/src/main/java/dev/cleat/scanning/WorkflowScanService.java @@ -3,7 +3,7 @@ import dev.cleat.githubclient.service.GitHubClient; import dev.cleat.persistence.entity.RepoEntity; import dev.cleat.persistence.entity.WorkflowScanEntity; -import dev.cleat.persistence.repo.WorkflowScanRepository; +import dev.cleat.persistence.repository.WorkflowScanRepository; import java.time.OffsetDateTime; import java.util.ArrayList; import java.util.List; From b4786e80953c99bbb930ac51f0a7ca843ce13905 Mon Sep 17 00:00:00 2001 From: nurlanakberli9-pixel Date: Sat, 27 Jun 2026 05:03:39 -0700 Subject: [PATCH 07/23] fix: resolve missing repository imports in tests --- .../dev/cleat/persistence/WorkflowScanRepositoryTest.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/libs/persistence/src/test/java/dev/cleat/persistence/WorkflowScanRepositoryTest.java b/backend/libs/persistence/src/test/java/dev/cleat/persistence/WorkflowScanRepositoryTest.java index ae08540..badb91d 100644 --- a/backend/libs/persistence/src/test/java/dev/cleat/persistence/WorkflowScanRepositoryTest.java +++ b/backend/libs/persistence/src/test/java/dev/cleat/persistence/WorkflowScanRepositoryTest.java @@ -6,8 +6,8 @@ import dev.cleat.persistence.entity.AccountEntity; import dev.cleat.persistence.entity.RepoEntity; import dev.cleat.persistence.entity.WorkflowScanEntity; -import dev.cleat.persistence.repo.AccountRepository; -import dev.cleat.persistence.repo.RepoRepository; +import dev.cleat.persistence.repository.AccountRepository; +import dev.cleat.persistence.repository.RepoRepository; import dev.cleat.persistence.repository.WorkflowScanRepository; import java.time.OffsetDateTime; import java.util.List; From af8216c47690d6c8b42bda9e4b60685b622fb661 Mon Sep 17 00:00:00 2001 From: nurlanakberli9-pixel Date: Sat, 27 Jun 2026 05:24:24 -0700 Subject: [PATCH 08/23] feat: add installationId to AccountEntity and fix migration versioning --- .../dev/cleat/worker/WorkflowScanJob.java | 2 +- .../persistence/entity/AccountEntity.java | 12 + ...sql => V3__update_repo_and_add_tables.sql} | 326 +++++++++--------- 3 files changed, 176 insertions(+), 164 deletions(-) rename backend/libs/persistence/src/main/resources/db/migration/{V2__update_repo_and_add_tables.sql => V3__update_repo_and_add_tables.sql} (97%) diff --git a/backend/apps/worker/src/main/java/dev/cleat/worker/WorkflowScanJob.java b/backend/apps/worker/src/main/java/dev/cleat/worker/WorkflowScanJob.java index 8388ebd..74106f6 100644 --- a/backend/apps/worker/src/main/java/dev/cleat/worker/WorkflowScanJob.java +++ b/backend/apps/worker/src/main/java/dev/cleat/worker/WorkflowScanJob.java @@ -1,7 +1,7 @@ package dev.cleat.worker; import dev.cleat.persistence.entity.RepoEntity; -import dev.cleat.persistence.repo.AccountRepository; +import dev.cleat.persistence.repository.AccountRepository; import dev.cleat.scanning.WorkflowScanService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; diff --git a/backend/libs/persistence/src/main/java/dev/cleat/persistence/entity/AccountEntity.java b/backend/libs/persistence/src/main/java/dev/cleat/persistence/entity/AccountEntity.java index 6035d3a..19f75f4 100644 --- a/backend/libs/persistence/src/main/java/dev/cleat/persistence/entity/AccountEntity.java +++ b/backend/libs/persistence/src/main/java/dev/cleat/persistence/entity/AccountEntity.java @@ -27,6 +27,9 @@ public class AccountEntity { @GeneratedValue(strategy = GenerationType.UUID) private UUID id; + @Column(name = "installation_id") + private String installationId; + @Column(name = "login", nullable = false) private String login; @@ -199,4 +202,13 @@ public AccountEntity setRepos(List repos) { this.repos = repos; return this; } + + public String getInstallationId() { + return installationId; + } + + public AccountEntity setInstallationId(String installationId) { + this.installationId = installationId; + return this; + } } diff --git a/backend/libs/persistence/src/main/resources/db/migration/V2__update_repo_and_add_tables.sql b/backend/libs/persistence/src/main/resources/db/migration/V3__update_repo_and_add_tables.sql similarity index 97% rename from backend/libs/persistence/src/main/resources/db/migration/V2__update_repo_and_add_tables.sql rename to backend/libs/persistence/src/main/resources/db/migration/V3__update_repo_and_add_tables.sql index 93df4f9..f68add4 100644 --- a/backend/libs/persistence/src/main/resources/db/migration/V2__update_repo_and_add_tables.sql +++ b/backend/libs/persistence/src/main/resources/db/migration/V3__update_repo_and_add_tables.sql @@ -1,163 +1,163 @@ -CREATE TABLE scorecard_check( - id UUID PRIMARY KEY, - name VARCHAR(255), - score INTEGER, - reason TEXT, - repo_id UUID, - - CONSTRAINT fk_scorecard_repo - FOREIGN KEY (repo_id) - REFERENCES repo(id) - ON DELETE CASCADE -); - -CREATE TABLE repo_topics( - repo_id UUID NOT NULL, - topics VARCHAR(255), - - CONSTRAINT fk_repo_topics - FOREIGN KEY (repo_id) - REFERENCES repo(id) - ON DELETE CASCADE -); - -CREATE TABLE activity_event( - id UUID PRIMARY KEY, - account_id UUID, - type VARCHAR(255), - severity VARCHAR(50), - actor VARCHAR(255), - target VARCHAR(255), - repo UUID, - message TEXT, - created_at TIMESTAMP WITH TIME ZONE, - category VARCHAR(50), - - CONSTRAINT fk_activity_account - FOREIGN KEY (account_id) - REFERENCES account(id), - - CONSTRAINT fk_activity_repo - FOREIGN KEY (repo) - REFERENCES repo(id) -); - -CREATE TABLE member( - id UUID PRIMARY KEY, - login VARCHAR(255), - name VARCHAR(255), - role VARCHAR(50), - two_factor BOOLEAN, - last_active_at TIMESTAMP WITH TIME ZONE, - outside_collaborator BOOLEAN, - repo_access INTEGER, - account_id UUID, - - - CONSTRAINT fk_member_account - FOREIGN KEY(account_id) - REFERENCES account(id) - -); - -CREATE TABLE member_teams( - member_id UUID PRIMARY KEY, - teams VARCHAR(255), - CONSTRAINT fk_member_teams - FOREIGN KEY (member_id) - REFERENCES member(id) - -); - -CREATE TABLE secret_finding( - id UUID PRIMARY KEY, - account_id UUID, - repo UUID, - provider VARCHAR(255), - secret_type VARCHAR(255), - file VARCHAR(255), - line INTEGER, - commit VARCHAR(255), - author VARCHAR(255), - detected_at TIMESTAMP WITH TIME ZONE, - validity VARCHAR(50), - severity VARCHAR(50), - push_protection_blocked BOOLEAN, - - CONSTRAINT fk_secret_account - FOREIGN KEY(account_id) - REFERENCES account(id), - - CONSTRAINT fk_secret_repo - FOREIGN KEY (repo) - REFERENCES repo(id) -); - -CREATE TABLE usage( - id UUID PRIMARY KEY, - actions_minutes INTEGER, - minutes_included INTEGER, - storage_gb DOUBLE PRECISION, - monthly_cost DECIMAL(19,4) DEFAULT 0.0, - reclaimable DECIMAL(19,4) DEFAULT 0.0, - account_id UUID, - - CONSTRAINT fk_usage_account - FOREIGN KEY(account_id) - REFERENCES account(id) -); - -CREATE TABLE usage_breakdown( - usage_id UUID NOT NULL, - breakdown VARCHAR(255), - - CONSTRAINT fk_usage_breakdown - FOREIGN KEY(usage_id) - REFERENCES usage(id) -); - -CREATE TABLE usage_point( - id UUID PRIMARY KEY, - label VARCHAR(255), - minutes INTEGER, - storage_gb DOUBLE PRECISION, - cost DECIMAL(19, 4) DEFAULT 0.0, - usage_id UUID, - - CONSTRAINT fk_usage_point_usage - FOREIGN KEY(usage_id) - REFERENCES usage(id) - -); - -CREATE TABLE vulnerability( - id UUID PRIMARY KEY, - account_id UUID, - package_name VARCHAR(255), - ecosystem VARCHAR(255), - current_version VARCHAR(255), - fixed_version VARCHAR(255), - cvss DOUBLE PRECISION, - severity VARCHAR(50), - epss DOUBLE PRECISION, - kev BOOLEAN, - reachable VARCHAR(50), - advisory_id UUID, - cwe VARCHAR(255), - title VARCHAR(255), - has_fix_pr BOOLEAN, - published_at TIMESTAMP WITH TIME ZONE - -); - -CREATE TABLE vulnerability_affected_repos( - vulnerability_id UUID NOT NULL, - affected_repos VARCHAR(255), - - CONSTRAINT fk_vulnerability_affected_repos - FOREIGN KEY(vulnerability_id) - REFERENCES vulnerability(id) - -); - - +CREATE TABLE scorecard_check( + id UUID PRIMARY KEY, + name VARCHAR(255), + score INTEGER, + reason TEXT, + repo_id UUID, + + CONSTRAINT fk_scorecard_repo + FOREIGN KEY (repo_id) + REFERENCES repo(id) + ON DELETE CASCADE +); + +CREATE TABLE repo_topics( + repo_id UUID NOT NULL, + topics VARCHAR(255), + + CONSTRAINT fk_repo_topics + FOREIGN KEY (repo_id) + REFERENCES repo(id) + ON DELETE CASCADE +); + +CREATE TABLE activity_event( + id UUID PRIMARY KEY, + account_id UUID, + type VARCHAR(255), + severity VARCHAR(50), + actor VARCHAR(255), + target VARCHAR(255), + repo UUID, + message TEXT, + created_at TIMESTAMP WITH TIME ZONE, + category VARCHAR(50), + + CONSTRAINT fk_activity_account + FOREIGN KEY (account_id) + REFERENCES account(id), + + CONSTRAINT fk_activity_repo + FOREIGN KEY (repo) + REFERENCES repo(id) +); + +CREATE TABLE member( + id UUID PRIMARY KEY, + login VARCHAR(255), + name VARCHAR(255), + role VARCHAR(50), + two_factor BOOLEAN, + last_active_at TIMESTAMP WITH TIME ZONE, + outside_collaborator BOOLEAN, + repo_access INTEGER, + account_id UUID, + + + CONSTRAINT fk_member_account + FOREIGN KEY(account_id) + REFERENCES account(id) + +); + +CREATE TABLE member_teams( + member_id UUID PRIMARY KEY, + teams VARCHAR(255), + CONSTRAINT fk_member_teams + FOREIGN KEY (member_id) + REFERENCES member(id) + +); + +CREATE TABLE secret_finding( + id UUID PRIMARY KEY, + account_id UUID, + repo UUID, + provider VARCHAR(255), + secret_type VARCHAR(255), + file VARCHAR(255), + line INTEGER, + commit VARCHAR(255), + author VARCHAR(255), + detected_at TIMESTAMP WITH TIME ZONE, + validity VARCHAR(50), + severity VARCHAR(50), + push_protection_blocked BOOLEAN, + + CONSTRAINT fk_secret_account + FOREIGN KEY(account_id) + REFERENCES account(id), + + CONSTRAINT fk_secret_repo + FOREIGN KEY (repo) + REFERENCES repo(id) +); + +CREATE TABLE usage( + id UUID PRIMARY KEY, + actions_minutes INTEGER, + minutes_included INTEGER, + storage_gb DOUBLE PRECISION, + monthly_cost DECIMAL(19,4) DEFAULT 0.0, + reclaimable DECIMAL(19,4) DEFAULT 0.0, + account_id UUID, + + CONSTRAINT fk_usage_account + FOREIGN KEY(account_id) + REFERENCES account(id) +); + +CREATE TABLE usage_breakdown( + usage_id UUID NOT NULL, + breakdown VARCHAR(255), + + CONSTRAINT fk_usage_breakdown + FOREIGN KEY(usage_id) + REFERENCES usage(id) +); + +CREATE TABLE usage_point( + id UUID PRIMARY KEY, + label VARCHAR(255), + minutes INTEGER, + storage_gb DOUBLE PRECISION, + cost DECIMAL(19, 4) DEFAULT 0.0, + usage_id UUID, + + CONSTRAINT fk_usage_point_usage + FOREIGN KEY(usage_id) + REFERENCES usage(id) + +); + +CREATE TABLE vulnerability( + id UUID PRIMARY KEY, + account_id UUID, + package_name VARCHAR(255), + ecosystem VARCHAR(255), + current_version VARCHAR(255), + fixed_version VARCHAR(255), + cvss DOUBLE PRECISION, + severity VARCHAR(50), + epss DOUBLE PRECISION, + kev BOOLEAN, + reachable VARCHAR(50), + advisory_id UUID, + cwe VARCHAR(255), + title VARCHAR(255), + has_fix_pr BOOLEAN, + published_at TIMESTAMP WITH TIME ZONE + +); + +CREATE TABLE vulnerability_affected_repos( + vulnerability_id UUID NOT NULL, + affected_repos VARCHAR(255), + + CONSTRAINT fk_vulnerability_affected_repos + FOREIGN KEY(vulnerability_id) + REFERENCES vulnerability(id) + +); + + From 1748dc5f2edd7637c61547fd8a9e1d0a8bb522d6 Mon Sep 17 00:00:00 2001 From: nurlanakberli9-pixel Date: Sat, 27 Jun 2026 05:34:13 -0700 Subject: [PATCH 09/23] feat: add installationId to Account entity and update database schema - Added installationId field (String) to AccountEntity - Updated V1 migration script to include installation_id column - Fixed compilation and consistency issues across modules --- .../java/dev/cleat/api/WorkflowScanControllerTest.java | 6 +++--- .../src/main/java/dev/cleat/worker/WorkflowScanJob.java | 8 ++++---- .../db/migration/V1__create_account_and_repo_tables.sql | 3 ++- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/backend/apps/api/src/test/java/dev/cleat/api/WorkflowScanControllerTest.java b/backend/apps/api/src/test/java/dev/cleat/api/WorkflowScanControllerTest.java index a2b36c8..52a4652 100644 --- a/backend/apps/api/src/test/java/dev/cleat/api/WorkflowScanControllerTest.java +++ b/backend/apps/api/src/test/java/dev/cleat/api/WorkflowScanControllerTest.java @@ -2,12 +2,12 @@ import static org.assertj.core.api.Assertions.assertThat; +import dev.cleat.common.enums.AccountType; import dev.cleat.persistence.entity.AccountEntity; import dev.cleat.persistence.entity.RepoEntity; import dev.cleat.persistence.entity.WorkflowScanEntity; -import dev.cleat.persistence.enums.AccountType; -import dev.cleat.persistence.repo.AccountRepository; -import dev.cleat.persistence.repo.RepoRepository; +import dev.cleat.persistence.repository.AccountRepository; +import dev.cleat.persistence.repository.RepoRepository; import dev.cleat.persistence.repository.WorkflowScanRepository; import java.time.OffsetDateTime; import java.util.List; diff --git a/backend/apps/worker/src/main/java/dev/cleat/worker/WorkflowScanJob.java b/backend/apps/worker/src/main/java/dev/cleat/worker/WorkflowScanJob.java index 74106f6..b2634e4 100644 --- a/backend/apps/worker/src/main/java/dev/cleat/worker/WorkflowScanJob.java +++ b/backend/apps/worker/src/main/java/dev/cleat/worker/WorkflowScanJob.java @@ -11,7 +11,7 @@ @Component public class WorkflowScanJob { - private static final Logger log = LoggerFactory.getLogger(WorkflowScanJob.class); + private static final Logger LOG = LoggerFactory.getLogger(WorkflowScanJob.class); private final AccountRepository accountRepository; private final WorkflowScanService workflowScanService; @@ -23,7 +23,7 @@ public WorkflowScanJob(AccountRepository accountRepository, WorkflowScanService @Scheduled(fixedDelayString = "${cleat.workflow-scan.interval-ms:3600000}") public void run() { - log.info("Starting workflow scan job"); + LOG.info("Starting workflow scan job"); accountRepository.findAll().forEach(account -> { String installationId = account.getInstallationId(); @@ -32,11 +32,11 @@ public void run() { try { workflowScanService.scanRepo(repo, installationId); } catch (Exception e) { - log.error("Failed to scan workflows for repo {}/{}", account.getLogin(), repo.getName(), e); + LOG.error("Failed to scan workflows for repo {}/{}", account.getLogin(), repo.getName(), e); } } }); - log.info("Workflow scan job completed"); + LOG.info("Workflow scan job completed"); } } diff --git a/backend/libs/persistence/src/main/resources/db/migration/V1__create_account_and_repo_tables.sql b/backend/libs/persistence/src/main/resources/db/migration/V1__create_account_and_repo_tables.sql index 9c7ccd8..3a0fce2 100644 --- a/backend/libs/persistence/src/main/resources/db/migration/V1__create_account_and_repo_tables.sql +++ b/backend/libs/persistence/src/main/resources/db/migration/V1__create_account_and_repo_tables.sql @@ -9,7 +9,8 @@ CREATE TABLE account ( posture_score INTEGER DEFAULT 0, monthly_spend DECIMAL(19,4) DEFAULT 0.0, reclaimable DECIMAL(19,4) DEFAULT 0.0, - created_at TIMESTAMP WITH TIME ZONE NOT NULL + created_at TIMESTAMP WITH TIME ZONE NOT NULL, + installation_id VARCHAR(255) ); CREATE TABLE repo ( From a0cbe5706ca3f7db70a658dacb9ec469385e08f7 Mon Sep 17 00:00:00 2001 From: nurlanakberli9-pixel Date: Sat, 27 Jun 2026 08:48:53 -0700 Subject: [PATCH 10/23] fix: resolve checkstyle violations --- .../dev/cleat/scanning/WorkflowParser.java | 20 ++++++++++++++----- .../cleat/scanning/WorkflowScanService.java | 18 +++++++++++------ 2 files changed, 27 insertions(+), 11 deletions(-) diff --git a/backend/libs/scanning/src/main/java/dev/cleat/scanning/WorkflowParser.java b/backend/libs/scanning/src/main/java/dev/cleat/scanning/WorkflowParser.java index 2e98a7f..8d99543 100644 --- a/backend/libs/scanning/src/main/java/dev/cleat/scanning/WorkflowParser.java +++ b/backend/libs/scanning/src/main/java/dev/cleat/scanning/WorkflowParser.java @@ -41,7 +41,9 @@ public WorkflowAnalysis parse(String workflowPath, String yamlContent) { Object jobsObj = workflow.get("jobs"); if (jobsObj instanceof Map jobs) { for (Object jobVal : jobs.values()) { - if (!(jobVal instanceof Map job)) continue; + if (!(jobVal instanceof Map job)) { + continue; + } // Per-job permissions can also grant OIDC Object jobPerms = job.get("permissions"); @@ -50,10 +52,14 @@ public WorkflowAnalysis parse(String workflowPath, String yamlContent) { } Object stepsObj = job.get("steps"); - if (!(stepsObj instanceof List steps)) continue; + if (!(stepsObj instanceof List steps)) { + continue; + } for (Object stepObj : steps) { - if (!(stepObj instanceof Map step)) continue; + if (!(stepObj instanceof Map step)) { + continue; + } Object uses = step.get("uses"); if (uses instanceof String action && !action.isBlank()) { if (!PINNED.matcher(action).matches()) { @@ -72,8 +78,12 @@ private WorkflowAnalysis scored( int score = 0; score += unpinnedActions.size() * 30; - if (broadPermissions) score += 40; - if (missingOidc) score += 20; + if (broadPermissions) { + score += 40; + } + if (missingOidc) { + score += 20; + } score = Math.min(score, 100); return new WorkflowAnalysis(workflowPath, unpinnedActions, broadPermissions, missingOidc, score); diff --git a/backend/libs/scanning/src/main/java/dev/cleat/scanning/WorkflowScanService.java b/backend/libs/scanning/src/main/java/dev/cleat/scanning/WorkflowScanService.java index 7d208ae..28b2b6d 100644 --- a/backend/libs/scanning/src/main/java/dev/cleat/scanning/WorkflowScanService.java +++ b/backend/libs/scanning/src/main/java/dev/cleat/scanning/WorkflowScanService.java @@ -16,7 +16,7 @@ @Service public class WorkflowScanService { - private static final Logger log = LoggerFactory.getLogger(WorkflowScanService.class); + private static final Logger LOG = LoggerFactory.getLogger(WorkflowScanService.class); private final GitHubClient gitHubClient; private final WorkflowParser workflowParser; @@ -40,7 +40,7 @@ public List scanRepo(RepoEntity repo, String installationId) "/repos/" + owner + "/" + repoName + "/contents/.github/workflows", installationId, List.class); if (files == null || files.isEmpty()) { - log.debug("No workflows found for {}/{}", owner, repoName); + LOG.debug("No workflows found for {}/{}", owner, repoName); return List.of(); } @@ -51,18 +51,24 @@ public List scanRepo(RepoEntity repo, String installationId) for (Map file : files) { String path = (String) file.get("path"); - if (path == null || (!path.endsWith(".yml") && !path.endsWith(".yaml"))) continue; + if (path == null || (!path.endsWith(".yml") && !path.endsWith(".yaml"))) { + continue; + } // 3. Fetch raw YAML content String downloadUrl = (String) file.get("download_url"); - if (downloadUrl == null) continue; + if (downloadUrl == null) { + continue; + } String yamlContent = gitHubClient.get(downloadUrl, installationId, String.class); - if (yamlContent == null) continue; + if (yamlContent == null) { + continue; + } // 4. Parse and score WorkflowAnalysis analysis = workflowParser.parse(path, yamlContent); - log.info( + LOG.info( "Scanned {}/{}/{} — score={} unpinned={} broadPerms={} missingOidc={}", owner, repoName, From 7787e1fa47ec106cead07a74b26ecdebfe22ad65 Mon Sep 17 00:00:00 2001 From: nurlanakberli9-pixel Date: Sat, 27 Jun 2026 09:11:51 -0700 Subject: [PATCH 11/23] fix: resolve CI build failures in test modules - Fix JUnit Platform launcher dependency in :libs:scanning - Resolve NoUniqueBeanDefinitionException in :apps:worker test context - Update API integration tests to match updated AccountEntity structure --- .../cleat/api/WorkflowScanControllerTest.java | 16 ++++++++++------ .../cleat/worker/AbstractIntegrationTest.java | 2 -- backend/libs/scanning/build.gradle.kts | 4 ++++ 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/backend/apps/api/src/test/java/dev/cleat/api/WorkflowScanControllerTest.java b/backend/apps/api/src/test/java/dev/cleat/api/WorkflowScanControllerTest.java index 52a4652..d24b01c 100644 --- a/backend/apps/api/src/test/java/dev/cleat/api/WorkflowScanControllerTest.java +++ b/backend/apps/api/src/test/java/dev/cleat/api/WorkflowScanControllerTest.java @@ -31,9 +31,11 @@ class WorkflowScanControllerTest extends AbstractIntegrationTest { @Test void returnsWorkflowScansForRepo() { - // Arrange — DB-yə account, repo, scan yaz - AccountEntity account = - new AccountEntity().setLogin("octocat").setName("Octocat").setType(AccountType.USER); + AccountEntity account = new AccountEntity() + .setLogin("octocat") + .setName("Octocat") + .setType(AccountType.USER) + .setInstallationId("inst-123"); accountRepository.saveAndFlush(account); RepoEntity repo = new RepoEntity().setAccount(account).setName("hello-world"); @@ -48,7 +50,6 @@ void returnsWorkflowScansForRepo() { .setRiskScore(80) .setScannedAt(OffsetDateTime.now())); - // Act — endpoint-ə sorğu at var response = restTemplate.getForEntity("/api/repos/" + repo.getId() + "/workflow-scans", List.class); // Assert @@ -58,8 +59,11 @@ void returnsWorkflowScansForRepo() { @Test void returnsEmptyListWhenNoScans() { - AccountEntity account = - new AccountEntity().setLogin("octocat3").setName("Octocat3").setType(AccountType.USER); + AccountEntity account = new AccountEntity() + .setLogin("octocat3") + .setName("Octocat3") + .setType(AccountType.USER) + .setInstallationId("inst-456"); accountRepository.saveAndFlush(account); RepoEntity repo = new RepoEntity().setAccount(account).setName("empty-repo"); diff --git a/backend/apps/worker/src/test/java/dev/cleat/worker/AbstractIntegrationTest.java b/backend/apps/worker/src/test/java/dev/cleat/worker/AbstractIntegrationTest.java index 63842d6..16315f1 100644 --- a/backend/apps/worker/src/test/java/dev/cleat/worker/AbstractIntegrationTest.java +++ b/backend/apps/worker/src/test/java/dev/cleat/worker/AbstractIntegrationTest.java @@ -1,6 +1,5 @@ package dev.cleat.worker; -import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.testcontainers.service.connection.ServiceConnection; import org.testcontainers.containers.GenericContainer; import org.testcontainers.containers.PostgreSQLContainer; @@ -8,7 +7,6 @@ import org.testcontainers.junit.jupiter.Testcontainers; @Testcontainers -@SpringBootTest public abstract class AbstractIntegrationTest { @Container diff --git a/backend/libs/scanning/build.gradle.kts b/backend/libs/scanning/build.gradle.kts index e1c5dde..e1121ad 100644 --- a/backend/libs/scanning/build.gradle.kts +++ b/backend/libs/scanning/build.gradle.kts @@ -12,4 +12,8 @@ dependencies { testImplementation("org.springframework.boot:spring-boot-starter-test") implementation("org.yaml:snakeyaml") + + testImplementation("org.testcontainers:junit-jupiter") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") + } From 622e6666fc21adfa25da243e88d066495c01535a Mon Sep 17 00:00:00 2001 From: nurlanakberli9-pixel Date: Sat, 27 Jun 2026 09:38:41 -0700 Subject: [PATCH 12/23] =?UTF-8?q?fix(worker):=20narrow=20EntityScan=20and?= =?UTF-8?q?=20EnableJpaRepositories=20to=20specific=20packages=EE=81=96?= =?UTF-8?q?=EE=80=BB=EE=83=81=EE=83=BB=EE=83=B9=EE=84=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/test/java/dev/cleat/scanning/WorkflowParserTest.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/libs/scanning/src/test/java/dev/cleat/scanning/WorkflowParserTest.java b/backend/libs/scanning/src/test/java/dev/cleat/scanning/WorkflowParserTest.java index 1133ded..1bd7c07 100644 --- a/backend/libs/scanning/src/test/java/dev/cleat/scanning/WorkflowParserTest.java +++ b/backend/libs/scanning/src/test/java/dev/cleat/scanning/WorkflowParserTest.java @@ -41,7 +41,7 @@ void flagsBroadPermissions() { build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@abc1234567890123456789012345678901234567890 + - uses: actions/checkout@abc123456789012345678901234567890123456789 """; WorkflowAnalysis result = parser.parse(".github/workflows/ci.yml", yaml); @@ -62,7 +62,7 @@ void cleanWorkflowHasZeroScore() { build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@abc1234567890123456789012345678901234567890 + - uses: actions/checkout@abc123456789012345678901234567890123456789 """; WorkflowAnalysis result = parser.parse(".github/workflows/ci.yml", yaml); From 48c35643a6955578d89e5be0edeea827d89575ed Mon Sep 17 00:00:00 2001 From: nurlanakberli9-pixel Date: Sat, 27 Jun 2026 12:39:44 -0700 Subject: [PATCH 13/23] =?UTF-8?q?fix(scanning):=20replace=20invalid=20SHA?= =?UTF-8?q?=20in=20WorkflowParserTest=20with=20valid=20hex=20SHA=EE=81=96?= =?UTF-8?q?=EE=80=BB=EE=83=81=EE=83=BB=EE=83=B9=EE=84=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/dev/cleat/api/WorkflowScanControllerTest.java | 8 ++++++-- backend/apps/api/src/test/resources/application.yml | 5 +++++ .../java/dev/cleat/worker/CleatWorkerApplication.java | 4 ++-- .../test/java/dev/cleat/scanning/WorkflowParserTest.java | 4 ++-- 4 files changed, 15 insertions(+), 6 deletions(-) create mode 100644 backend/apps/api/src/test/resources/application.yml diff --git a/backend/apps/api/src/test/java/dev/cleat/api/WorkflowScanControllerTest.java b/backend/apps/api/src/test/java/dev/cleat/api/WorkflowScanControllerTest.java index d24b01c..295cd3b 100644 --- a/backend/apps/api/src/test/java/dev/cleat/api/WorkflowScanControllerTest.java +++ b/backend/apps/api/src/test/java/dev/cleat/api/WorkflowScanControllerTest.java @@ -50,7 +50,9 @@ void returnsWorkflowScansForRepo() { .setRiskScore(80) .setScannedAt(OffsetDateTime.now())); - var response = restTemplate.getForEntity("/api/repos/" + repo.getId() + "/workflow-scans", List.class); + var response = restTemplate + .withBasicAuth("test", "test") + .getForEntity("/api/repos/" + repo.getId() + "/workflow-scans", List.class); // Assert assertThat(response.getStatusCode().is2xxSuccessful()).isTrue(); @@ -69,7 +71,9 @@ void returnsEmptyListWhenNoScans() { RepoEntity repo = new RepoEntity().setAccount(account).setName("empty-repo"); repoRepository.saveAndFlush(repo); - var response = restTemplate.getForEntity("/api/repos/" + repo.getId() + "/workflow-scans", List.class); + var response = restTemplate + .withBasicAuth("test", "test") + .getForEntity("/api/repos/" + repo.getId() + "/workflow-scans", List.class); assertThat(response.getStatusCode().is2xxSuccessful()).isTrue(); assertThat(response.getBody()).isEmpty(); diff --git a/backend/apps/api/src/test/resources/application.yml b/backend/apps/api/src/test/resources/application.yml new file mode 100644 index 0000000..7c3769f --- /dev/null +++ b/backend/apps/api/src/test/resources/application.yml @@ -0,0 +1,5 @@ +spring: + security: + user: + name: test + password: test \ No newline at end of file diff --git a/backend/apps/worker/src/main/java/dev/cleat/worker/CleatWorkerApplication.java b/backend/apps/worker/src/main/java/dev/cleat/worker/CleatWorkerApplication.java index 2285faa..a570ab9 100644 --- a/backend/apps/worker/src/main/java/dev/cleat/worker/CleatWorkerApplication.java +++ b/backend/apps/worker/src/main/java/dev/cleat/worker/CleatWorkerApplication.java @@ -9,8 +9,8 @@ @SpringBootApplication @EnableScheduling -@EntityScan(basePackages = "dev.cleat.persistence") -@EnableJpaRepositories(basePackages = "dev.cleat.persistence") +@EntityScan(basePackages = "dev.cleat.persistence.entity") +@EnableJpaRepositories(basePackages = "dev.cleat.persistence.repository") @ComponentScan(basePackages = {"dev.cleat.worker", "dev.cleat.scanning", "dev.cleat.githubclient"}) public class CleatWorkerApplication { diff --git a/backend/libs/scanning/src/test/java/dev/cleat/scanning/WorkflowParserTest.java b/backend/libs/scanning/src/test/java/dev/cleat/scanning/WorkflowParserTest.java index 1bd7c07..f391831 100644 --- a/backend/libs/scanning/src/test/java/dev/cleat/scanning/WorkflowParserTest.java +++ b/backend/libs/scanning/src/test/java/dev/cleat/scanning/WorkflowParserTest.java @@ -41,7 +41,7 @@ void flagsBroadPermissions() { build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@abc123456789012345678901234567890123456789 + - uses: actions/checkout@a81bbbf8298c0fa03ea29cdc473d45769f953675 """; WorkflowAnalysis result = parser.parse(".github/workflows/ci.yml", yaml); @@ -62,7 +62,7 @@ void cleanWorkflowHasZeroScore() { build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@abc123456789012345678901234567890123456789 + - uses: actions/checkout@a81bbbf8298c0fa03ea29cdc473d45769f953675 """; WorkflowAnalysis result = parser.parse(".github/workflows/ci.yml", yaml); From 428728a4a9eb4fb870df08d8596a728b9c372573 Mon Sep 17 00:00:00 2001 From: nurlanakberli9-pixel Date: Sat, 27 Jun 2026 12:50:25 -0700 Subject: [PATCH 14/23] =?UTF-8?q?fix(github-client):=20mark=20redisTemplat?= =?UTF-8?q?e=20bean=20as=20@Primary=20to=20resolve=20NoUniqueBeanDefinitio?= =?UTF-8?q?nException=EE=81=96=EE=80=BB=EE=83=81=EE=83=BB=EE=83=B9?= =?UTF-8?q?=EE=84=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/dev/cleat/githubclient/config/GitHubConfig.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/backend/libs/github-client/src/main/java/dev/cleat/githubclient/config/GitHubConfig.java b/backend/libs/github-client/src/main/java/dev/cleat/githubclient/config/GitHubConfig.java index e18c52c..5748eb6 100644 --- a/backend/libs/github-client/src/main/java/dev/cleat/githubclient/config/GitHubConfig.java +++ b/backend/libs/github-client/src/main/java/dev/cleat/githubclient/config/GitHubConfig.java @@ -2,6 +2,7 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.serializer.StringRedisSerializer; @@ -16,6 +17,7 @@ public WebClient gitHubWebClient(WebClient.Builder builder) { } @Bean + @Primary public RedisTemplate redisTemplate(RedisConnectionFactory connectionFactory) { RedisTemplate template = new RedisTemplate<>(); template.setConnectionFactory(connectionFactory); From eca70f6b7211f944ac0a46b5d3dd3fc514ad06a5 Mon Sep 17 00:00:00 2001 From: nurlanakberli9-pixel Date: Sat, 27 Jun 2026 12:55:55 -0700 Subject: [PATCH 15/23] fix(worker): add test application.yml with required GitHub and scan properties --- backend/apps/worker/src/test/resources/application.yml | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 backend/apps/worker/src/test/resources/application.yml diff --git a/backend/apps/worker/src/test/resources/application.yml b/backend/apps/worker/src/test/resources/application.yml new file mode 100644 index 0000000..9fd2379 --- /dev/null +++ b/backend/apps/worker/src/test/resources/application.yml @@ -0,0 +1,7 @@ +yamlgithub: + app-id: "test-app-id" + private-key-path: "/tmp/test-key.pem" + +cleat: + workflow-scan: + interval-ms: 3600000 \ No newline at end of file From 035e1e2d05599692ca6b1ef4bd6289d142521cac Mon Sep 17 00:00:00 2001 From: nurlanakberli9-pixel Date: Sun, 28 Jun 2026 08:56:46 -0700 Subject: [PATCH 16/23] fix(worker): fix YAML syntax in test application.yml --- backend/apps/worker/src/test/resources/application.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/apps/worker/src/test/resources/application.yml b/backend/apps/worker/src/test/resources/application.yml index 9fd2379..697242c 100644 --- a/backend/apps/worker/src/test/resources/application.yml +++ b/backend/apps/worker/src/test/resources/application.yml @@ -1,4 +1,4 @@ -yamlgithub: +github: app-id: "test-app-id" private-key-path: "/tmp/test-key.pem" From d4d489bf1f0bdb25f70cfade591e8f6890d129c3 Mon Sep 17 00:00:00 2001 From: nurlanakberli9-pixel Date: Sun, 28 Jun 2026 11:00:20 -0700 Subject: [PATCH 17/23] fix: resolve LazyInitializationException by enabling @Transactional for WorkflowScanJob --- .../worker/src/main/java/dev/cleat/worker/WorkflowScanJob.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/backend/apps/worker/src/main/java/dev/cleat/worker/WorkflowScanJob.java b/backend/apps/worker/src/main/java/dev/cleat/worker/WorkflowScanJob.java index b2634e4..7265c51 100644 --- a/backend/apps/worker/src/main/java/dev/cleat/worker/WorkflowScanJob.java +++ b/backend/apps/worker/src/main/java/dev/cleat/worker/WorkflowScanJob.java @@ -3,6 +3,7 @@ import dev.cleat.persistence.entity.RepoEntity; import dev.cleat.persistence.repository.AccountRepository; import dev.cleat.scanning.WorkflowScanService; +import jakarta.transaction.Transactional; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.scheduling.annotation.Scheduled; @@ -22,6 +23,7 @@ public WorkflowScanJob(AccountRepository accountRepository, WorkflowScanService } @Scheduled(fixedDelayString = "${cleat.workflow-scan.interval-ms:3600000}") + @Transactional public void run() { LOG.info("Starting workflow scan job"); From ba5ba0236e16f6e8a104d05af05766bf630b0322 Mon Sep 17 00:00:00 2001 From: nurlanakberli9-pixel Date: Sun, 28 Jun 2026 11:23:42 -0700 Subject: [PATCH 18/23] fix: retrieve workflow content from base64 field to avoid host mismatch --- .../java/dev/cleat/scanning/WorkflowScanService.java | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/backend/libs/scanning/src/main/java/dev/cleat/scanning/WorkflowScanService.java b/backend/libs/scanning/src/main/java/dev/cleat/scanning/WorkflowScanService.java index 28b2b6d..14764f9 100644 --- a/backend/libs/scanning/src/main/java/dev/cleat/scanning/WorkflowScanService.java +++ b/backend/libs/scanning/src/main/java/dev/cleat/scanning/WorkflowScanService.java @@ -55,16 +55,13 @@ public List scanRepo(RepoEntity repo, String installationId) continue; } - // 3. Fetch raw YAML content - String downloadUrl = (String) file.get("download_url"); - if (downloadUrl == null) { + String base64Content = (String) file.get("content"); + if (base64Content == null) { + LOG.warn("Content field missing for {}, skipping.", path); continue; } - String yamlContent = gitHubClient.get(downloadUrl, installationId, String.class); - if (yamlContent == null) { - continue; - } + String yamlContent = new String(java.util.Base64.getMimeDecoder().decode(base64Content)); // 4. Parse and score WorkflowAnalysis analysis = workflowParser.parse(path, yamlContent); From 8b59d84ba3855d5d50d8298eada563cfb2fb89ba Mon Sep 17 00:00:00 2001 From: nurlanakberli9-pixel Date: Sun, 28 Jun 2026 11:37:19 -0700 Subject: [PATCH 19/23] fix: delete old scan records before checking for empty workflow files --- .../main/java/dev/cleat/scanning/WorkflowScanService.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/libs/scanning/src/main/java/dev/cleat/scanning/WorkflowScanService.java b/backend/libs/scanning/src/main/java/dev/cleat/scanning/WorkflowScanService.java index 14764f9..3af4f26 100644 --- a/backend/libs/scanning/src/main/java/dev/cleat/scanning/WorkflowScanService.java +++ b/backend/libs/scanning/src/main/java/dev/cleat/scanning/WorkflowScanService.java @@ -39,14 +39,14 @@ public List scanRepo(RepoEntity repo, String installationId) List> files = gitHubClient.get( "/repos/" + owner + "/" + repoName + "/contents/.github/workflows", installationId, List.class); + // 2. Delete previous scans for this repo before inserting fresh ones + workflowScanRepository.deleteByRepoId(repo.getId()); + if (files == null || files.isEmpty()) { LOG.debug("No workflows found for {}/{}", owner, repoName); return List.of(); } - // 2. Delete previous scans for this repo before inserting fresh ones - workflowScanRepository.deleteByRepoId(repo.getId()); - List results = new ArrayList<>(); for (Map file : files) { From 269ac1761c906aed606749ee608b927789ef4bf5 Mon Sep 17 00:00:00 2001 From: nurlanakberli9-pixel Date: Sun, 28 Jun 2026 11:49:20 -0700 Subject: [PATCH 20/23] refactor(scanning): improve permission and OIDC audit logic - replace arbitrary map heuristic with explicit PermissionStatus model - fix write-all scalar permission to correctly grant id-token: write - extend broad permission and OIDC checks to include job-level scopes --- .../dev/cleat/scanning/WorkflowParser.java | 38 +++++++++++++------ 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/backend/libs/scanning/src/main/java/dev/cleat/scanning/WorkflowParser.java b/backend/libs/scanning/src/main/java/dev/cleat/scanning/WorkflowParser.java index 8d99543..d2bdd44 100644 --- a/backend/libs/scanning/src/main/java/dev/cleat/scanning/WorkflowParser.java +++ b/backend/libs/scanning/src/main/java/dev/cleat/scanning/WorkflowParser.java @@ -28,16 +28,14 @@ public WorkflowAnalysis parse(String workflowPath, String yamlContent) { return scored(workflowPath, unpinnedActions, broadPermissions, missingOidc); } - // Check top-level permissions block - Object perms = workflow.get("permissions"); - if (perms instanceof String s) { - broadPermissions = BROAD_VALUES.contains(s); - } else if (perms instanceof Map permsMap) { - broadPermissions = permsMap.values().stream().anyMatch(v -> "write".equals(v) && permsMap.size() > 3); - missingOidc = !"write".equals(permsMap.get("id-token")); + // Check top-level permissions + var topPerms = getPermissionStatus(workflow.get("permissions")); + broadPermissions = topPerms.broad(); + if (topPerms.oidcWrite()) { + missingOidc = false; } - // Walk jobs → steps → uses + // Walk jobs Object jobsObj = workflow.get("jobs"); if (jobsObj instanceof Map jobs) { for (Object jobVal : jobs.values()) { @@ -45,9 +43,12 @@ public WorkflowAnalysis parse(String workflowPath, String yamlContent) { continue; } - // Per-job permissions can also grant OIDC - Object jobPerms = job.get("permissions"); - if (jobPerms instanceof Map jp && "write".equals(jp.get("id-token"))) { + // Check job-level permissions + var jobPerms = getPermissionStatus(job.get("permissions")); + if (jobPerms.broad()) { + broadPermissions = true; + } + if (jobPerms.oidcWrite()) { missingOidc = false; } @@ -73,6 +74,21 @@ public WorkflowAnalysis parse(String workflowPath, String yamlContent) { return scored(workflowPath, unpinnedActions, broadPermissions, missingOidc); } + private PermissionStatus getPermissionStatus(Object perms) { + if (perms instanceof String s) { + boolean isBroad = BROAD_VALUES.contains(s); + boolean isOidc = "write-all".equals(s); + return new PermissionStatus(isBroad, isOidc); + } else if (perms instanceof Map map) { + boolean isOidc = "write".equals(map.get("id-token")); + boolean isBroad = map.values().stream().anyMatch(v -> "write".equals(v)); + return new PermissionStatus(isBroad, isOidc); + } + return new PermissionStatus(false, false); + } + + private record PermissionStatus(boolean broad, boolean oidcWrite) {} + private WorkflowAnalysis scored( String workflowPath, List unpinnedActions, boolean broadPermissions, boolean missingOidc) { From e431f7a0cdc30d6686e392e1a725dacb24118cd9 Mon Sep 17 00:00:00 2001 From: nurlanakberli9-pixel Date: Sun, 28 Jun 2026 12:04:36 -0700 Subject: [PATCH 21/23] refactor(scanning): align test expectations with new OIDC logic --- .../src/test/java/dev/cleat/scanning/WorkflowParserTest.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/libs/scanning/src/test/java/dev/cleat/scanning/WorkflowParserTest.java b/backend/libs/scanning/src/test/java/dev/cleat/scanning/WorkflowParserTest.java index f391831..161d4ec 100644 --- a/backend/libs/scanning/src/test/java/dev/cleat/scanning/WorkflowParserTest.java +++ b/backend/libs/scanning/src/test/java/dev/cleat/scanning/WorkflowParserTest.java @@ -74,10 +74,10 @@ void cleanWorkflowHasZeroScore() { } @Test - void emptyYamlReturnsZeroScore() { + void emptyYamlScoresOnlyMissingOidc() { WorkflowAnalysis result = parser.parse(".github/workflows/empty.yml", ""); assertThat(result.unpinnedActions()).isEmpty(); - assertThat(result.riskScore()).isEqualTo(20); // only missingOidc penalty + assertThat(result.riskScore()).isEqualTo(20); } } From 5850c2feb991270dc9b8e1189706d6fdf2b0b480 Mon Sep 17 00:00:00 2001 From: nurlanakberli9-pixel Date: Mon, 29 Jun 2026 04:05:34 -0700 Subject: [PATCH 22/23] refactor(test): replace Testcontainers with H2 in-memory database for local test compatibility --- backend/apps/api/build.gradle.kts | 1 + .../cleat/api/AbstractIntegrationTest.java | 17 +---------------- .../api/src/test/resources/application.yml | 12 ++++++++++++ backend/apps/worker/build.gradle.kts | 1 + .../cleat/worker/AbstractIntegrationTest.java | 19 +++---------------- .../worker/src/test/resources/application.yml | 14 ++++++++++++++ backend/libs/persistence/build.gradle.kts | 1 + .../persistence/AccountRepositoryTest.java | 16 ---------------- .../WorkflowScanRepositoryTest.java | 16 ---------------- .../src/test/resources/application.yml | 12 ++++++++---- 10 files changed, 41 insertions(+), 68 deletions(-) diff --git a/backend/apps/api/build.gradle.kts b/backend/apps/api/build.gradle.kts index 2d4da1d..871a07d 100644 --- a/backend/apps/api/build.gradle.kts +++ b/backend/apps/api/build.gradle.kts @@ -19,4 +19,5 @@ dependencies { testImplementation("org.testcontainers:postgresql") testImplementation("org.testcontainers:testcontainers") testImplementation("org.springframework.boot:spring-boot-testcontainers") + testImplementation("com.h2database:h2") } diff --git a/backend/apps/api/src/test/java/dev/cleat/api/AbstractIntegrationTest.java b/backend/apps/api/src/test/java/dev/cleat/api/AbstractIntegrationTest.java index 24d6959..be60628 100644 --- a/backend/apps/api/src/test/java/dev/cleat/api/AbstractIntegrationTest.java +++ b/backend/apps/api/src/test/java/dev/cleat/api/AbstractIntegrationTest.java @@ -1,21 +1,6 @@ package dev.cleat.api; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.testcontainers.service.connection.ServiceConnection; -import org.testcontainers.containers.GenericContainer; -import org.testcontainers.containers.PostgreSQLContainer; -import org.testcontainers.junit.jupiter.Container; -import org.testcontainers.junit.jupiter.Testcontainers; -@Testcontainers @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) -public abstract class AbstractIntegrationTest { - - @Container - @ServiceConnection - static PostgreSQLContainer postgres = new PostgreSQLContainer<>("postgres:16-alpine"); - - @Container - @ServiceConnection - static GenericContainer redis = new GenericContainer<>("redis:7-alpine").withExposedPorts(6379); -} +public abstract class AbstractIntegrationTest {} diff --git a/backend/apps/api/src/test/resources/application.yml b/backend/apps/api/src/test/resources/application.yml index 7c3769f..2334d21 100644 --- a/backend/apps/api/src/test/resources/application.yml +++ b/backend/apps/api/src/test/resources/application.yml @@ -1,4 +1,16 @@ spring: + datasource: + url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;MODE=PostgreSQL + username: sa + password: + driver-class-name: org.h2.Driver + jpa: + hibernate: + ddl-auto: none + data: + redis: + host: localhost + port: 6379 security: user: name: test diff --git a/backend/apps/worker/build.gradle.kts b/backend/apps/worker/build.gradle.kts index 4ae7e15..51ec160 100644 --- a/backend/apps/worker/build.gradle.kts +++ b/backend/apps/worker/build.gradle.kts @@ -18,4 +18,5 @@ dependencies { testImplementation("org.testcontainers:postgresql") testImplementation("org.testcontainers:testcontainers") testImplementation("org.springframework.boot:spring-boot-testcontainers") + testImplementation("com.h2database:h2") } diff --git a/backend/apps/worker/src/test/java/dev/cleat/worker/AbstractIntegrationTest.java b/backend/apps/worker/src/test/java/dev/cleat/worker/AbstractIntegrationTest.java index 16315f1..7124e99 100644 --- a/backend/apps/worker/src/test/java/dev/cleat/worker/AbstractIntegrationTest.java +++ b/backend/apps/worker/src/test/java/dev/cleat/worker/AbstractIntegrationTest.java @@ -1,19 +1,6 @@ package dev.cleat.worker; -import org.springframework.boot.testcontainers.service.connection.ServiceConnection; -import org.testcontainers.containers.GenericContainer; -import org.testcontainers.containers.PostgreSQLContainer; -import org.testcontainers.junit.jupiter.Container; -import org.testcontainers.junit.jupiter.Testcontainers; +import org.springframework.boot.test.context.SpringBootTest; -@Testcontainers -public abstract class AbstractIntegrationTest { - - @Container - @ServiceConnection - static PostgreSQLContainer postgres = new PostgreSQLContainer<>("postgres:16-alpine"); - - @Container - @ServiceConnection - static GenericContainer redis = new GenericContainer<>("redis:7-alpine").withExposedPorts(6379); -} +@SpringBootTest +public abstract class AbstractIntegrationTest {} diff --git a/backend/apps/worker/src/test/resources/application.yml b/backend/apps/worker/src/test/resources/application.yml index 697242c..720f459 100644 --- a/backend/apps/worker/src/test/resources/application.yml +++ b/backend/apps/worker/src/test/resources/application.yml @@ -1,3 +1,17 @@ +spring: + datasource: + url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;MODE=PostgreSQL + username: sa + password: + driver-class-name: org.h2.Driver + jpa: + hibernate: + ddl-auto: none + data: + redis: + host: localhost + port: 6379 + github: app-id: "test-app-id" private-key-path: "/tmp/test-key.pem" diff --git a/backend/libs/persistence/build.gradle.kts b/backend/libs/persistence/build.gradle.kts index c79da08..eba4054 100644 --- a/backend/libs/persistence/build.gradle.kts +++ b/backend/libs/persistence/build.gradle.kts @@ -12,6 +12,7 @@ dependencies { testImplementation("org.testcontainers:junit-jupiter") testImplementation("org.testcontainers:postgresql") testRuntimeOnly("org.junit.platform:junit-platform-launcher") + testImplementation("com.h2database:h2") } diff --git a/backend/libs/persistence/src/test/java/dev/cleat/persistence/AccountRepositoryTest.java b/backend/libs/persistence/src/test/java/dev/cleat/persistence/AccountRepositoryTest.java index 024f003..7f45df0 100644 --- a/backend/libs/persistence/src/test/java/dev/cleat/persistence/AccountRepositoryTest.java +++ b/backend/libs/persistence/src/test/java/dev/cleat/persistence/AccountRepositoryTest.java @@ -12,31 +12,15 @@ import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; import org.springframework.test.context.ContextConfiguration; -import org.springframework.test.context.DynamicPropertyRegistry; -import org.springframework.test.context.DynamicPropertySource; -import org.testcontainers.containers.PostgreSQLContainer; -import org.testcontainers.junit.jupiter.Container; -import org.testcontainers.junit.jupiter.Testcontainers; @DataJpaTest -@Testcontainers @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) @ContextConfiguration(classes = TestPersistenceConfig.class) public class AccountRepositoryTest { - @Container - static PostgreSQLContainer postgres = new PostgreSQLContainer<>("postgres:16"); - @Autowired private TestEntityManager testEntityManager; - @DynamicPropertySource - static void props(DynamicPropertyRegistry registry) { - registry.add("spring.datasource.url", postgres::getJdbcUrl); - registry.add("spring.datasource.username", postgres::getUsername); - registry.add("spring.datasource.password", postgres::getPassword); - } - @Autowired AccountRepository accountRepository; diff --git a/backend/libs/persistence/src/test/java/dev/cleat/persistence/WorkflowScanRepositoryTest.java b/backend/libs/persistence/src/test/java/dev/cleat/persistence/WorkflowScanRepositoryTest.java index badb91d..61ebf51 100644 --- a/backend/libs/persistence/src/test/java/dev/cleat/persistence/WorkflowScanRepositoryTest.java +++ b/backend/libs/persistence/src/test/java/dev/cleat/persistence/WorkflowScanRepositoryTest.java @@ -17,28 +17,12 @@ import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; import org.springframework.test.context.ContextConfiguration; -import org.springframework.test.context.DynamicPropertyRegistry; -import org.springframework.test.context.DynamicPropertySource; -import org.testcontainers.containers.PostgreSQLContainer; -import org.testcontainers.junit.jupiter.Container; -import org.testcontainers.junit.jupiter.Testcontainers; @DataJpaTest -@Testcontainers @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) @ContextConfiguration(classes = TestPersistenceConfig.class) class WorkflowScanRepositoryTest { - @Container - static PostgreSQLContainer postgres = new PostgreSQLContainer<>("postgres:16"); - - @DynamicPropertySource - static void props(DynamicPropertyRegistry registry) { - registry.add("spring.datasource.url", postgres::getJdbcUrl); - registry.add("spring.datasource.username", postgres::getUsername); - registry.add("spring.datasource.password", postgres::getPassword); - } - @Autowired private TestEntityManager testEntityManager; diff --git a/backend/libs/persistence/src/test/resources/application.yml b/backend/libs/persistence/src/test/resources/application.yml index 63cff44..9514ff5 100644 --- a/backend/libs/persistence/src/test/resources/application.yml +++ b/backend/libs/persistence/src/test/resources/application.yml @@ -1,8 +1,12 @@ spring: - flyway: - enabled: true + datasource: + url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;MODE=PostgreSQL + username: sa + password: + driver-class-name: org.h2.Driver jpa: hibernate: ddl-auto: none - - + flyway: + enabled: true + locations: classpath:db/migration \ No newline at end of file From 5f68b278b3882906271ffdf9bafd3f487f9b27ae Mon Sep 17 00:00:00 2001 From: nurlanakberli9-pixel Date: Mon, 29 Jun 2026 04:24:31 -0700 Subject: [PATCH 23/23] fix(scanning): exclude id-token from broad permissions check in WorkflowParser --- .../src/main/java/dev/cleat/scanning/WorkflowParser.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/libs/scanning/src/main/java/dev/cleat/scanning/WorkflowParser.java b/backend/libs/scanning/src/main/java/dev/cleat/scanning/WorkflowParser.java index d2bdd44..2ca301c 100644 --- a/backend/libs/scanning/src/main/java/dev/cleat/scanning/WorkflowParser.java +++ b/backend/libs/scanning/src/main/java/dev/cleat/scanning/WorkflowParser.java @@ -81,7 +81,8 @@ private PermissionStatus getPermissionStatus(Object perms) { return new PermissionStatus(isBroad, isOidc); } else if (perms instanceof Map map) { boolean isOidc = "write".equals(map.get("id-token")); - boolean isBroad = map.values().stream().anyMatch(v -> "write".equals(v)); + boolean isBroad = map.entrySet().stream() + .anyMatch(e -> "write".equals(e.getValue()) && !"id-token".equals(e.getKey())); return new PermissionStatus(isBroad, isOidc); } return new PermissionStatus(false, false);