Skip to content

Commit 77c91ef

Browse files
joanagmaiaclaude
andauthored
chore: update pvtr plugin to v0.24.0+2fixes (#4314)
Signed-off-by: Joana Maia <jmaia@contractor.linuxfoundation.org> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 7a486b3 commit 77c91ef

7 files changed

Lines changed: 243 additions & 73 deletions

File tree

scripts/services/docker/Dockerfile.security_best_practices_worker

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
ARG PVTR_VERSION=v0.23.2
1+
ARG PVTR_VERSION=v0.24.0
22

33
FROM alpine:3.21 AS core
44
RUN apk add --no-cache wget tar unzip
@@ -10,13 +10,13 @@ ARG PLATFORM=Linux_x86_64
1010
RUN wget https://github.com/privateerproj/privateer/releases/download/v${VERSION}/privateer_${PLATFORM}.tar.gz
1111
RUN tar -xzf privateer_${PLATFORM}.tar.gz
1212

13-
FROM golang:1.26.3-alpine3.23 AS plugin
13+
FROM golang:1.26.4-alpine3.22 AS plugin
1414
RUN apk add --no-cache make git
1515
WORKDIR /plugin
16-
ARG PVTR_COMMIT=c7bd9538d64f7eaab94a05c9b5fd05458a387b1c
16+
ARG PVTR_COMMIT=c1095c95a1b399ec63e4f4e2b7880b0ef55e604f
1717
ARG PVTR_VERSION
1818
# To run the latest version of the plugin, we need to use the latest commit of the pvtr-github-repo-scanner repository.
19-
# Currently using v0.23.2: https://github.com/ossf/pvtr-github-repo-scanner/commit/c7bd9538d64f7eaab94a05c9b5fd05458a387b1c
19+
# Currently using v0.24.0+2fixes: https://github.com/ossf/pvtr-github-repo-scanner/commit/c1095c95a1b399ec63e4f4e2b7880b0ef55e604f
2020
RUN git clone https://github.com/ossf/pvtr-github-repo-scanner.git && cd pvtr-github-repo-scanner && git checkout ${PVTR_COMMIT}
2121
RUN cd pvtr-github-repo-scanner && make binary && cp github-repo ../github-repo
2222

services/apps/security_best_practices_worker/example-config.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,6 @@ services:
1212
- Maturity Level 1
1313

1414
vars:
15-
owner: $REPO_OWNER
16-
repo: $REPO_NAME
17-
token: $GITHUB_TOKEN
15+
owner: '$REPO_OWNER'
16+
repo: '$REPO_NAME'
17+
token: '$GITHUB_TOKEN'

services/apps/security_best_practices_worker/src/activities.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {
22
findObsoleteRepos,
3+
getCurrentTimeMs,
34
getOSPSBaselineInsights,
45
initializeTokenInfos,
56
saveOSPSBaselineInsightsToDB,
@@ -12,4 +13,5 @@ export {
1213
findObsoleteRepos,
1314
initializeTokenInfos,
1415
updateTokenInfos,
16+
getCurrentTimeMs,
1517
}

services/apps/security_best_practices_worker/src/activities/index.ts

Lines changed: 76 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -44,25 +44,12 @@ export async function getOSPSBaselineInsights(repoUrl: string, token: string): P
4444

4545
const combinedOutput = `${stdout}\n${stderr}`
4646

47-
if (combinedOutput.includes('403')) {
48-
svc.log.warn('Detected 403 error in privateer output!')
49-
throw ApplicationFailure.create({
50-
message: 'GitHub token rate-limited',
51-
type: 'Token403Error',
52-
})
53-
}
47+
classifyTokenError(combinedOutput, 'privateer output')
5448
} catch (err) {
5549
svc.log.error(`Privateer run failed: ${err.message}`)
5650

57-
// check for 403 in captured output if available
5851
const output = `${err.stdout || ''}\n${err.stderr || ''}`
59-
if (output.includes('403')) {
60-
svc.log.warn('Detected 403 error in failed privateer output!')
61-
throw ApplicationFailure.create({
62-
message: 'GitHub token rate-limited',
63-
type: 'Token403Error',
64-
})
65-
}
52+
classifyTokenError(output, 'failed privateer output')
6653
throw err
6754
}
6855

@@ -196,6 +183,40 @@ export async function saveOSPSBaselineInsightsToRedis(
196183
await redisCache.set(key, JSON.stringify(insights), 60 * 60 * 24) // 1 day
197184
}
198185

186+
function classifyTokenError(output: string, source: string): void {
187+
const failedAtMs = Date.now()
188+
189+
if (output.includes('401 Unauthorized') || output.includes('401 Bad credentials')) {
190+
svc.log.warn(`Detected 401 error in ${source} - token invalid or expired!`)
191+
throw ApplicationFailure.create({
192+
message: 'GitHub token invalid or expired',
193+
type: 'TokenAuthError',
194+
nonRetryable: true,
195+
details: [failedAtMs],
196+
})
197+
}
198+
199+
// Word-boundary match so unrelated numbers don't get misclassified as HTTP 429/403.
200+
const has403 = /\b403\b/.test(output)
201+
const has429 = /\b429\b/.test(output)
202+
if (!has403 && !has429) return
203+
204+
// 429 is always rate-limit. 403 with rate-limit body is rate-limit. 403 without is a
205+
// permission problem (SAML, missing scopes) — log it but don't throw; the child workflow's
206+
// retry policy will exhaust retries and the repo will be deferred to the next scheduled run.
207+
const isRateLimit = has429 || /rate limit|rate_limit|secondary rate/i.test(output)
208+
if (isRateLimit) {
209+
svc.log.warn(`Detected rate-limit in ${source} - token rate-limited!`)
210+
throw ApplicationFailure.create({
211+
message: 'GitHub token rate-limited',
212+
type: 'Token403Error',
213+
nonRetryable: true,
214+
details: [failedAtMs],
215+
})
216+
}
217+
svc.log.warn(`Detected 403 permission error in ${source} - token may lack access to this repo`)
218+
}
219+
199220
function computeRunDuration(start: string | undefined, end: string | undefined): string {
200221
if (!start || !end) return ''
201222
const startMs = new Date(start).getTime()
@@ -258,13 +279,13 @@ async function runBinary(
258279
resolve({ stdout, stderr })
259280
} else {
260281
const truncated = (s: string) => (s.length > 500 ? s.slice(0, 500) + '…' : s)
261-
const truncStdout = truncated(stdout)
262-
const truncStderr = truncated(stderr)
282+
// Attach full stdout/stderr so classifyTokenError sees rate-limit markers that may
283+
// appear after the truncation cut. Message is still truncated for log readability.
263284
const err = Object.assign(
264285
new Error(
265-
`Binary exited with code ${code}\nStderr:\n${truncStderr}\nStdout:\n${truncStdout}`,
286+
`Binary exited with code ${code}\nStderr:\n${truncated(stderr)}\nStdout:\n${truncated(stdout)}`,
266287
),
267-
{ stdout: truncStdout, stderr: truncStderr },
288+
{ stdout, stderr },
268289
)
269290
reject(err)
270291
}
@@ -276,20 +297,45 @@ export async function initializeTokenInfos(): Promise<ITokenInfo[]> {
276297
const redisCache = new RedisCache(`osps-baseline-insights`, svc.redis, svc.log)
277298

278299
const tokenInfosInRedis = await redisCache.get('tokenInfos')
279-
280-
if (tokenInfosInRedis) {
281-
return JSON.parse(tokenInfosInRedis)
282-
}
283-
284-
return process.env['CROWD_GITHUB_PERSONAL_ACCESS_TOKENS'].split(',').map((token) => ({
285-
token,
286-
inUse: false,
287-
lastUsed: new Date(),
288-
isRateLimited: false,
289-
}))
300+
const cached: ITokenInfo[] = tokenInfosInRedis ? JSON.parse(tokenInfosInRedis) : []
301+
const cachedByToken = new Map(cached.map((t) => [t.token, t]))
302+
303+
// Env var is authoritative for pool membership so PAT rotation is picked up on next run:
304+
// a new token in CROWD_GITHUB_PERSONAL_ACCESS_TOKENS joins the pool fresh, and a removed
305+
// token drops out even if its cached entry is still in Redis.
306+
const envTokens = process.env['CROWD_GITHUB_PERSONAL_ACCESS_TOKENS'].split(',')
307+
308+
return envTokens.map((token) => {
309+
const t = cachedByToken.get(token)
310+
if (t) {
311+
return {
312+
...t,
313+
// Reset inUse — workflow processes may have crashed leaving stale in-use flags.
314+
inUse: false,
315+
// Backward compat: legacy entries have isRateLimited=true with no rateLimitedAt timestamp.
316+
// Without a timestamp the 1-hour expiry can't apply and the token would be stuck forever.
317+
// Clear stale rate-limits that lack a timestamp so they can be retried on this run.
318+
isRateLimited: t.isRateLimited && !!t.rateLimitedAt,
319+
rateLimitedAt: t.isRateLimited && t.rateLimitedAt ? t.rateLimitedAt : undefined,
320+
}
321+
}
322+
return {
323+
token,
324+
inUse: false,
325+
lastUsed: new Date(),
326+
isRateLimited: false,
327+
}
328+
})
290329
}
291330

292331
export async function updateTokenInfos(tokenInfos: ITokenInfo[]): Promise<void> {
293332
const redisCache = new RedisCache(`osps-baseline-insights`, svc.redis, svc.log)
294333
await redisCache.set('tokenInfos', JSON.stringify(tokenInfos), 60 * 60 * 24) // 1 day
295334
}
335+
336+
// Wall-clock time via activity — workflow code can't call Date.now() (determinism rule),
337+
// but rate-limit cooldowns need real elapsed time to expire mid-run rather than only at
338+
// the next continueAsNew batch boundary.
339+
export async function getCurrentTimeMs(): Promise<number> {
340+
return Date.now()
341+
}

services/apps/security_best_practices_worker/src/types.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,10 @@ export interface ITriggerSecurityInsightsCheckForReposParams {
5252
export interface ITokenInfo {
5353
token: string
5454
inUse: boolean
55-
lastUsed: Date
55+
// Date at initialization time; becomes an ISO string after JSON round-trip through Redis
56+
// and Temporal payloads, so callers must wrap in `new Date()` before comparing.
57+
lastUsed: Date | string
5658
isRateLimited: boolean
59+
rateLimitedAt?: string // ISO timestamp; used to auto-reset after 1 hour
60+
isInvalid?: boolean // 401 auth failure; recover by rotating the PAT out of CROWD_GITHUB_PERSONAL_ACCESS_TOKENS
5761
}

0 commit comments

Comments
 (0)