Skip to content

Commit d688b17

Browse files
feat(security-agent): add runtime_state for sync tracking (#919)
2 parents fc10a04 + f2f6edf commit d688b17

10 files changed

Lines changed: 14623 additions & 63 deletions

File tree

cloudflare-security-sync/src/sync.ts

Lines changed: 171 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -125,13 +125,17 @@ type SecurityReviewOwner =
125125
type SyncResult = {
126126
synced: number;
127127
errors: number;
128+
/** Repos where Dependabot alerts are permanently disabled (safe to skip) */
129+
skipped: number;
130+
/** Repos that returned 404 or are access-blocked (deleted/transferred/inaccessible) */
128131
staleRepos: string[];
129132
};
130133

131134
type FetchAlertsResult =
132135
| { status: 'success'; alerts: DependabotAlertRaw[] }
133136
| { status: 'repo_not_found' }
134-
| { status: 'alerts_disabled' };
137+
| { status: 'alerts_disabled' }
138+
| { status: 'access_blocked' };
135139

136140
function isOrgOwner(
137141
owner: SecurityReviewOwner
@@ -160,6 +164,9 @@ type EnabledOwnerConfig = {
160164
repositories: string[];
161165
repoNameToId: Map<string, number>;
162166
slaConfig: SecurityAgentConfig;
167+
/** Number of selected_repository_ids that are no longer accessible via the installation.
168+
* Non-zero means the app lost access to a configured repo — freshness must not advance. */
169+
missingSelectedRepoCount: number;
163170
};
164171

165172
export async function getOwnerConfig(
@@ -232,18 +239,28 @@ export async function getOwnerConfig(
232239
}
233240
const securityConfig = parsed.data;
234241
let selectedRepos: string[];
235-
if (
236-
securityConfig.repository_selection_mode === 'selected' &&
237-
securityConfig.selected_repository_ids &&
238-
securityConfig.selected_repository_ids.length > 0
239-
) {
240-
const selectedIds = new Set(securityConfig.selected_repository_ids);
241-
selectedRepos = allRepos.filter(r => selectedIds.has(r.id)).map(r => r.full_name);
242+
let missingSelectedRepoCount = 0;
243+
if (securityConfig.repository_selection_mode === 'selected') {
244+
const selectedIds = new Set(securityConfig.selected_repository_ids ?? []);
245+
if (selectedIds.size > 0) {
246+
const accessibleIds = new Set(allRepos.map(r => r.id));
247+
selectedRepos = allRepos.filter(r => selectedIds.has(r.id)).map(r => r.full_name);
248+
missingSelectedRepoCount = [...selectedIds].filter(id => !accessibleIds.has(id)).length;
249+
} else {
250+
// Mode is 'selected' but no repos are configured — don't fall through to 'all'
251+
selectedRepos = [];
252+
}
242253
} else {
243254
selectedRepos = allRepos.map(r => r.full_name);
244255
}
245256

246-
if (selectedRepos.length === 0) return null;
257+
if (selectedRepos.length === 0 && missingSelectedRepoCount === 0) return null;
258+
259+
if (missingSelectedRepoCount > 0) {
260+
console.warn(`${missingSelectedRepoCount} selected repo(s) no longer accessible for owner`, {
261+
owner,
262+
});
263+
}
247264

248265
return {
249266
owner,
@@ -252,6 +269,7 @@ export async function getOwnerConfig(
252269
repositories: selectedRepos,
253270
repoNameToId,
254271
slaConfig: { ...DEFAULT_SLA_CONFIG, ...securityConfig },
272+
missingSelectedRepoCount,
255273
};
256274
}
257275

@@ -278,13 +296,27 @@ async function fetchAllDependabotAlerts(
278296
return { status: 'repo_not_found' };
279297
}
280298

299+
// 451 "Unavailable for Legal Reasons" — repo is blocked, won't recover.
300+
if (response.status === 451) {
301+
return { status: 'access_blocked' };
302+
}
303+
281304
if (!response.ok) {
282305
const body = await response.text();
283306

284307
if (
285-
response.status === 403 &&
308+
(response.status === 403 || response.status === 422) &&
309+
body.includes('repository access blocked')
310+
) {
311+
return { status: 'access_blocked' };
312+
}
313+
314+
if (
315+
(response.status === 403 || response.status === 422) &&
286316
(body.includes('Dependabot alerts are disabled') ||
287-
body.includes('Dependabot alerts are not available'))
317+
body.includes('Dependabot alerts are not available') ||
318+
body.includes('archived repositories') ||
319+
body.includes('archived repository'))
288320
) {
289321
return { status: 'alerts_disabled' };
290322
}
@@ -641,21 +673,72 @@ async function pruneStaleReposFromConfig(
641673
);
642674
}
643675

676+
/** Remove selected_repository_ids that are no longer accessible via the GitHub installation.
677+
* Unlike pruneStaleReposFromConfig (which prunes by repo name after sync), this handles
678+
* repos that silently vanished from the installation and were never synced at all. */
679+
async function pruneMissingSelectedRepos(
680+
db: WorkerDb,
681+
owner: SecurityReviewOwner,
682+
accessibleRepoIds: Set<number>
683+
): Promise<void> {
684+
const rows = await db
685+
.select({
686+
id: agent_configs.id,
687+
config: agent_configs.config,
688+
})
689+
.from(agent_configs)
690+
.where(
691+
and(
692+
eq(agent_configs.agent_type, 'security_scan'),
693+
eq(agent_configs.platform, 'github'),
694+
ownerFilter(owner)
695+
)
696+
)
697+
.limit(1);
698+
699+
if (rows.length === 0) return;
700+
701+
const parsed = securityAgentConfigSchema.partial().safeParse(rows[0].config);
702+
if (!parsed.success) return;
703+
const config = parsed.data;
704+
705+
if (
706+
config.repository_selection_mode !== 'selected' ||
707+
!config.selected_repository_ids ||
708+
config.selected_repository_ids.length === 0
709+
) {
710+
return;
711+
}
712+
713+
const prunedIds = config.selected_repository_ids.filter(id => accessibleRepoIds.has(id));
714+
if (prunedIds.length === config.selected_repository_ids.length) return;
715+
716+
const removedCount = config.selected_repository_ids.length - prunedIds.length;
717+
const updatedConfig = { ...config, selected_repository_ids: prunedIds };
718+
await db
719+
.update(agent_configs)
720+
.set({ config: updatedConfig, updated_at: sql`now()` })
721+
.where(eq(agent_configs.id, rows[0].id));
722+
723+
console.warn(`Pruned ${removedCount} inaccessible repo ID(s) from config`);
724+
}
725+
644726
export async function syncOwner(params: {
645727
db: WorkerDb;
646728
gitTokenService: GitTokenService;
647729
owner: SecurityReviewOwner;
648730
runId: string;
649731
}): Promise<SyncResult> {
650732
const { db: database, gitTokenService, owner, runId } = params;
733+
const startTime = Date.now();
651734

652735
const config = await getOwnerConfig(database, owner);
653736
if (!config) {
654737
console.info(`No enabled config for owner, skipping`, { runId, owner });
655-
return { synced: 0, errors: 0, staleRepos: [] };
738+
return { synced: 0, errors: 0, skipped: 0, staleRepos: [] };
656739
}
657740

658-
const totalResult: SyncResult = { synced: 0, errors: 0, staleRepos: [] };
741+
const totalResult: SyncResult = { synced: 0, errors: 0, skipped: 0, staleRepos: [] };
659742
let firstError: Error | null = null;
660743
let successfulRepos = 0;
661744

@@ -672,6 +755,7 @@ export async function syncOwner(params: {
672755
});
673756
totalResult.synced += repoResult.synced;
674757
totalResult.errors += repoResult.errors;
758+
totalResult.skipped += repoResult.skipped;
675759
totalResult.staleRepos.push(...repoResult.staleRepos);
676760
successfulRepos++;
677761
} catch (error) {
@@ -700,6 +784,18 @@ export async function syncOwner(params: {
700784
}
701785
}
702786

787+
// Prune selected repo IDs that silently vanished from the installation
788+
if (config.missingSelectedRepoCount > 0) {
789+
try {
790+
const accessibleRepoIds = new Set(config.repoNameToId.values());
791+
await pruneMissingSelectedRepos(database, owner, accessibleRepoIds);
792+
} catch (error) {
793+
console.error('Failed to prune missing selected repos from config', {
794+
error: error instanceof Error ? error.message : String(error),
795+
});
796+
}
797+
}
798+
703799
// Write audit log
704800
const ownerId =
705801
'organizationId' in owner ? (owner.organizationId ?? 'unknown') : (owner.userId ?? 'unknown');
@@ -724,6 +820,60 @@ export async function syncOwner(params: {
724820
});
725821
}
726822

823+
// Only advance owner-level freshness when every repo was actually synced.
824+
// Stale repos (deleted/transferred/access-blocked) block the update because
825+
// they were selected for sync but never refreshed. Skipped repos
826+
// (Dependabot permanently disabled) do NOT block — that's a permanent
827+
// repo-level setting, and blocking here would leave the timestamp stuck.
828+
// Missing selected repos (installation lost access) also block — the repo
829+
// was configured but silently dropped from the accessible list.
830+
if (
831+
totalResult.errors === 0 &&
832+
totalResult.staleRepos.length === 0 &&
833+
config.missingSelectedRepoCount === 0
834+
) {
835+
try {
836+
await database
837+
.update(agent_configs)
838+
.set({
839+
runtime_state: sql`jsonb_set(
840+
COALESCE(${agent_configs.runtime_state}, '{}'::jsonb),
841+
'{last_synced_at}',
842+
to_jsonb(now())
843+
)`,
844+
})
845+
.where(
846+
and(
847+
eq(agent_configs.agent_type, 'security_scan'),
848+
eq(agent_configs.platform, 'github'),
849+
ownerFilter(owner)
850+
)
851+
);
852+
} catch (error) {
853+
console.error('Failed to update last_synced_at in runtime_state', {
854+
error: error instanceof Error ? error.message : String(error),
855+
});
856+
}
857+
}
858+
859+
const syncSummary = {
860+
runId,
861+
ownerId,
862+
reposScanned: config.repositories.length,
863+
findingsSynced: totalResult.synced,
864+
errors: totalResult.errors,
865+
skippedRepos: totalResult.skipped,
866+
staleRepos: totalResult.staleRepos,
867+
missingSelectedRepos: config.missingSelectedRepoCount,
868+
durationMs: Date.now() - startTime,
869+
};
870+
871+
if (totalResult.synced === 0 && totalResult.errors === 0 && totalResult.skipped === 0) {
872+
console.warn('Sync completed with zero findings processed across all repos', syncSummary);
873+
} else {
874+
console.info('Sync cycle summary', syncSummary);
875+
}
876+
727877
return totalResult;
728878
}
729879

@@ -746,7 +896,7 @@ async function syncRepo(params: {
746896
slaConfig,
747897
} = params;
748898
const token = await gitTokenService.getToken(installationId);
749-
const result: SyncResult = { synced: 0, errors: 0, staleRepos: [] };
899+
const result: SyncResult = { synced: 0, errors: 0, skipped: 0, staleRepos: [] };
750900

751901
const [repoOwner, repoName] = repoFullName.split('/');
752902
if (!repoOwner || !repoName) {
@@ -763,6 +913,13 @@ async function syncRepo(params: {
763913

764914
if (fetchResult.status === 'alerts_disabled') {
765915
console.info(`Dependabot alerts disabled for ${repoFullName}, skipping`);
916+
result.skipped = 1;
917+
return result;
918+
}
919+
920+
if (fetchResult.status === 'access_blocked') {
921+
console.warn(`Repository ${repoFullName} access blocked, marking as stale`);
922+
result.staleRepos.push(repoFullName);
766923
return result;
767924
}
768925

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
ALTER TABLE "agent_configs" ADD COLUMN "runtime_state" jsonb DEFAULT '{}'::jsonb;
2+
3+
-- Backfill last_synced_at from existing findings so owners don't lose their
4+
-- "Last synced" timestamp until the next full sync completes.
5+
UPDATE "agent_configs" ac
6+
SET "runtime_state" = jsonb_set(
7+
COALESCE(ac."runtime_state", '{}'::jsonb),
8+
'{last_synced_at}',
9+
to_jsonb(sf.last_synced)
10+
)
11+
FROM (
12+
SELECT
13+
sf_inner."owned_by_organization_id",
14+
sf_inner."owned_by_user_id",
15+
MAX(sf_inner."last_synced_at") AS last_synced
16+
FROM "security_findings" sf_inner
17+
GROUP BY sf_inner."owned_by_organization_id", sf_inner."owned_by_user_id"
18+
) sf
19+
WHERE ac."agent_type" = 'security_scan'
20+
AND ac."platform" = 'github'
21+
AND (
22+
(ac."owned_by_organization_id" IS NOT NULL AND ac."owned_by_organization_id" = sf."owned_by_organization_id")
23+
OR
24+
(ac."owned_by_user_id" IS NOT NULL AND ac."owned_by_user_id" = sf."owned_by_user_id")
25+
);

0 commit comments

Comments
 (0)