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..7c20efe --- /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.repository.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..295cd3b --- /dev/null +++ b/backend/apps/api/src/test/java/dev/cleat/api/WorkflowScanControllerTest.java @@ -0,0 +1,81 @@ +package dev.cleat.api; + +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.repository.AccountRepository; +import dev.cleat.persistence.repository.RepoRepository; +import dev.cleat.persistence.repository.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() { + 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"); + 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())); + + var response = restTemplate + .withBasicAuth("test", "test") + .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) + .setInstallationId("inst-456"); + accountRepository.saveAndFlush(account); + + RepoEntity repo = new RepoEntity().setAccount(account).setName("empty-repo"); + repoRepository.saveAndFlush(repo); + + 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 c28eff7..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 @@ -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.entity") +@EnableJpaRepositories(basePackages = "dev.cleat.persistence.repository") +@ComponentScan(basePackages = {"dev.cleat.worker", "dev.cleat.scanning", "dev.cleat.githubclient"}) 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..7265c51 --- /dev/null +++ b/backend/apps/worker/src/main/java/dev/cleat/worker/WorkflowScanJob.java @@ -0,0 +1,44 @@ +package dev.cleat.worker; + +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; +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}") + @Transactional + 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/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/apps/worker/src/test/resources/application.yml b/backend/apps/worker/src/test/resources/application.yml new file mode 100644 index 0000000..697242c --- /dev/null +++ b/backend/apps/worker/src/test/resources/application.yml @@ -0,0 +1,7 @@ +github: + app-id: "test-app-id" + private-key-path: "/tmp/test-key.pem" + +cleat: + workflow-scan: + interval-ms: 3600000 \ No newline at end of file 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); 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/java/dev/cleat/persistence/entity/RepoEntity.java b/backend/libs/persistence/src/main/java/dev/cleat/persistence/entity/RepoEntity.java index 983e895..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 @@ -157,6 +157,7 @@ public RepoEntity( this.staleBranches = staleBranches; this.openPRs = openPRs; this.hygieneScore = hygieneScore; + this.scorecard = scorecard; this.topics = topics; this.createdAt = createdAt; 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/repository/WorkflowScanRepository.java b/backend/libs/persistence/src/main/java/dev/cleat/persistence/repository/WorkflowScanRepository.java new file mode 100644 index 0000000..c33d859 --- /dev/null +++ b/backend/libs/persistence/src/main/java/dev/cleat/persistence/repository/WorkflowScanRepository.java @@ -0,0 +1,13 @@ +package dev.cleat.persistence.repository; + +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/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 ( 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/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) + +); + + 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..badb91d --- /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.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.repository.AccountRepository; +import dev.cleat.persistence.repository.RepoRepository; +import dev.cleat.persistence.repository.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..e1121ad 100644 --- a/backend/libs/scanning/build.gradle.kts +++ b/backend/libs/scanning/build.gradle.kts @@ -11,4 +11,9 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter") testImplementation("org.springframework.boot:spring-boot-starter-test") + implementation("org.yaml:snakeyaml") + + testImplementation("org.testcontainers:junit-jupiter") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") + } 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..d2bdd44 --- /dev/null +++ b/backend/libs/scanning/src/main/java/dev/cleat/scanning/WorkflowParser.java @@ -0,0 +1,107 @@ +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 + var topPerms = getPermissionStatus(workflow.get("permissions")); + broadPermissions = topPerms.broad(); + if (topPerms.oidcWrite()) { + missingOidc = false; + } + + // Walk jobs + Object jobsObj = workflow.get("jobs"); + if (jobsObj instanceof Map jobs) { + for (Object jobVal : jobs.values()) { + if (!(jobVal instanceof Map job)) { + continue; + } + + // Check job-level permissions + var jobPerms = getPermissionStatus(job.get("permissions")); + if (jobPerms.broad()) { + broadPermissions = true; + } + if (jobPerms.oidcWrite()) { + 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 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) { + + 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..3af4f26 --- /dev/null +++ b/backend/libs/scanning/src/main/java/dev/cleat/scanning/WorkflowScanService.java @@ -0,0 +1,93 @@ +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.repository.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); + + // 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(); + } + + List results = new ArrayList<>(); + + for (Map file : files) { + String path = (String) file.get("path"); + if (path == null || (!path.endsWith(".yml") && !path.endsWith(".yaml"))) { + continue; + } + + String base64Content = (String) file.get("content"); + if (base64Content == null) { + LOG.warn("Content field missing for {}, skipping.", path); + continue; + } + + String yamlContent = new String(java.util.Base64.getMimeDecoder().decode(base64Content)); + + // 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..161d4ec --- /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@a81bbbf8298c0fa03ea29cdc473d45769f953675 + """; + + 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@a81bbbf8298c0fa03ea29cdc473d45769f953675 + """; + + 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 emptyYamlScoresOnlyMissingOidc() { + WorkflowAnalysis result = parser.parse(".github/workflows/empty.yml", ""); + + assertThat(result.unpinnedActions()).isEmpty(); + assertThat(result.riskScore()).isEqualTo(20); + } +}