diff --git a/dist/evaluators/RepoPolicyEvaluator.js b/dist/evaluators/RepoPolicyEvaluator.js index 4f8b871..4ce2f4d 100644 --- a/dist/evaluators/RepoPolicyEvaluator.js +++ b/dist/evaluators/RepoPolicyEvaluator.js @@ -12,6 +12,7 @@ const WorkflowsChecks_1 = require("./repository/WorkflowsChecks"); const RunnersChecks_1 = require("./repository/RunnersChecks"); const WebHooksChecks_1 = require("./repository/WebHooksChecks"); const AdminsChecks_1 = require("./repository/AdminsChecks"); +const TagProtectionChecks_1 = require("./repository/TagProtectionChecks"); const outputFormatter_1 = require("../utils/outputFormatter"); // This class is the main Repository evaluator. It evaluates the policy for a given repository. class RepoPolicyEvaluator { @@ -91,6 +92,12 @@ class RepoPolicyEvaluator { logger_1.logger.debug(`Admins checks results: ${JSON.stringify(admins_checks)}`); this.repositoryCheckResults.push(admins_checks); } + if (this.policy.tags) { + const tag_protection = new TagProtectionChecks_1.TagProtectionChecks(this.policy, this.repository); + const tag_protection_results = await tag_protection.checkTagProtection(); + logger_1.logger.debug(`Tag protection rule results: ${JSON.stringify(tag_protection_results, null, 2)}`); + this.repositoryCheckResults.push(tag_protection_results); + } } // Run webhook checks printCheckResults() { diff --git a/dist/evaluators/repository/TagProtectionChecks.js b/dist/evaluators/repository/TagProtectionChecks.js new file mode 100644 index 0000000..5343789 --- /dev/null +++ b/dist/evaluators/repository/TagProtectionChecks.js @@ -0,0 +1,284 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.TagProtectionChecks = void 0; +const Repositories_1 = require("../../github/Repositories"); +class TagProtectionChecks { + policy; + repository; + constructor(policy, repository) { + this.policy = policy; + this.repository = repository; + } + async checkTagProtection() { + const rulesets = await (0, Repositories_1.getRepoRulesets)(this.repository.owner, this.repository.name); + const policyTags = this.policy.tags; + if (!policyTags) { + return this.createResult(true, {}, {}); + } + // Filter to get only tag rulesets that match the policy enforcement + const tagRulesets = rulesets.filter((ruleset) => ruleset.target === "tag" && + ruleset.enforcement === policyTags.enforcement); + const checks = { + enforcement: { passed: false, details: {} }, + scope: { passed: false, details: {} }, + operations: { passed: false, details: {} }, + naming: { passed: false, details: {} }, + bypass: { passed: false, details: {} }, + }; + // Check if we found matching rulesets + if (tagRulesets.length === 0) { + checks.enforcement.passed = false; + checks.enforcement.details = { + expected: policyTags.enforcement, + found: "none", + }; + return this.createResult(false, checks, { + message: "No tag rulesets found with required enforcement level", + }); + } + // For simplicity, we'll check the first matching ruleset + // In production, you might want to check all or aggregate results + const ruleset = tagRulesets[0]; + // Check enforcement + checks.enforcement.passed = ruleset.enforcement === policyTags.enforcement; + checks.enforcement.details = { + expected: policyTags.enforcement, + actual: ruleset.enforcement, + }; + // Check scope (include/exclude patterns) + checks.scope = this.checkScope(policyTags.scope, ruleset.conditions); + // Check operations (create/update/delete) + checks.operations = this.checkOperations(policyTags.operations, ruleset); + // Check naming constraints + if (policyTags.naming?.enabled) { + checks.naming = this.checkNaming(policyTags.naming, ruleset); + } + else { + checks.naming.passed = true; + checks.naming.details = { message: "Naming constraints not enabled" }; + } + // Check bypass actors + if (policyTags.bypass) { + checks.bypass = this.checkBypass(policyTags.bypass, ruleset); + } + else { + checks.bypass.passed = true; + checks.bypass.details = { message: "Bypass not configured in policy" }; + } + const allPassed = checks.enforcement.passed && + checks.scope.passed && + checks.operations.passed && + checks.naming.passed && + checks.bypass.passed; + return this.createResult(allPassed, checks, { + ruleset_id: ruleset.id, + ruleset_name: ruleset.name, + }); + } + checkScope(policyScope, conditions) { + const includePatterns = conditions?.ref_name?.include || []; + const excludePatterns = conditions?.ref_name?.exclude || []; + const expectedInclude = policyScope.include || []; + const expectedExclude = policyScope.exclude || []; + // Normalize patterns - GitHub API returns patterns with refs/tags/ prefix + const normalizePattern = (pattern) => { + return pattern.replace(/^refs\/tags\//, ""); + }; + const normalizedIncludePatterns = includePatterns.map(normalizePattern); + const normalizedExcludePatterns = excludePatterns.map(normalizePattern); + // Check if all expected include patterns are present + const missingIncludes = expectedInclude.filter((pattern) => !normalizedIncludePatterns.includes(pattern)); + // Check if all expected exclude patterns are present + const missingExcludes = expectedExclude.filter((pattern) => !normalizedExcludePatterns.includes(pattern)); + const passed = missingIncludes.length === 0 && missingExcludes.length === 0; + return { + passed, + details: { + expected_include: expectedInclude, + actual_include: normalizedIncludePatterns, + missing_includes: missingIncludes, + expected_exclude: expectedExclude, + actual_exclude: normalizedExcludePatterns, + missing_excludes: missingExcludes, + }, + }; + } + checkOperations(policyOps, ruleset) { + // Extract operation rules from ruleset + const rules = ruleset.rules || []; + const operationRules = { + create: "allowed", + update: "allowed", + delete: "allowed", + }; + // Check for creation, update, and deletion rules + rules.forEach((rule) => { + if (rule.type === "creation") { + operationRules.create = "restricted"; + } + if (rule.type === "update") { + operationRules.update = "restricted"; + } + if (rule.type === "deletion") { + operationRules.delete = "restricted"; + } + }); + const checks = { + create: operationRules.create === policyOps.create, + update: operationRules.update === policyOps.update, + delete: operationRules.delete === policyOps.delete, + }; + const passed = checks.create && checks.update && checks.delete; + return { + passed, + details: { + expected: policyOps, + actual: operationRules, + checks, + }, + }; + } + checkNaming(policyNaming, ruleset) { + const rules = ruleset.rules || []; + // Find tag_name_pattern rule + const tagNamePatternRule = rules.find((rule) => rule.type === "tag_name_pattern"); + if (!tagNamePatternRule) { + return { + passed: false, + details: { + message: "No tag_name_pattern rule found in ruleset", + expected: policyNaming, + actual: null, + }, + }; + } + const params = tagNamePatternRule.parameters || {}; + // Check if operator matches + const operatorMatches = params.operator === policyNaming.operator; + // Check if pattern matches + const patternMatches = params.pattern === policyNaming.pattern; + // Check if negate matches + const negateMatches = params.negate === policyNaming.negate; + const passed = operatorMatches && patternMatches && negateMatches; + return { + passed, + details: { + expected: { + operator: policyNaming.operator, + pattern: policyNaming.pattern, + negate: policyNaming.negate, + }, + actual: { + operator: params.operator, + pattern: params.pattern, + negate: params.negate, + }, + checks: { + operator: operatorMatches, + pattern: patternMatches, + negate: negateMatches, + }, + }, + }; + } + checkBypass(policyBypass, ruleset) { + const bypassActors = ruleset.bypass_actors || []; + const checks = {}; + // Check organization admins + if (policyBypass.organization_admins) { + const orgAdminBypass = bypassActors.find((actor) => actor.actor_type === "OrganizationAdmin"); + checks.organization_admins = { + expected: policyBypass.organization_admins, + found: orgAdminBypass ? orgAdminBypass.bypass_mode : "not configured", + passed: orgAdminBypass?.bypass_mode === policyBypass.organization_admins, + }; + } + // Check teams + if (policyBypass.teams) { + checks.teams = policyBypass.teams.map((team) => { + const teamBypass = bypassActors.find((actor) => actor.actor_type === "Team" && actor.actor_id === team.id); + return { + id: team.id, + expected_mode: team.mode, + actual_mode: teamBypass ? teamBypass.bypass_mode : "not configured", + passed: teamBypass?.bypass_mode === team.mode, + }; + }); + } + // Check integrations + if (policyBypass.integrations) { + checks.integrations = policyBypass.integrations.map((integration) => { + const integrationBypass = bypassActors.find((actor) => actor.actor_type === "Integration" && + actor.actor_id === integration.id); + return { + id: integration.id, + expected_mode: integration.mode, + actual_mode: integrationBypass + ? integrationBypass.bypass_mode + : "not configured", + passed: integrationBypass?.bypass_mode === integration.mode, + }; + }); + } + // Check repository roles + if (policyBypass.repository_roles) { + checks.repository_roles = policyBypass.repository_roles.map((role) => { + const roleBypass = bypassActors.find((actor) => actor.actor_type === "RepositoryRole" && + actor.actor_id === role.id); + return { + id: role.id, + expected_mode: role.mode, + actual_mode: roleBypass ? roleBypass.bypass_mode : "not configured", + passed: roleBypass?.bypass_mode === role.mode, + }; + }); + } + // Check deploy keys + if (policyBypass.deploy_keys) { + const deployKeyBypass = bypassActors.find((actor) => actor.actor_type === "DeployKey"); + checks.deploy_keys = { + expected_allow: policyBypass.deploy_keys.allow, + expected_mode: policyBypass.deploy_keys.mode, + found: deployKeyBypass ? "configured" : "not configured", + actual_mode: deployKeyBypass + ? deployKeyBypass.bypass_mode + : "not configured", + passed: policyBypass.deploy_keys.allow === !!deployKeyBypass && + (!deployKeyBypass || + deployKeyBypass.bypass_mode === policyBypass.deploy_keys.mode), + }; + } + // Determine if all bypass checks passed + const allBypassPassed = Object.values(checks).every((check) => { + if (Array.isArray(check)) { + return check.every((item) => item.passed); + } + return check.passed; + }); + return { + passed: allBypassPassed, + details: checks, + }; + } + createResult(passed, checks, info) { + const name = "Tag Protection"; + // Determine which checks passed and failed + const passedChecks = []; + const failedChecks = {}; + Object.entries(checks).forEach(([key, value]) => { + if (value.passed) { + passedChecks.push(key); + } + else { + failedChecks[key] = value.details; + } + }); + const data = { + passed: passedChecks, + failed: failedChecks, + info, + }; + return { name, pass: passed, data }; + } +} +exports.TagProtectionChecks = TagProtectionChecks; diff --git a/dist/github/Repositories.js b/dist/github/Repositories.js index 89d7fca..241e226 100644 --- a/dist/github/Repositories.js +++ b/dist/github/Repositories.js @@ -1,6 +1,6 @@ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -exports.getRepositoryCodeScanningAnalysis = exports.getRepoDependabotSecurityUpdates = exports.getRepoDependabotAlerts = exports.getRepoFile = exports.getRepoBranchProtection = exports.getRepoProtectedBranches = exports.getRepoBranch = exports.getRepoCollaborators = exports.getRepoPullRequests = exports.getRepository = exports.getRepositoriesForTeamAsAdmin = void 0; +exports.getRepoRulesets = exports.getRepositoryCodeScanningAnalysis = exports.getRepoDependabotSecurityUpdates = exports.getRepoDependabotAlerts = exports.getRepoFile = exports.getRepoBranchProtection = exports.getRepoProtectedBranches = exports.getRepoBranch = exports.getRepoCollaborators = exports.getRepoPullRequests = exports.getRepository = exports.getRepositoriesForTeamAsAdmin = void 0; const GitArmorKit_1 = require("./GitArmorKit"); const logger_1 = require("../utils/logger"); const getRepositoriesForTeamAsAdmin = async (org, teamSlug) => { @@ -151,3 +151,24 @@ const getRepositoryCodeScanningAnalysis = async (owner, repo) => { } }; exports.getRepositoryCodeScanningAnalysis = getRepositoryCodeScanningAnalysis; +// get repository rulesets for tag protection +const getRepoRulesets = async (owner, repo) => { + const octokit = new GitArmorKit_1.GitArmorKit(); + try { + const response = await octokit.rest.repos.getRepoRulesets({ + owner: owner, + repo: repo, + }); + return response.data; + } + catch (error) { + logger_1.logger.debug(`Repository rulesets fetching error: ${error.message}`); + if (error.status === 404) { + return []; + } + else { + throw error; + } + } +}; +exports.getRepoRulesets = getRepoRulesets; diff --git a/dist/index.js b/dist/index.js index 20f385f..4765e2a 100644 --- a/dist/index.js +++ b/dist/index.js @@ -47885,6 +47885,7 @@ const WorkflowsChecks_1 = __nccwpck_require__(8336); const RunnersChecks_1 = __nccwpck_require__(9863); const WebHooksChecks_1 = __nccwpck_require__(1149); const AdminsChecks_1 = __nccwpck_require__(2818); +const TagProtectionChecks_1 = __nccwpck_require__(3739); const outputFormatter_1 = __nccwpck_require__(6871); // This class is the main Repository evaluator. It evaluates the policy for a given repository. class RepoPolicyEvaluator { @@ -47964,6 +47965,12 @@ class RepoPolicyEvaluator { logger_1.logger.debug(`Admins checks results: ${JSON.stringify(admins_checks)}`); this.repositoryCheckResults.push(admins_checks); } + if (this.policy.tags) { + const tag_protection = new TagProtectionChecks_1.TagProtectionChecks(this.policy, this.repository); + const tag_protection_results = await tag_protection.checkTagProtection(); + logger_1.logger.debug(`Tag protection rule results: ${JSON.stringify(tag_protection_results, null, 2)}`); + this.repositoryCheckResults.push(tag_protection_results); + } } // Run webhook checks printCheckResults() { @@ -49215,6 +49222,298 @@ class RunnersChecks { exports.RunnersChecks = RunnersChecks; +/***/ }), + +/***/ 3739: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.TagProtectionChecks = void 0; +const Repositories_1 = __nccwpck_require__(3354); +class TagProtectionChecks { + policy; + repository; + constructor(policy, repository) { + this.policy = policy; + this.repository = repository; + } + async checkTagProtection() { + const rulesets = await (0, Repositories_1.getRepoRulesets)(this.repository.owner, this.repository.name); + const policyTags = this.policy.tags; + if (!policyTags) { + return this.createResult(true, {}, {}); + } + // Filter to get only tag rulesets that match the policy enforcement + const tagRulesets = rulesets.filter((ruleset) => ruleset.target === "tag" && + ruleset.enforcement === policyTags.enforcement); + const checks = { + enforcement: { passed: false, details: {} }, + scope: { passed: false, details: {} }, + operations: { passed: false, details: {} }, + naming: { passed: false, details: {} }, + bypass: { passed: false, details: {} }, + }; + // Check if we found matching rulesets + if (tagRulesets.length === 0) { + checks.enforcement.passed = false; + checks.enforcement.details = { + expected: policyTags.enforcement, + found: "none", + }; + return this.createResult(false, checks, { + message: "No tag rulesets found with required enforcement level", + }); + } + // For simplicity, we'll check the first matching ruleset + // In production, you might want to check all or aggregate results + const ruleset = tagRulesets[0]; + // Check enforcement + checks.enforcement.passed = ruleset.enforcement === policyTags.enforcement; + checks.enforcement.details = { + expected: policyTags.enforcement, + actual: ruleset.enforcement, + }; + // Check scope (include/exclude patterns) + checks.scope = this.checkScope(policyTags.scope, ruleset.conditions); + // Check operations (create/update/delete) + checks.operations = this.checkOperations(policyTags.operations, ruleset); + // Check naming constraints + if (policyTags.naming?.enabled) { + checks.naming = this.checkNaming(policyTags.naming, ruleset); + } + else { + checks.naming.passed = true; + checks.naming.details = { message: "Naming constraints not enabled" }; + } + // Check bypass actors + if (policyTags.bypass) { + checks.bypass = this.checkBypass(policyTags.bypass, ruleset); + } + else { + checks.bypass.passed = true; + checks.bypass.details = { message: "Bypass not configured in policy" }; + } + const allPassed = checks.enforcement.passed && + checks.scope.passed && + checks.operations.passed && + checks.naming.passed && + checks.bypass.passed; + return this.createResult(allPassed, checks, { + ruleset_id: ruleset.id, + ruleset_name: ruleset.name, + }); + } + checkScope(policyScope, conditions) { + const includePatterns = conditions?.ref_name?.include || []; + const excludePatterns = conditions?.ref_name?.exclude || []; + const expectedInclude = policyScope.include || []; + const expectedExclude = policyScope.exclude || []; + // Normalize patterns - GitHub API returns patterns with refs/tags/ prefix + const normalizePattern = (pattern) => { + return pattern.replace(/^refs\/tags\//, ""); + }; + const normalizedIncludePatterns = includePatterns.map(normalizePattern); + const normalizedExcludePatterns = excludePatterns.map(normalizePattern); + // Check if all expected include patterns are present + const missingIncludes = expectedInclude.filter((pattern) => !normalizedIncludePatterns.includes(pattern)); + // Check if all expected exclude patterns are present + const missingExcludes = expectedExclude.filter((pattern) => !normalizedExcludePatterns.includes(pattern)); + const passed = missingIncludes.length === 0 && missingExcludes.length === 0; + return { + passed, + details: { + expected_include: expectedInclude, + actual_include: normalizedIncludePatterns, + missing_includes: missingIncludes, + expected_exclude: expectedExclude, + actual_exclude: normalizedExcludePatterns, + missing_excludes: missingExcludes, + }, + }; + } + checkOperations(policyOps, ruleset) { + // Extract operation rules from ruleset + const rules = ruleset.rules || []; + const operationRules = { + create: "allowed", + update: "allowed", + delete: "allowed", + }; + // Check for creation, update, and deletion rules + rules.forEach((rule) => { + if (rule.type === "creation") { + operationRules.create = "restricted"; + } + if (rule.type === "update") { + operationRules.update = "restricted"; + } + if (rule.type === "deletion") { + operationRules.delete = "restricted"; + } + }); + const checks = { + create: operationRules.create === policyOps.create, + update: operationRules.update === policyOps.update, + delete: operationRules.delete === policyOps.delete, + }; + const passed = checks.create && checks.update && checks.delete; + return { + passed, + details: { + expected: policyOps, + actual: operationRules, + checks, + }, + }; + } + checkNaming(policyNaming, ruleset) { + const rules = ruleset.rules || []; + // Find tag_name_pattern rule + const tagNamePatternRule = rules.find((rule) => rule.type === "tag_name_pattern"); + if (!tagNamePatternRule) { + return { + passed: false, + details: { + message: "No tag_name_pattern rule found in ruleset", + expected: policyNaming, + actual: null, + }, + }; + } + const params = tagNamePatternRule.parameters || {}; + // Check if operator matches + const operatorMatches = params.operator === policyNaming.operator; + // Check if pattern matches + const patternMatches = params.pattern === policyNaming.pattern; + // Check if negate matches + const negateMatches = params.negate === policyNaming.negate; + const passed = operatorMatches && patternMatches && negateMatches; + return { + passed, + details: { + expected: { + operator: policyNaming.operator, + pattern: policyNaming.pattern, + negate: policyNaming.negate, + }, + actual: { + operator: params.operator, + pattern: params.pattern, + negate: params.negate, + }, + checks: { + operator: operatorMatches, + pattern: patternMatches, + negate: negateMatches, + }, + }, + }; + } + checkBypass(policyBypass, ruleset) { + const bypassActors = ruleset.bypass_actors || []; + const checks = {}; + // Check organization admins + if (policyBypass.organization_admins) { + const orgAdminBypass = bypassActors.find((actor) => actor.actor_type === "OrganizationAdmin"); + checks.organization_admins = { + expected: policyBypass.organization_admins, + found: orgAdminBypass ? orgAdminBypass.bypass_mode : "not configured", + passed: orgAdminBypass?.bypass_mode === policyBypass.organization_admins, + }; + } + // Check teams + if (policyBypass.teams) { + checks.teams = policyBypass.teams.map((team) => { + const teamBypass = bypassActors.find((actor) => actor.actor_type === "Team" && actor.actor_id === team.id); + return { + id: team.id, + expected_mode: team.mode, + actual_mode: teamBypass ? teamBypass.bypass_mode : "not configured", + passed: teamBypass?.bypass_mode === team.mode, + }; + }); + } + // Check integrations + if (policyBypass.integrations) { + checks.integrations = policyBypass.integrations.map((integration) => { + const integrationBypass = bypassActors.find((actor) => actor.actor_type === "Integration" && + actor.actor_id === integration.id); + return { + id: integration.id, + expected_mode: integration.mode, + actual_mode: integrationBypass + ? integrationBypass.bypass_mode + : "not configured", + passed: integrationBypass?.bypass_mode === integration.mode, + }; + }); + } + // Check repository roles + if (policyBypass.repository_roles) { + checks.repository_roles = policyBypass.repository_roles.map((role) => { + const roleBypass = bypassActors.find((actor) => actor.actor_type === "RepositoryRole" && + actor.actor_id === role.id); + return { + id: role.id, + expected_mode: role.mode, + actual_mode: roleBypass ? roleBypass.bypass_mode : "not configured", + passed: roleBypass?.bypass_mode === role.mode, + }; + }); + } + // Check deploy keys + if (policyBypass.deploy_keys) { + const deployKeyBypass = bypassActors.find((actor) => actor.actor_type === "DeployKey"); + checks.deploy_keys = { + expected_allow: policyBypass.deploy_keys.allow, + expected_mode: policyBypass.deploy_keys.mode, + found: deployKeyBypass ? "configured" : "not configured", + actual_mode: deployKeyBypass + ? deployKeyBypass.bypass_mode + : "not configured", + passed: policyBypass.deploy_keys.allow === !!deployKeyBypass && + (!deployKeyBypass || + deployKeyBypass.bypass_mode === policyBypass.deploy_keys.mode), + }; + } + // Determine if all bypass checks passed + const allBypassPassed = Object.values(checks).every((check) => { + if (Array.isArray(check)) { + return check.every((item) => item.passed); + } + return check.passed; + }); + return { + passed: allBypassPassed, + details: checks, + }; + } + createResult(passed, checks, info) { + const name = "Tag Protection"; + // Determine which checks passed and failed + const passedChecks = []; + const failedChecks = {}; + Object.entries(checks).forEach(([key, value]) => { + if (value.passed) { + passedChecks.push(key); + } + else { + failedChecks[key] = value.details; + } + }); + const data = { + passed: passedChecks, + failed: failedChecks, + info, + }; + return { name, pass: passed, data }; + } +} +exports.TagProtectionChecks = TagProtectionChecks; + + /***/ }), /***/ 1149: @@ -49656,7 +49955,7 @@ exports.getCustomRolesForOrg = getCustomRolesForOrg; "use strict"; Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.getRepositoryCodeScanningAnalysis = exports.getRepoDependabotSecurityUpdates = exports.getRepoDependabotAlerts = exports.getRepoFile = exports.getRepoBranchProtection = exports.getRepoProtectedBranches = exports.getRepoBranch = exports.getRepoCollaborators = exports.getRepoPullRequests = exports.getRepository = exports.getRepositoriesForTeamAsAdmin = void 0; +exports.getRepoRulesets = exports.getRepositoryCodeScanningAnalysis = exports.getRepoDependabotSecurityUpdates = exports.getRepoDependabotAlerts = exports.getRepoFile = exports.getRepoBranchProtection = exports.getRepoProtectedBranches = exports.getRepoBranch = exports.getRepoCollaborators = exports.getRepoPullRequests = exports.getRepository = exports.getRepositoriesForTeamAsAdmin = void 0; const GitArmorKit_1 = __nccwpck_require__(2009); const logger_1 = __nccwpck_require__(8836); const getRepositoriesForTeamAsAdmin = async (org, teamSlug) => { @@ -49807,6 +50106,27 @@ const getRepositoryCodeScanningAnalysis = async (owner, repo) => { } }; exports.getRepositoryCodeScanningAnalysis = getRepositoryCodeScanningAnalysis; +// get repository rulesets for tag protection +const getRepoRulesets = async (owner, repo) => { + const octokit = new GitArmorKit_1.GitArmorKit(); + try { + const response = await octokit.rest.repos.getRepoRulesets({ + owner: owner, + repo: repo, + }); + return response.data; + } + catch (error) { + logger_1.logger.debug(`Repository rulesets fetching error: ${error.message}`); + if (error.status === 404) { + return []; + } + else { + throw error; + } + } +}; +exports.getRepoRulesets = getRepoRulesets; /***/ }), diff --git a/policies/repository.yml b/policies/repository.yml index 7ccc2ef..0e8617b 100644 --- a/policies/repository.yml +++ b/policies/repository.yml @@ -34,6 +34,43 @@ protected_branches: required_pull_request_reviews: true allow_fork_syncing: false +# define the protected tags for the repository +tags: + enforcement: active # disabled | active | evaluate + target: tag # fixed for tag rules so we can also not specify it here but fix it in code + + scope: + include: + - "v*" # e.g., protect all version tags + # - "~ALL" # special token: all tags + exclude: [] # patterns to exclude, e.g., ["v0.*"] + + operations: # who can perform actions on matching tags + create: restricted # allowed | restricted (restricted = bypass-only) + update: restricted + delete: restricted + + naming: # optional: constrain tag names + enabled: true + operator: regex # starts_with | ends_with | contains | regex + pattern: "^v\\d+\\.\\d+\\.\\d+(-[0-9A-Za-z.-]+)?$" + negate: false # true = pattern disallowed + + bypass: # actors allowed to bypass protections + organization_admins: always # always | exempt + teams: + - id: 1234567 # example team id + mode: always # always | exempt + integrations: + - id: 987654 # GitHub App id + mode: always + repository_roles: + - id: 3 # e.g., Maintainer role id + mode: always + deploy_keys: + allow: true # DeployKeys can bypass when true + mode: always + file_exists: - .github/CODEOWNERS diff --git a/src/evaluators/RepoPolicyEvaluator.ts b/src/evaluators/RepoPolicyEvaluator.ts index 15cf34b..feafb74 100644 --- a/src/evaluators/RepoPolicyEvaluator.ts +++ b/src/evaluators/RepoPolicyEvaluator.ts @@ -10,6 +10,7 @@ import { WorkflowsChecks } from "./repository/WorkflowsChecks"; import { RunnersChecks } from "./repository/RunnersChecks"; import { WebHooksChecks } from "./repository/WebHooksChecks"; import { AdminsChecks } from "./repository/AdminsChecks"; +import { TagProtectionChecks } from "./repository/TagProtectionChecks"; import { printEnhancedCheckResult, printResultsHeader, @@ -159,6 +160,22 @@ export class RepoPolicyEvaluator { logger.debug(`Admins checks results: ${JSON.stringify(admins_checks)}`); this.repositoryCheckResults.push(admins_checks); } + + if (this.policy.tags) { + const tag_protection = new TagProtectionChecks( + this.policy, + this.repository, + ); + const tag_protection_results = await tag_protection.checkTagProtection(); + logger.debug( + `Tag protection rule results: ${JSON.stringify( + tag_protection_results, + null, + 2, + )}`, + ); + this.repositoryCheckResults.push(tag_protection_results); + } } // Run webhook checks diff --git a/src/evaluators/repository/TagProtectionChecks.ts b/src/evaluators/repository/TagProtectionChecks.ts new file mode 100644 index 0000000..8abccb9 --- /dev/null +++ b/src/evaluators/repository/TagProtectionChecks.ts @@ -0,0 +1,374 @@ +import { getRepoRulesets } from "../../github/Repositories"; +import { Repository, CheckResult } from "../../types/common/main"; +import { logger } from "../../utils/logger"; + +export class TagProtectionChecks { + private policy: any; + private repository: Repository; + + constructor(policy: any, repository: Repository) { + this.policy = policy; + this.repository = repository; + } + + async checkTagProtection(): Promise { + const rulesets = await getRepoRulesets( + this.repository.owner, + this.repository.name, + ); + + const policyTags = this.policy.tags; + if (!policyTags) { + return this.createResult(true, {}, {}); + } + + // Filter to get only tag rulesets that match the policy enforcement + const tagRulesets = rulesets.filter( + (ruleset: any) => + ruleset.target === "tag" && + ruleset.enforcement === policyTags.enforcement, + ); + + const checks: any = { + enforcement: { passed: false, details: {} }, + scope: { passed: false, details: {} }, + operations: { passed: false, details: {} }, + naming: { passed: false, details: {} }, + bypass: { passed: false, details: {} }, + }; + + // Check if we found matching rulesets + if (tagRulesets.length === 0) { + checks.enforcement.passed = false; + checks.enforcement.details = { + expected: policyTags.enforcement, + found: "none", + }; + return this.createResult(false, checks, { + message: "No tag rulesets found with required enforcement level", + }); + } + + // For simplicity, we'll check the first matching ruleset + // In production, you might want to check all or aggregate results + const ruleset = tagRulesets[0]; + + // Check enforcement + checks.enforcement.passed = ruleset.enforcement === policyTags.enforcement; + checks.enforcement.details = { + expected: policyTags.enforcement, + actual: ruleset.enforcement, + }; + + // Check scope (include/exclude patterns) + checks.scope = this.checkScope(policyTags.scope, ruleset.conditions); + + // Check operations (create/update/delete) + checks.operations = this.checkOperations(policyTags.operations, ruleset); + + // Check naming constraints + if (policyTags.naming?.enabled) { + checks.naming = this.checkNaming(policyTags.naming, ruleset); + } else { + checks.naming.passed = true; + checks.naming.details = { message: "Naming constraints not enabled" }; + } + + // Check bypass actors + if (policyTags.bypass) { + checks.bypass = this.checkBypass(policyTags.bypass, ruleset); + } else { + checks.bypass.passed = true; + checks.bypass.details = { message: "Bypass not configured in policy" }; + } + + const allPassed = + checks.enforcement.passed && + checks.scope.passed && + checks.operations.passed && + checks.naming.passed && + checks.bypass.passed; + + return this.createResult(allPassed, checks, { + ruleset_id: ruleset.id, + ruleset_name: ruleset.name, + }); + } + + private checkScope( + policyScope: any, + conditions: any, + ): { passed: boolean; details: any } { + const includePatterns = conditions?.ref_name?.include || []; + const excludePatterns = conditions?.ref_name?.exclude || []; + + const expectedInclude = policyScope.include || []; + const expectedExclude = policyScope.exclude || []; + + // Normalize patterns - GitHub API returns patterns with refs/tags/ prefix + const normalizePattern = (pattern: string): string => { + return pattern.replace(/^refs\/tags\//, ""); + }; + + const normalizedIncludePatterns = includePatterns.map(normalizePattern); + const normalizedExcludePatterns = excludePatterns.map(normalizePattern); + + // Check if all expected include patterns are present + const missingIncludes = expectedInclude.filter( + (pattern: string) => !normalizedIncludePatterns.includes(pattern), + ); + + // Check if all expected exclude patterns are present + const missingExcludes = expectedExclude.filter( + (pattern: string) => !normalizedExcludePatterns.includes(pattern), + ); + + const passed = missingIncludes.length === 0 && missingExcludes.length === 0; + + return { + passed, + details: { + expected_include: expectedInclude, + actual_include: normalizedIncludePatterns, + missing_includes: missingIncludes, + expected_exclude: expectedExclude, + actual_exclude: normalizedExcludePatterns, + missing_excludes: missingExcludes, + }, + }; + } + + private checkOperations( + policyOps: any, + ruleset: any, + ): { passed: boolean; details: any } { + // Extract operation rules from ruleset + const rules = ruleset.rules || []; + const operationRules: any = { + create: "allowed", + update: "allowed", + delete: "allowed", + }; + + // Check for creation, update, and deletion rules + rules.forEach((rule: any) => { + if (rule.type === "creation") { + operationRules.create = "restricted"; + } + if (rule.type === "update") { + operationRules.update = "restricted"; + } + if (rule.type === "deletion") { + operationRules.delete = "restricted"; + } + }); + + const checks: any = { + create: operationRules.create === policyOps.create, + update: operationRules.update === policyOps.update, + delete: operationRules.delete === policyOps.delete, + }; + + const passed = checks.create && checks.update && checks.delete; + + return { + passed, + details: { + expected: policyOps, + actual: operationRules, + checks, + }, + }; + } + + private checkNaming( + policyNaming: any, + ruleset: any, + ): { passed: boolean; details: any } { + const rules = ruleset.rules || []; + + // Find tag_name_pattern rule + const tagNamePatternRule = rules.find( + (rule: any) => rule.type === "tag_name_pattern", + ); + + if (!tagNamePatternRule) { + return { + passed: false, + details: { + message: "No tag_name_pattern rule found in ruleset", + expected: policyNaming, + actual: null, + }, + }; + } + + const params = tagNamePatternRule.parameters || {}; + + // Check if operator matches + const operatorMatches = params.operator === policyNaming.operator; + + // Check if pattern matches + const patternMatches = params.pattern === policyNaming.pattern; + + // Check if negate matches + const negateMatches = params.negate === policyNaming.negate; + + const passed = operatorMatches && patternMatches && negateMatches; + + return { + passed, + details: { + expected: { + operator: policyNaming.operator, + pattern: policyNaming.pattern, + negate: policyNaming.negate, + }, + actual: { + operator: params.operator, + pattern: params.pattern, + negate: params.negate, + }, + checks: { + operator: operatorMatches, + pattern: patternMatches, + negate: negateMatches, + }, + }, + }; + } + + private checkBypass( + policyBypass: any, + ruleset: any, + ): { passed: boolean; details: any } { + const bypassActors = ruleset.bypass_actors || []; + + const checks: any = {}; + + // Check organization admins + if (policyBypass.organization_admins) { + const orgAdminBypass = bypassActors.find( + (actor: any) => actor.actor_type === "OrganizationAdmin", + ); + checks.organization_admins = { + expected: policyBypass.organization_admins, + found: orgAdminBypass ? orgAdminBypass.bypass_mode : "not configured", + passed: + orgAdminBypass?.bypass_mode === policyBypass.organization_admins, + }; + } + + // Check teams + if (policyBypass.teams) { + checks.teams = policyBypass.teams.map((team: any) => { + const teamBypass = bypassActors.find( + (actor: any) => + actor.actor_type === "Team" && actor.actor_id === team.id, + ); + return { + id: team.id, + expected_mode: team.mode, + actual_mode: teamBypass ? teamBypass.bypass_mode : "not configured", + passed: teamBypass?.bypass_mode === team.mode, + }; + }); + } + + // Check integrations + if (policyBypass.integrations) { + checks.integrations = policyBypass.integrations.map( + (integration: any) => { + const integrationBypass = bypassActors.find( + (actor: any) => + actor.actor_type === "Integration" && + actor.actor_id === integration.id, + ); + return { + id: integration.id, + expected_mode: integration.mode, + actual_mode: integrationBypass + ? integrationBypass.bypass_mode + : "not configured", + passed: integrationBypass?.bypass_mode === integration.mode, + }; + }, + ); + } + + // Check repository roles + if (policyBypass.repository_roles) { + checks.repository_roles = policyBypass.repository_roles.map( + (role: any) => { + const roleBypass = bypassActors.find( + (actor: any) => + actor.actor_type === "RepositoryRole" && + actor.actor_id === role.id, + ); + return { + id: role.id, + expected_mode: role.mode, + actual_mode: roleBypass ? roleBypass.bypass_mode : "not configured", + passed: roleBypass?.bypass_mode === role.mode, + }; + }, + ); + } + + // Check deploy keys + if (policyBypass.deploy_keys) { + const deployKeyBypass = bypassActors.find( + (actor: any) => actor.actor_type === "DeployKey", + ); + checks.deploy_keys = { + expected_allow: policyBypass.deploy_keys.allow, + expected_mode: policyBypass.deploy_keys.mode, + found: deployKeyBypass ? "configured" : "not configured", + actual_mode: deployKeyBypass + ? deployKeyBypass.bypass_mode + : "not configured", + passed: + policyBypass.deploy_keys.allow === !!deployKeyBypass && + (!deployKeyBypass || + deployKeyBypass.bypass_mode === policyBypass.deploy_keys.mode), + }; + } + + // Determine if all bypass checks passed + const allBypassPassed = Object.values(checks).every((check: any) => { + if (Array.isArray(check)) { + return check.every((item: any) => item.passed); + } + return check.passed; + }); + + return { + passed: allBypassPassed, + details: checks, + }; + } + + private createResult(passed: boolean, checks: any, info: any): CheckResult { + const name = "Tag Protection"; + + // Determine which checks passed and failed + const passedChecks: string[] = []; + const failedChecks: any = {}; + + Object.entries(checks).forEach(([key, value]: [string, any]) => { + if (value.passed) { + passedChecks.push(key); + } else { + failedChecks[key] = value.details; + } + }); + + const data = { + passed: passedChecks, + failed: failedChecks, + info, + }; + + return { name, pass: passed, data }; + } +} diff --git a/src/github/Repositories.ts b/src/github/Repositories.ts index e96b21e..fc84f49 100644 --- a/src/github/Repositories.ts +++ b/src/github/Repositories.ts @@ -223,3 +223,29 @@ export const getRepositoryCodeScanningAnalysis = async ( } } }; + +// get repository rulesets for tag protection +export const getRepoRulesets = async ( + owner: string, + repo: string, +): Promise< + Endpoints["GET /repos/{owner}/{repo}/rulesets"]["response"]["data"] +> => { + const octokit = new GitArmorKit(); + try { + const response: Endpoints["GET /repos/{owner}/{repo}/rulesets"]["response"] = + await octokit.rest.repos.getRepoRulesets({ + owner: owner, + repo: repo, + }); + + return response.data; + } catch (error) { + logger.debug(`Repository rulesets fetching error: ${error.message}`); + if (error.status === 404) { + return []; + } else { + throw error; + } + } +}; diff --git a/src/types/common/main.d.ts b/src/types/common/main.d.ts index bb59ac4..64c753b 100644 --- a/src/types/common/main.d.ts +++ b/src/types/common/main.d.ts @@ -61,6 +61,36 @@ interface ProtectedBranch { allow_fork_syncing?: boolean; } +interface TagProtection { + enforcement: string; + target: string; + scope: { + include: string[]; + exclude: string[]; + }; + operations: { + create: string; + update: string; + delete: string; + }; + naming?: { + enabled: boolean; + operator: string; + pattern: string; + negate: boolean; + }; + bypass?: { + organization_admins?: string; + teams?: Array<{ id: number; mode: string }>; + integrations?: Array<{ id: number; mode: string }>; + repository_roles?: Array<{ id: number; mode: string }>; + deploy_keys?: { + allow: boolean; + mode: string; + }; + }; +} + interface AdvancedSecurity { ghas: boolean; secret_scanning: boolean; @@ -115,6 +145,7 @@ interface WebHookConfig { interface RepoPolicy { protected_branches: ProtectedBranch[]; + tags: TagProtection; file_exists: string[]; file_disallow: string[]; advanced_security: AdvancedSecurity;