From 1d7400bb24db384ce9aae72e5dc9d52bfac16158 Mon Sep 17 00:00:00 2001 From: Kaio Fellipe Date: Mon, 20 Apr 2026 03:32:32 -0300 Subject: [PATCH 01/14] ci: add CodeQL SAST workflow for Go analysis Signed-off-by: Kaio Fellipe --- .github/workflows/codeql.yml | 37 ++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 .github/workflows/codeql.yml diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..5c9cc3a --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,37 @@ +name: CodeQL + +on: + push: + branches: + - main + pull_request: + branches: + - main + schedule: + - cron: '0 6 * * 3' + +permissions: {} + +jobs: + analyze: + name: Analyze Go + runs-on: ubuntu-latest + permissions: + contents: read + security-events: write + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Initialize CodeQL + uses: github/codeql-action/init@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1 + with: + languages: go + + - name: Autobuild + uses: github/codeql-action/autobuild@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1 + with: + category: /language:go From eb755187c15483880c073498a905cefede8e6a7d Mon Sep 17 00:00:00 2001 From: Kaio Fellipe Date: Mon, 20 Apr 2026 03:32:35 -0300 Subject: [PATCH 02/14] ci: add OSSF Scorecard supply chain analysis workflow Signed-off-by: Kaio Fellipe --- .github/workflows/scorecard.yml | 45 +++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 .github/workflows/scorecard.yml diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml new file mode 100644 index 0000000..a85736f --- /dev/null +++ b/.github/workflows/scorecard.yml @@ -0,0 +1,45 @@ +name: OSSF Scorecard + +on: + schedule: + - cron: '0 6 * * 1' + push: + branches: + - main + workflow_dispatch: + +permissions: read-all + +jobs: + analysis: + name: Scorecard analysis + runs-on: ubuntu-latest + permissions: + security-events: write + id-token: write + contents: read + actions: read + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + + - name: Run Scorecard + uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3 + with: + results_file: results.sarif + results_format: sarif + publish_results: true + + - name: Upload SARIF artifact + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: scorecard-sarif + path: results.sarif + retention-days: 5 + + - name: Upload SARIF to Security tab + uses: github/codeql-action/upload-sarif@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1 + with: + sarif_file: results.sarif From c8e90a105e801484aebe5e846290ade837c91a11 Mon Sep 17 00:00:00 2001 From: Kaio Fellipe Date: Mon, 20 Apr 2026 03:32:38 -0300 Subject: [PATCH 03/14] ci: add Scorecard PR check with 7.0 threshold enforcement Signed-off-by: Kaio Fellipe --- .github/workflows/scorecard-pr.yml | 122 +++++++++++++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 .github/workflows/scorecard-pr.yml diff --git a/.github/workflows/scorecard-pr.yml b/.github/workflows/scorecard-pr.yml new file mode 100644 index 0000000..9c5017c --- /dev/null +++ b/.github/workflows/scorecard-pr.yml @@ -0,0 +1,122 @@ +name: Scorecard PR Check + +on: + pull_request: + branches: + - main + +permissions: {} + +jobs: + scorecard-check: + name: Scorecard Check + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + issues: read + checks: read + env: + SCORECARD_VERSION: "5.4.0" + SCORECARD_CHECKSUM: "e5183aeaa5aa548fbb7318a6deb3e1038be0ef9aca24e655422ae88dfbe67502" + SCORE_THRESHOLD: "7.0" + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + + - name: Install scorecard CLI + run: | + set -euo pipefail + TARBALL="scorecard_${SCORECARD_VERSION}_linux_amd64.tar.gz" + curl -sLO "https://github.com/ossf/scorecard/releases/download/v${SCORECARD_VERSION}/${TARBALL}" + echo "${SCORECARD_CHECKSUM} ${TARBALL}" | sha256sum --check --strict + tar xzf "${TARBALL}" scorecard + chmod +x scorecard + sudo mv scorecard /usr/local/bin/scorecard + rm "${TARBALL}" + + - name: Run scorecard + id: scorecard + env: + GITHUB_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + scorecard --repo="github.com/${{ github.repository }}" \ + --commit="${{ github.event.pull_request.head.sha }}" \ + --format=json --show-details > scorecard.json + SCORE=$(jq -r '.score' scorecard.json) + echo "score=${SCORE}" >> "$GITHUB_OUTPUT" + echo "Scorecard overall score: ${SCORE}" + + - name: Comment on PR + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7 + with: + script: | + const fs = require('fs'); + const data = JSON.parse(fs.readFileSync('scorecard.json', 'utf8')); + const score = data.score; + const threshold = parseFloat('${{ env.SCORE_THRESHOLD }}'); + const passed = score >= threshold; + const icon = passed ? ':white_check_mark:' : ':x:'; + const repo = '${{ github.repository }}'; + + const checks = data.checks + .sort((a, b) => a.name.localeCompare(b.name)) + .map(c => { + const s = c.score === -1 ? 'N/A' : `${c.score}/10`; + const raw = (c.reason || '').replace(/\|/g, '\\|'); + const reason = raw.length > 80 + ? raw.substring(0, 77) + '...' + : raw; + return `| ${c.name} | ${s} | ${reason} |`; + }) + .join('\n'); + + let body = `## OpenSSF Scorecard — ${score}/10 ${icon}\n\n`; + body += `| Check | Score | Details |\n`; + body += `|-------|-------|---------|`; + body += `\n${checks}\n\n`; + + if (!passed) { + body += `> :rotating_light: Score ${score} is below threshold ${threshold} — this check will fail.\n\n`; + } + + body += `> Threshold: ${threshold} | [Full report](https://securityscorecards.dev/viewer/?uri=github.com/${repo})\n`; + + const marker = ''; + body = marker + '\n' + body; + + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + const existing = comments.find(c => c.body.includes(marker)); + + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body: body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: body, + }); + } + + - name: Enforce threshold + run: | + SCORE="${{ steps.scorecard.outputs.score }}" + THRESHOLD="${{ env.SCORE_THRESHOLD }}" + if [ "$(echo "${SCORE} < ${THRESHOLD}" | bc -l)" -eq 1 ]; then + echo "::error::OpenSSF Scorecard score ${SCORE} is below threshold ${THRESHOLD}" + exit 1 + fi + echo "Scorecard score ${SCORE} meets threshold ${THRESHOLD}" From 812374a33789e9ac161274f24a3bfa8112ce00ed Mon Sep 17 00:00:00 2001 From: Kaio Fellipe Date: Mon, 20 Apr 2026 03:32:40 -0300 Subject: [PATCH 04/14] ci: add gitleaks secret scanning and govulncheck vulnerability scanning Signed-off-by: Kaio Fellipe --- .github/workflows/security.yml | 51 ++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 .github/workflows/security.yml diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 0000000..e4751b7 --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,51 @@ +name: Security Scanning + +on: + push: + branches: + - main + pull_request: + branches: + - main + +permissions: {} + +jobs: + gitleaks: + name: Secret Scan + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + fetch-depth: 0 + + - name: Run Gitleaks + uses: gitleaks/gitleaks-action@ff98106e4c7b2bc287b24eaf42907196329070c7 # v2 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + govulncheck: + name: Vulnerability Scan (Go) + runs-on: ubuntu-latest + permissions: + contents: read + defaults: + run: + working-directory: lambda + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Set up Go + uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6 + with: + go-version-file: lambda/go.mod + + - name: Install govulncheck + run: go install golang.org/x/vuln/cmd/govulncheck@latest + + - name: Run govulncheck + run: govulncheck ./... From dbad513d6fdee2ebe2af917ea0479ce4eb25a162 Mon Sep 17 00:00:00 2001 From: Kaio Fellipe Date: Mon, 20 Apr 2026 03:32:43 -0300 Subject: [PATCH 05/14] ci: add CODEOWNERS with default owner Signed-off-by: Kaio Fellipe --- .github/CODEOWNERS | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .github/CODEOWNERS diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..962f096 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,2 @@ +# Default code owners for all files +* @kaio6fellipe From 0b61a36ce6ccb53ddbfdeb42c73f1a4bfe69227a Mon Sep 17 00:00:00 2001 From: Kaio Fellipe Date: Mon, 20 Apr 2026 03:32:59 -0300 Subject: [PATCH 06/14] ci: add gosec, revive, gocyclo, misspell, unconvert linters and goimports formatter Signed-off-by: Kaio Fellipe --- .golangci.yml | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/.golangci.yml b/.golangci.yml index 6756b67..b31a21d 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -8,20 +8,54 @@ linters: default: none enable: - errcheck + - gocyclo + - gosec - govet - ineffassign + - misspell + - revive - staticcheck + - unconvert - unused settings: errcheck: check-type-assertions: true check-blank: true + gocyclo: + min-complexity: 15 + gosec: + excludes: + - G115 + revive: + rules: + - name: exported + severity: warning + - name: var-naming + severity: warning + - name: error-return + severity: warning + - name: error-naming + severity: warning + - name: unused-parameter + severity: warning exclusions: rules: - path: _test\.go linters: - errcheck + - gosec + - gocyclo + - revive + - path: _test\.go + text: "exported" + linters: + - revive formatters: enable: - gofmt + - goimports + settings: + goimports: + local-prefixes: + - github.com/devopsfactory-io/jit-runners From 8601a4f8ff6694c588549cb21af18d6fd809b46c Mon Sep 17 00:00:00 2001 From: Kaio Fellipe Date: Mon, 20 Apr 2026 03:39:39 -0300 Subject: [PATCH 07/14] ci: replace CodeQL autobuild with explicit lambda build, pin govulncheck to v1.1.4 Signed-off-by: Kaio Fellipe --- .github/workflows/codeql.yml | 4 ++-- .github/workflows/security.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 5c9cc3a..e2f983c 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -28,8 +28,8 @@ jobs: with: languages: go - - name: Autobuild - uses: github/codeql-action/autobuild@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1 + - name: Build + run: cd lambda && go build ./... - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1 diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index e4751b7..fc4d567 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -45,7 +45,7 @@ jobs: go-version-file: lambda/go.mod - name: Install govulncheck - run: go install golang.org/x/vuln/cmd/govulncheck@latest + run: go install golang.org/x/vuln/cmd/govulncheck@v1.1.4 - name: Run govulncheck run: govulncheck ./... From 89c54a5abf46853003bcc1e3d7cf2293f44290e1 Mon Sep 17 00:00:00 2001 From: Kaio Fellipe Date: Mon, 20 Apr 2026 04:17:14 -0300 Subject: [PATCH 08/14] fix(ci): replace gitleaks-action with free CLI v8.30.1 The gitleaks GitHub Action requires a paid license for organization repositories. Replace with direct CLI binary installation. Signed-off-by: Kaio Fellipe --- .github/workflows/security.yml | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index fc4d567..a32fe58 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -22,10 +22,17 @@ jobs: with: fetch-depth: 0 + - name: Install Gitleaks + run: | + set -euo pipefail + GITLEAKS_VERSION="8.30.1" + curl -sLO "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" + tar xzf "gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" gitleaks + sudo mv gitleaks /usr/local/bin/gitleaks + rm "gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" + - name: Run Gitleaks - uses: gitleaks/gitleaks-action@ff98106e4c7b2bc287b24eaf42907196329070c7 # v2 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: gitleaks detect --source . --verbose govulncheck: name: Vulnerability Scan (Go) From c54aa1cb8119f9d0276d474af4650fcd9e33924b Mon Sep 17 00:00:00 2001 From: Kaio Fellipe Date: Mon, 20 Apr 2026 04:23:20 -0300 Subject: [PATCH 09/14] fix(ci): harden workflow token permissions to least-privilege Convert all workflows to permissions: {} at workflow level with per-job grants. This maximizes the OpenSSF Scorecard Token-Permissions check (0/10 -> 10/10). Signed-off-by: Kaio Fellipe --- .github/workflows/ami-build.yml | 7 ++++--- .github/workflows/label-old-prs.yml | 9 +++++---- .github/workflows/labeler.yml | 5 +---- .github/workflows/release.yml | 5 +++-- .github/workflows/scorecard.yml | 2 +- .github/workflows/test.yml | 7 +++++-- 6 files changed, 19 insertions(+), 16 deletions(-) diff --git a/.github/workflows/ami-build.yml b/.github/workflows/ami-build.yml index 2579239..c8c79a1 100644 --- a/.github/workflows/ami-build.yml +++ b/.github/workflows/ami-build.yml @@ -35,9 +35,7 @@ on: paths: - "infra/packer/**" -permissions: - contents: read - id-token: write +permissions: {} env: AMI_DISTRIBUTION_REGIONS: >- @@ -45,6 +43,9 @@ env: jobs: build: + permissions: + contents: read + id-token: write runs-on: ubuntu-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 diff --git a/.github/workflows/label-old-prs.yml b/.github/workflows/label-old-prs.yml index fe36767..579e717 100644 --- a/.github/workflows/label-old-prs.yml +++ b/.github/workflows/label-old-prs.yml @@ -20,13 +20,14 @@ on: default: '200' type: string -permissions: - contents: read - pull-requests: write - issues: write +permissions: {} jobs: labeler: + permissions: + contents: read + pull-requests: write + issues: write runs-on: [self-hosted, medium] steps: - name: Checkout diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index 937e524..81d6e63 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -4,10 +4,7 @@ on: pull_request: types: [opened, synchronize, reopened] -permissions: - contents: read - pull-requests: write - issues: write +permissions: {} jobs: labeler: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f150f91..e81e705 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,11 +5,12 @@ on: tags: - "v*" -permissions: - contents: write +permissions: {} jobs: release: + permissions: + contents: write runs-on: [self-hosted, release] steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index a85736f..7a02aba 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -8,7 +8,7 @@ on: - main workflow_dispatch: -permissions: read-all +permissions: {} jobs: analysis: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ab23dc3..4617c38 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -6,12 +6,13 @@ on: pull_request: branches: [main] -permissions: - contents: read +permissions: {} jobs: test: name: fmt, vet, test and coverage + permissions: + contents: read runs-on: [self-hosted, large] defaults: run: @@ -37,6 +38,8 @@ jobs: lint: name: lint + permissions: + contents: read runs-on: [self-hosted, large] steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 From c5cdd8d4fc8a645ed198a3ecf67a4bf68f52658c Mon Sep 17 00:00:00 2001 From: Kaio Fellipe Date: Mon, 20 Apr 2026 04:24:19 -0300 Subject: [PATCH 10/14] fix(ci): pin hashicorp/setup-packer to SHA for scorecard Pin setup-packer@main to v3.2.0 SHA to improve the OpenSSF Scorecard Pinned-Dependencies check. Signed-off-by: Kaio Fellipe --- .github/workflows/ami-build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ami-build.yml b/.github/workflows/ami-build.yml index c8c79a1..96d9371 100644 --- a/.github/workflows/ami-build.yml +++ b/.github/workflows/ami-build.yml @@ -73,7 +73,7 @@ jobs: aws-region: us-east-2 - name: Setup Packer - uses: hashicorp/setup-packer@main + uses: hashicorp/setup-packer@c3d53c525d422944e50ee27b840746d6522b08de # v3.2.0 - name: Packer init working-directory: infra/packer From ec820c906a8dea396e2d2a80bb58a42254bac81c Mon Sep 17 00:00:00 2001 From: Kaio Fellipe Date: Mon, 20 Apr 2026 05:42:31 -0300 Subject: [PATCH 11/14] fix: rename stuttering interfaces EC2API->API, SQSSender->Sender Resolves revive stutter lint violations. The package name already provides the namespace (ec2.API, sqs.Sender). Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Kaio Fellipe --- lambda/internal/ec2/launcher.go | 8 ++++---- lambda/internal/sqs/publisher.go | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/lambda/internal/ec2/launcher.go b/lambda/internal/ec2/launcher.go index 58c7101..c5a2f6f 100644 --- a/lambda/internal/ec2/launcher.go +++ b/lambda/internal/ec2/launcher.go @@ -9,8 +9,8 @@ import ( "github.com/aws/aws-sdk-go-v2/service/ec2/types" ) -// EC2API abstracts the EC2 RunInstances API for testing. -type EC2API interface { +// API abstracts the EC2 RunInstances API for testing. +type API interface { RunInstances(ctx context.Context, input *ec2.RunInstancesInput, opts ...func(*ec2.Options)) (*ec2.RunInstancesOutput, error) TerminateInstances(ctx context.Context, input *ec2.TerminateInstancesInput, opts ...func(*ec2.Options)) (*ec2.TerminateInstancesOutput, error) DescribeInstances(ctx context.Context, input *ec2.DescribeInstancesInput, opts ...func(*ec2.Options)) (*ec2.DescribeInstancesOutput, error) @@ -18,11 +18,11 @@ type EC2API interface { // Launcher manages EC2 instance lifecycle for runners. type Launcher struct { - client EC2API + client API } // NewLauncher creates a Launcher with the given EC2 client. -func NewLauncher(client EC2API) *Launcher { +func NewLauncher(client API) *Launcher { return &Launcher{client: client} } diff --git a/lambda/internal/sqs/publisher.go b/lambda/internal/sqs/publisher.go index fbd2003..7d0caba 100644 --- a/lambda/internal/sqs/publisher.go +++ b/lambda/internal/sqs/publisher.go @@ -11,19 +11,19 @@ import ( const defaultDelaySeconds = 30 -// SQSSender abstracts the SQS SendMessage API for testing. -type SQSSender interface { +// Sender abstracts the SQS SendMessage API for testing. +type Sender interface { SendMessage(ctx context.Context, input *sqs.SendMessageInput, opts ...func(*sqs.Options)) (*sqs.SendMessageOutput, error) } // Publisher sends scale-up messages to SQS. type Publisher struct { - client SQSSender + client Sender queueURL string } // NewPublisher creates a Publisher for the given queue URL. -func NewPublisher(client SQSSender, queueURL string) *Publisher { +func NewPublisher(client Sender, queueURL string) *Publisher { return &Publisher{ client: client, queueURL: queueURL, From cc116b95a2115548980f85946bd2d40fa214b81d Mon Sep 17 00:00:00 2001 From: Kaio Fellipe Date: Mon, 20 Apr 2026 05:42:34 -0300 Subject: [PATCH 12/14] refactor: reduce cyclomatic complexity in LoadWithClient and Cleaner.Run Extract validateRequiredEnv, loadSecrets, cleanupStaleInstances, and reconcileOrphanInstances helpers. Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Kaio Fellipe --- lambda/internal/config/config.go | 46 +++++++++++++------- lambda/internal/runner/cleanup.go | 72 ++++++++++++++++--------------- 2 files changed, 69 insertions(+), 49 deletions(-) diff --git a/lambda/internal/config/config.go b/lambda/internal/config/config.go index 9f51ccc..c03908f 100644 --- a/lambda/internal/config/config.go +++ b/lambda/internal/config/config.go @@ -72,14 +72,8 @@ func LoadWithClient(ctx context.Context, client SecretsReader) (*Config, error) DefaultAMI: os.Getenv("EC2_DEFAULT_AMI"), } - if cfg.AppID == "" { - return nil, fmt.Errorf("GITHUB_APP_ID is required") - } - if cfg.QueueURL == "" { - return nil, fmt.Errorf("SQS_QUEUE_URL is required") - } - if cfg.TableName == "" { - return nil, fmt.Errorf("DYNAMODB_TABLE_NAME is required") + if err := validateRequiredEnv(cfg); err != nil { + return nil, err } // Parse subnet IDs (comma-separated). @@ -94,7 +88,29 @@ func LoadWithClient(ctx context.Context, client SecretsReader) (*Config, error) } } - // Load secrets. + if err := loadSecrets(ctx, cfg, client); err != nil { + return nil, err + } + + return cfg, nil +} + +// validateRequiredEnv checks that required environment variables are set on the config. +func validateRequiredEnv(cfg *Config) error { + if cfg.AppID == "" { + return fmt.Errorf("GITHUB_APP_ID is required") + } + if cfg.QueueURL == "" { + return fmt.Errorf("SQS_QUEUE_URL is required") + } + if cfg.TableName == "" { + return fmt.Errorf("DYNAMODB_TABLE_NAME is required") + } + return nil +} + +// loadSecrets loads webhook secret and private key from Secrets Manager or environment. +func loadSecrets(ctx context.Context, cfg *Config, client SecretsReader) error { webhookSecretARN := os.Getenv("GITHUB_APP_WEBHOOK_SECRET_ARN") privateKeyARN := os.Getenv("GITHUB_APP_PRIVATE_KEY_SECRET_ARN") @@ -102,14 +118,14 @@ func LoadWithClient(ctx context.Context, client SecretsReader) (*Config, error) if client == nil { awsCfg, err := config.LoadDefaultConfig(ctx) if err != nil { - return nil, fmt.Errorf("load AWS config: %w", err) + return fmt.Errorf("load AWS config: %w", err) } client = secretsmanager.NewFromConfig(awsCfg) } if webhookSecretARN != "" { secret, err := getSecret(ctx, client, webhookSecretARN) if err != nil { - return nil, fmt.Errorf("webhook secret: %w", err) + return fmt.Errorf("webhook secret: %w", err) } cfg.WebhookSecret = secret } else { @@ -118,7 +134,7 @@ func LoadWithClient(ctx context.Context, client SecretsReader) (*Config, error) if privateKeyARN != "" { secret, err := getSecret(ctx, client, privateKeyARN) if err != nil { - return nil, fmt.Errorf("private key: %w", err) + return fmt.Errorf("private key: %w", err) } cfg.PrivateKey = secret } else { @@ -130,12 +146,12 @@ func LoadWithClient(ctx context.Context, client SecretsReader) (*Config, error) } if cfg.WebhookSecret == "" { - return nil, fmt.Errorf("webhook secret is required (GITHUB_APP_WEBHOOK_SECRET or GITHUB_APP_WEBHOOK_SECRET_ARN)") + return fmt.Errorf("webhook secret is required (GITHUB_APP_WEBHOOK_SECRET or GITHUB_APP_WEBHOOK_SECRET_ARN)") } if cfg.PrivateKey == "" { - return nil, fmt.Errorf("private key is required (GITHUB_APP_PRIVATE_KEY or GITHUB_APP_PRIVATE_KEY_SECRET_ARN)") + return fmt.Errorf("private key is required (GITHUB_APP_PRIVATE_KEY or GITHUB_APP_PRIVATE_KEY_SECRET_ARN)") } - return cfg, nil + return nil } func getSecret(ctx context.Context, client SecretsReader, arn string) (string, error) { diff --git a/lambda/internal/runner/cleanup.go b/lambda/internal/runner/cleanup.go index 74f0552..19aff41 100644 --- a/lambda/internal/runner/cleanup.go +++ b/lambda/internal/runner/cleanup.go @@ -58,21 +58,8 @@ func (c *Cleaner) Run(ctx context.Context) (*CleanupResult, error) { return result, err } staleThreshold := now - int64(c.staleThresholdMinutes*60) - for _, r := range pending { - if r.CreatedAt < staleThreshold { - log.Printf("cleanup: terminating stale pending runner %s (instance %s)", r.RunnerID, r.InstanceID) - if err := c.ec2.Terminate(ctx, r.InstanceID); err != nil { - log.Printf("cleanup: failed to terminate %s: %v", r.InstanceID, err) - result.Errors++ - continue - } - if err := c.store.UpdateStatus(ctx, r.Repository, r.JobID, StatusFailed); err != nil { - log.Printf("cleanup: failed to update status for %s: %v", r.RunnerID, err) - result.Errors++ - continue - } - result.StaleTerminated++ - } + if err := c.cleanupStaleInstances(ctx, pending, staleThreshold, "pending", result); err != nil { + return result, err } // 2. Clean up stuck "running" instances. @@ -81,9 +68,35 @@ func (c *Cleaner) Run(ctx context.Context) (*CleanupResult, error) { return result, err } maxAgeThreshold := now - int64(c.maxAgeMinutes*60) - for _, r := range running { - if r.CreatedAt < maxAgeThreshold { - log.Printf("cleanup: terminating stuck running runner %s (instance %s)", r.RunnerID, r.InstanceID) + if err := c.cleanupStaleInstances(ctx, running, maxAgeThreshold, "running", result); err != nil { + return result, err + } + + // 3. Detect orphaned EC2 instances (tagged but not in DynamoDB). + allRecords := append(pending, running...) + completed, err := c.store.ListByStatus(ctx, StatusCompleted) + if err != nil { + return result, fmt.Errorf("list completed runners: %w", err) + } + failed, err := c.store.ListByStatus(ctx, StatusFailed) + if err != nil { + return result, fmt.Errorf("list failed runners: %w", err) + } + allRecords = append(allRecords, completed...) + allRecords = append(allRecords, failed...) + + if err := c.reconcileOrphanInstances(ctx, allRecords, result); err != nil { + return result, err + } + + return result, nil +} + +// cleanupStaleInstances terminates instances that have been in the given status longer than the threshold. +func (c *Cleaner) cleanupStaleInstances(ctx context.Context, records []*Record, threshold int64, statusLabel string, result *CleanupResult) error { + for _, r := range records { + if r.CreatedAt < threshold { + log.Printf("cleanup: terminating stale %s runner %s (instance %s)", statusLabel, r.RunnerID, r.InstanceID) if err := c.ec2.Terminate(ctx, r.InstanceID); err != nil { log.Printf("cleanup: failed to terminate %s: %v", r.InstanceID, err) result.Errors++ @@ -97,25 +110,17 @@ func (c *Cleaner) Run(ctx context.Context) (*CleanupResult, error) { result.StaleTerminated++ } } + return nil +} - // 3. Detect orphaned EC2 instances (tagged but not in DynamoDB). +// reconcileOrphanInstances finds EC2 instances not tracked in DynamoDB and terminates them. +func (c *Cleaner) reconcileOrphanInstances(ctx context.Context, knownRecords []*Record, result *CleanupResult) error { managedIDs, err := c.ec2.ListManagedInstances(ctx) if err != nil { - return result, err + return err } knownIDs := make(map[string]bool) - allRecords := append(pending, running...) - completed, err := c.store.ListByStatus(ctx, StatusCompleted) - if err != nil { - return result, fmt.Errorf("list completed runners: %w", err) - } - failed, err := c.store.ListByStatus(ctx, StatusFailed) - if err != nil { - return result, fmt.Errorf("list failed runners: %w", err) - } - allRecords = append(allRecords, completed...) - allRecords = append(allRecords, failed...) - for _, r := range allRecords { + for _, r := range knownRecords { knownIDs[r.InstanceID] = true } for _, id := range managedIDs { @@ -129,6 +134,5 @@ func (c *Cleaner) Run(ctx context.Context) (*CleanupResult, error) { result.OrphanTerminated++ } } - - return result, nil + return nil } From 0721d82a0c77143108c9ddd53acfe944d3246dc1 Mon Sep 17 00:00:00 2001 From: Kaio Fellipe Date: Mon, 20 Apr 2026 05:44:17 -0300 Subject: [PATCH 13/14] fix: annotate remaining gosec findings (G117, G704) Mark PrivateKey struct field as non-hardcoded credential and annotate SSRF findings on GitHub API HTTP calls with controlled URLs. Signed-off-by: Kaio Fellipe --- lambda/internal/config/config.go | 2 +- lambda/internal/github/client.go | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lambda/internal/config/config.go b/lambda/internal/config/config.go index c03908f..e50a8ff 100644 --- a/lambda/internal/config/config.go +++ b/lambda/internal/config/config.go @@ -16,7 +16,7 @@ import ( type Config struct { // GitHub App credentials. AppID string - PrivateKey string + PrivateKey string //nolint:gosec // G117: not a hardcoded credential, loaded from env/secrets manager WebhookSecret string // SQS queue URL for scale-up messages. diff --git a/lambda/internal/github/client.go b/lambda/internal/github/client.go index 4be232f..982ad0f 100644 --- a/lambda/internal/github/client.go +++ b/lambda/internal/github/client.go @@ -77,7 +77,7 @@ func installationTokenWithBase(ctx context.Context, appID, privateKeyPEM string, req.Header.Set("Authorization", "Bearer "+jwtStr) req.Header.Set("X-GitHub-Api-Version", "2022-11-28") - resp, err := http.DefaultClient.Do(req) + resp, err := http.DefaultClient.Do(req) //nolint:gosec // G704: URL from GitHub API constant if err != nil { return "", fmt.Errorf("request installation token: %w", err) } @@ -126,7 +126,7 @@ func (c *Client) GenerateJITConfig(ctx context.Context, ownerRepo string, name s req.Header.Set("X-GitHub-Api-Version", "2022-11-28") req.Header.Set("Content-Type", "application/json") - resp, err := c.httpClient.Do(req) + resp, err := c.httpClient.Do(req) //nolint:gosec // G704: URL from GitHub API constant if err != nil { return nil, fmt.Errorf("request JIT config: %w", err) } From c904a738a5d4da79d5698239f9a4c0f1c5062e3e Mon Sep 17 00:00:00 2001 From: Kaio Fellipe Date: Mon, 20 Apr 2026 10:41:19 -0300 Subject: [PATCH 14/14] fix(ci): skip scorecard PR comment for fork PRs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fork PRs don't have write access to the GITHUB_TOKEN, so the comment step would fail with 403. Skip it for forks — the scorecard check and threshold enforcement still run. Signed-off-by: Kaio Fellipe --- .github/workflows/scorecard-pr.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/scorecard-pr.yml b/.github/workflows/scorecard-pr.yml index 9c5017c..af6da9f 100644 --- a/.github/workflows/scorecard-pr.yml +++ b/.github/workflows/scorecard-pr.yml @@ -51,6 +51,7 @@ jobs: echo "Scorecard overall score: ${SCORE}" - name: Comment on PR + if: github.event.pull_request.head.repo.full_name == github.repository uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7 with: script: |