/**
 * This class provides scannng of repositories to ensure they follow the
 * guidance listed in go/cloud-dpe-oss-standards. It can be run locally as
 * a CLI:
 *
 *  $ policy --repo googleapis/sloth
 *
 * Or it can be used as an export to a Google Sheet that's used to construct
 * a dashboard (go/yoshi-live in this case)
 */ import { request } from 'gaxios' ; import { Octokit } from '@octokit/rest' ; import { Endpoints } from '@octokit/types' ; export type GitHubRepo = Endpoints [ 'GET /repos/{owner}/{repo}' ] [ 'response' ] [ 'data' ] ; export const githubRawBase = 'https://raw.githubusercontent.com' ; export interface PolicyResult { /**
     * The short name of the GitHub repository, not including the org/owner
     */ repo : string ; /**
     * The GitHub organization name of the given repository
     */ org : string ; /**
     * A list of repository topics for the given repository
     */ topics : string [ ] ; /**
     * Primary programming language used in the repository
     */ language : string ; /**
     * Does the repository have a `renovate.json` available in the root?
     */ hasRenovateConfig : boolean ; /**
     * Does the repository have a LICENSE file in an approved location, with a valid LICENSE?
     */ hasValidLicense : boolean ; /**
     * Does the repository have a CODE_OF_CONDUCT file available?
     */ hasCodeOfConduct : boolean ; /**
     * Does the repository have a CONTRIBUTING file available?
     */ hasContributing : boolean ; /**
     * Does the repository have a CODEOWNERS file available?
     */ hasCodeowners : boolean ; /**
     * Does the repository have Branch Proection configured in a safe way?
     */ hasBranchProtection : boolean ; /**
     * Does the repository have merge commits disabled?
     */ hasMergeCommitsDisabled : boolean ; /**
     * Does the repository have a SECURITY file available?
     */ hasSecurityPolicy : boolean ; /**
     * Is the default branch set to `main`, and not `master`?
     */ hasMainDefault : boolean ; /**
     * Date when the scan was run for this repository
     */ timestamp : Date ; } export function getPolicy ( octokit , logger ) { return new Policy ( octokit , logger ) ; } export interface Logger { warn : ( message : string ) => void ; } export class Policy { constructor ( private octokit , private logger ) { } param repo async getRepo ( name ) { const [ owner , repo ] = name . split ( '/' ) ; const res = await this . octokit . repos . get ( { owner , repo , headers : { accept : 'application/vnd.github.mercy-preview+json' , } , } ) ; return res . data ; } param search async getRepos ( search ) { const repos = [ ] ; for ( let page = 1 ; ; page ++ ) { const res = await this . octokit . search . repos ( { page , per_page : 100 , q : search , headers : { accept : 'application/vnd.github.mercy-preview+json' , } , } ) ; if ( res . data . incomplete_results ) { this . logger . warn ( `Incomplete results from repo query: ${ search }` ) ; } repos . push ( ... ( res . data . items as { } [ ] as GitHubRepo [ ] ) ) ; if ( res . data . items . length < 100 ) { break ; } } return repos ; } param repo param file param checkMagicFolder async checkFileExists ( repo , file , checkMagicFolder = true ) { const urls = [ `${ githubRawBase }/${ repo . full_name }/${ repo . default_branch }/${ file }` , ] ; if ( checkMagicFolder ) { urls . push ( `${ githubRawBase }/${ repo . full_name }/${ repo . default_branch }/.github/${ file }` ) ; } const results = await Promise . all ( urls . map ( url => { return request < void > ( { url , validateStatus : ( ) => true , } ) ; } ) ) ; const good = results . filter ( x => { if ( x . status >= 500 ) throw Error ( `received ${ x . status } fetching ${ file } in ${ repo }` ) ; return x . status === 200 ; } ) ; return good . length > 0 ; } /**
     * RenovateBot is enabled
     */ async hasRenovate ( repo ) { const results = await Promise . all ( [ this . checkFileExists ( repo , 'renovate.json' , true ) , this . checkFileExists ( repo , 'renovate.json5' , true ) , ] ) ; return results . find ( x => x === true ) || false ; } /**
     * Branch protection with the following rules are enabled:
     * - require code reviews
     * - at least one reviewer required
     * - codeowners approval required
     * - at least one required status check
     */ async hasBranchProtection ( repo ) { const [ owner , name ] = repo . full_name . split ( '/' ) ; type GetBranchProtectionResult = Endpoints [ 'GET /repos/{owner}/{repo}/branches/{branch}/protection' ] [ 'response' ] [ 'data' ] ; let data ; try { const res = await this . octokit . repos . getBranchProtection ( { owner , repo : name , branch : repo . default_branch , } ) ; data = res . data ; } catch ( e ) { const err = e as Error ; console . error ( `Error checking branch protection for ${ repo . full_name }` , err . toString ( ) ) ; return false ; } if ( ! data . required_pull_request_reviews ) { return false ; } if ( ! data . required_status_checks || data . required_status_checks . contexts . length === 0 ) { return false ; } if ( ! data . required_pull_request_reviews . require_code_owner_reviews ) { return false ; } return true ; } /**
     * Ensure there is a `CODEOWNERS` file.  If this is not available,
     * but the flag is enabled in branch protection, GitHub ignores it.
     */ async hasCodeOwners ( repo ) { return this . checkFileExists ( repo , 'CODEOWNERS' , true ) ; } /**
     * Ensure there is a `SECURITY.md` file.
     *
     * In future, also check for:
     * - Reference to SECURITY.md in README.md
     * - Contents of SECURITY.md
     */ async hasSecurityPolicy ( repo ) { return this . checkFileExists ( repo , 'SECURITY.md' , true ) ; } /**
     * Ensure `main` is the default branch name.
     */ async hasMainDefault ( repo ) { return repo . default_branch === 'main' ; } /**
     * Merge Commits are disabled
     */ async hasMergeCommitsDisabled ( repo ) { return ! repo . allow_merge_commit ; } /**
     * Ensure there is a recognized LICENSE. GitHub verifies the license
     * is valid using https://github.com/licensee/licensee. This license
     * list is purposefully small. We can expand as needed.
     */ async hasLicense ( repo ) { const validLicenses = [ 'apache-2.0' , 'mit' , 'bsd-3-clause' ] ; return validLicenses . includes ( repo . license ?. key || '' ) ; } /**
     * Ensure there is a Code of Conduct
     */ async hasCodeOfConduct ( repo ) { return this . checkFileExists ( repo , 'CODE_OF_CONDUCT.md' , true ) ; } /**
     * There is a CONTRIBUTING.md
     */ async hasContributing ( repo ) { return this . checkFileExists ( repo , 'CONTRIBUTING.md' , true ) ; } /**
     * Run all known checks in parallel, and return the results.
     *
     * Note: as of now, the GitHub API is not complaining about potential abuse
     * or rate limiting.  We should keep an eye out here to make sure it doesn't
     * start.
     */ async checkRepoPolicy ( repo ) { const [ hasRenovateConfig , hasValidLicense , hasCodeOfConduct , hasContributing , hasCodeowners , hasBranchProtection , hasMergeCommitsDisabled , hasSecurityPolicy , hasMainDefault , ] = await Promise . all ( [ this . hasRenovate ( repo ) , this . hasLicense ( repo ) , this . hasCodeOfConduct ( repo ) , this . hasContributing ( repo ) , this . hasCodeOwners ( repo ) , this . hasBranchProtection ( repo ) , this . hasMergeCommitsDisabled ( repo ) , this . hasSecurityPolicy ( repo ) , this . hasMainDefault ( repo ) , ] ) ; const [ org , name ] = repo . full_name . split ( '/' ) ; const results = { repo : name , org : org , topics : repo . topics || [ ] , language : repo . language || '' , hasRenovateConfig , hasValidLicense , hasCodeOfConduct , hasContributing , hasCodeowners , hasBranchProtection , hasMainDefault , hasMergeCommitsDisabled , hasSecurityPolicy , timestamp : new Date ( ) , } ; return results ; } }