diff --git a/__tests__/checks/staticCodeAnalysis.test.js b/__tests__/checks/staticCodeAnalysis.test.js new file mode 100644 index 0000000..626ad0b --- /dev/null +++ b/__tests__/checks/staticCodeAnalysis.test.js @@ -0,0 +1,209 @@ +const knexInit = require('knex') +const { getConfig } = require('../../src/config') +const staticCodeAnalysis = require('../../src/checks/complianceChecks/staticCodeAnalysis') +const { + resetDatabase, initializeStore, generateGithubRepoData, + generateOSSFScorecardData +} = require('../../__utils__') +const { sampleGithubOrg } = require('../../__fixtures__') + +const { dbSettings } = getConfig('test') + +let knex, + project, + check, + addProject, + addGithubOrg, + addGithubRepo, + addOSSFScorecardResult, + getAllResults, + getAllTasks, + getAllAlerts, + addAlert, + addTask, + addResult, + getCheckByCodeName + +beforeAll(async () => { + knex = knexInit(dbSettings); + ({ + addProject, + addGithubOrganization: addGithubOrg, + addOSSFScorecardResult, + addGithubRepo, + getAllResults, + getAllTasks, + getAllAlerts, + addAlert, + addTask, + addResult, + getCheckByCodeName + } = initializeStore(knex)) + check = await getCheckByCodeName('staticCodeAnalysis') +}) + +beforeEach(async () => { + await resetDatabase(knex) + project = await addProject({ name: sampleGithubOrg.login }) +}) + +afterAll(async () => { + await knex.destroy() +}) + +describe('Integration: staticCodeAnalysis', () => { + test('Should add results without alerts or tasks', async () => { + // Add a passed check scenario + const org = await addGithubOrg({ + login: sampleGithubOrg.login, + html_url: sampleGithubOrg.html_url, + project_id: project.id + }) + const repoData = generateGithubRepoData({ org, name: 'repo1', id: 1 }) + await addGithubRepo(repoData) + + const ossfResults = generateOSSFScorecardData({ github_repository_id: 1, sast_score: 10 }) + await addOSSFScorecardResult(ossfResults) + + // Check that the database is empty + let results = await getAllResults() + expect(results.length).toBe(0) + let alerts = await getAllAlerts() + expect(alerts.length).toBe(0) + let tasks = await getAllTasks() + expect(tasks.length).toBe(0) + // Run the check + await expect(staticCodeAnalysis(knex)).resolves.toBeUndefined() + // Check that the database has the expected results + results = await getAllResults() + expect(results.length).toBe(1) + expect(results[0].status).toBe('passed') + expect(results[0].compliance_check_id).toBe(check.id) + alerts = await getAllAlerts() + expect(alerts.length).toBe(0) + tasks = await getAllTasks() + expect(tasks.length).toBe(0) + }) + test('Should delete (previous alerts and tasks) and add results', async () => { + // Prepare the Scenario + const org = await addGithubOrg({ + login: sampleGithubOrg.login, + html_url: sampleGithubOrg.html_url, + project_id: project.id + }) + const repoData = generateGithubRepoData({ org, name: 'repo1' }) + await addGithubRepo(repoData) + + const ossfResults = generateOSSFScorecardData({ github_repository_id: 1, sast_score: 10 }) + await addOSSFScorecardResult(ossfResults) + + await addAlert({ compliance_check_id: check.id, project_id: project.id, title: 'existing', description: 'existing', severity: 'critical' }) + await addTask({ compliance_check_id: check.id, project_id: project.id, title: 'existing', description: 'existing', severity: 'critical' }) + // Check that the database has the expected results + let results = await getAllResults() + expect(results.length).toBe(0) + let alerts = await getAllAlerts() + expect(alerts.length).toBe(1) + expect(alerts[0].compliance_check_id).toBe(check.id) + let tasks = await getAllTasks() + expect(tasks.length).toBe(1) + expect(tasks[0].compliance_check_id).toBe(check.id) + // Run the check + await staticCodeAnalysis(knex) + // Check that the database has the expected results + results = await getAllResults() + expect(results.length).toBe(1) + expect(results[0].status).toBe('passed') + alerts = await getAllAlerts() + expect(alerts.length).toBe(0) + tasks = await getAllTasks() + expect(tasks.length).toBe(0) + }) + + test('Should add (alerts and tasks) and update results', async () => { + const org = await addGithubOrg({ login: sampleGithubOrg.login, html_url: sampleGithubOrg.html_url, project_id: project.id }) + const repoData = generateGithubRepoData({ org, name: 'repo1', secret_scanning_status: 'enabled' }) + await addGithubRepo(repoData) + + const ossfResults = generateOSSFScorecardData({ github_repository_id: 1, sast_score: 4 }) + await addOSSFScorecardResult(ossfResults) + + await addResult({ compliance_check_id: check.id, project_id: project.id, status: 'passed', rationale: 'failed previously', severity: 'critical' }) + // Check that the database has the expected results + let results = await getAllResults() + expect(results.length).toBe(1) + expect(results[0].compliance_check_id).toBe(check.id) + let alerts = await getAllAlerts() + expect(alerts.length).toBe(0) + let tasks = await getAllTasks() + expect(tasks.length).toBe(0) + // Run the check + await staticCodeAnalysis(knex) + // Check that the database has the expected results + results = await getAllResults() + expect(results.length).toBe(1) + expect(results[0].status).toBe('failed') + expect(results[0].rationale).not.toBe('failed previously') + alerts = await getAllAlerts() + expect(alerts.length).toBe(1) + expect(alerts[0].compliance_check_id).toBe(check.id) + tasks = await getAllTasks() + expect(tasks.length).toBe(1) + expect(tasks[0].compliance_check_id).toBe(check.id) + }) + test('Should add (alerts and tasks) and update results without repos and ossf_results', async () => { + // Prepare the Scenario + await addGithubOrg({ login: sampleGithubOrg.login, html_url: sampleGithubOrg.html_url, project_id: project.id }) + + await addResult({ compliance_check_id: check.id, project_id: project.id, status: 'passed', rationale: 'failed previously', severity: 'critical' }) + // Check that the database has the expected results + let results = await getAllResults() + expect(results.length).toBe(1) + expect(results[0].compliance_check_id).toBe(check.id) + let alerts = await getAllAlerts() + expect(alerts.length).toBe(0) + let tasks = await getAllTasks() + expect(tasks.length).toBe(0) + // Run the check + await staticCodeAnalysis(knex) + // Check that the database has the expected results + results = await getAllResults() + expect(results.length).toBe(1) + expect(results[0].status).toBe('passed') + expect(results[0].rationale).not.toBe('failed previously') + alerts = await getAllAlerts() + expect(alerts.length).toBe(0) + tasks = await getAllTasks() + expect(tasks.length).toBe(0) + }) + + test('Should add (alerts and tasks) and update results without ossf_results', async () => { + // Add a passed check scenario + const org = await addGithubOrg({ + login: sampleGithubOrg.login, + html_url: sampleGithubOrg.html_url, + project_id: project.id + }) + const repoData = generateGithubRepoData({ org, name: 'repo1', id: 1 }) + await addGithubRepo(repoData) + + // Check that the database is empty + let results = await getAllResults() + expect(results.length).toBe(0) + let alerts = await getAllAlerts() + expect(alerts.length).toBe(0) + let tasks = await getAllTasks() + expect(tasks.length).toBe(0) + // Run the check + await expect(staticCodeAnalysis(knex)).resolves.toBeUndefined() + // Check that the database has the expected results + results = await getAllResults() + expect(results.length).toBe(1) + expect(results[0].status).toBe('unknown') + expect(results[0].compliance_check_id).toBe(check.id) + alerts = await getAllAlerts() + expect(alerts.length).toBe(0) + tasks = await getAllTasks() + expect(tasks.length).toBe(0) + }) +}) diff --git a/__tests__/checks/validators/staticCodeAnalysis.test.js b/__tests__/checks/validators/staticCodeAnalysis.test.js new file mode 100644 index 0000000..deae6cb --- /dev/null +++ b/__tests__/checks/validators/staticCodeAnalysis.test.js @@ -0,0 +1,225 @@ +const { staticCodeAnalysis } = require('../../../src/checks/validators') + +describe('staticCodeAnalysis', () => { + let data, check, projects + beforeEach(() => { + data = [ + { + project_id: 1, + github_organization_id: 1, + login: 'org1', + repositories: [ + { + id: 1, + name: 'test', + full_name: 'org1/test', + ossf_results: { + sast_score: 10 + } + }, + { + id: 2, + name: 'discussions', + full_name: 'org1/discussions', + ossf_results: { + sast_score: 10 + } + } + ] + }, { + project_id: 1, + github_organization_id: 2, + login: 'org2', + repositories: [ + { + id: 3, + name: '.github', + full_name: 'org2/.github', + ossf_results: { + sast_score: 10 + } + } + ] + }, { + project_id: 2, + github_organization_id: 3, + login: 'org3', + repositories: [ + { + id: 4, + name: 'support', + full_name: 'org3/support', + ossf_results: { + sast_score: 10 + } + } + ] + }] + + check = { + id: 1, + default_priority_group: 'P6', + details_url: 'https://example.com' + } + + projects = [ + { + id: 1 + }, + { + id: 2 + } + ] + }) + + it('Should generate a passed result if all repositories has a high static code analysis score', () => { + const analysis = staticCodeAnalysis({ data, check, projects }) + expect(analysis).toEqual({ + alerts: [], + results: [ + { + project_id: 1, + compliance_check_id: 1, + severity: 'medium', + status: 'passed', + rationale: 'All repositories in all organizations have a static code analysis tool' + }, + { + compliance_check_id: 1, + project_id: 2, + rationale: 'All repositories in all organizations have a static code analysis tool', + severity: 'medium', + status: 'passed' + } + ], + tasks: [] + }) + }) + + it.todo('Should generate a pass result if not have public repositories in some organizations') + it.todo('Should generate a pass result if not have public repositories in all the organizations') + + it('Should generate a failed result if some repositories have low static code analysis score', () => { + data[0].repositories[0].ossf_results.sast_score = 0 + data[0].repositories[1].ossf_results.sast_score = null + data[1].repositories[0].ossf_results.sast_score = 0 + + const analysis = staticCodeAnalysis({ data, check, projects }) + expect(analysis).toEqual({ + alerts: [ + { + project_id: 1, + compliance_check_id: 1, + severity: 'medium', + title: '2 (66.7%) repositories do not have a static code analysis tool', + description: 'Check the details on https://example.com' + } + ], + results: [ + { + project_id: 1, + compliance_check_id: 1, + severity: 'medium', + status: 'failed', + rationale: '2 (66.7%) repositories do not have a static code analysis tool' + }, + { + compliance_check_id: 1, + project_id: 2, + rationale: 'All repositories in all organizations have a static code analysis tool', + severity: 'medium', + status: 'passed' + } + ], + tasks: [ + { + compliance_check_id: 1, + description: 'Check the details on https://example.com', + project_id: 1, + severity: 'medium', + title: 'Add a code analysis tool for 2 (66.7%) repositories (org1/test, org2/.github) in GitHub' + } + ] + }) + }) + + it('Should generate an unknown result if not have ossf results', () => { + data[0].repositories[0].ossf_results = null + data[0].repositories[1].ossf_results = null + data[1].repositories[0].ossf_results = null + + const analysis = staticCodeAnalysis({ data, check, projects }) + expect(analysis).toEqual({ + alerts: [], + results: [ + { + project_id: 1, + compliance_check_id: 1, + severity: 'medium', + status: 'unknown', + rationale: 'No results have been generated from the OSSF Scorecard' + }, + { + compliance_check_id: 1, + project_id: 2, + rationale: 'All repositories in all organizations have a static code analysis tool', + severity: 'medium', + status: 'passed' + } + ], + tasks: [] + }) + }) + + it('Should generate an unknown result if some have repositories have unkown ossf results but other repositories have a high static code analysis score', () => { + data[0].repositories[1].ossf_results = undefined + + const analysis = staticCodeAnalysis({ data, check, projects }) + expect(analysis).toEqual({ + alerts: [], + results: [ + { + project_id: 1, + compliance_check_id: 1, + severity: 'medium', + status: 'unknown', + rationale: '1 (33.3%) repositories do not generated results from the OSSF Scorecard' + }, + { + compliance_check_id: 1, + project_id: 2, + rationale: 'All repositories in all organizations have a static code analysis tool', + severity: 'medium', + status: 'passed' + } + ], + tasks: [] + }) + }) + + it('Should generate an unknown result if some repositories have unknown static code analysis', () => { + data[2].repositories[0].ossf_results.sast_score = null + + const analysis = staticCodeAnalysis({ data, check, projects }) + expect(analysis).toEqual({ + alerts: [], + results: [ + { + project_id: 1, + compliance_check_id: 1, + severity: 'medium', + status: 'passed', + rationale: 'All repositories in all organizations have a static code analysis tool' + }, + { + compliance_check_id: 1, + project_id: 2, + rationale: '1 (100%) repositories could not be determined to have a code analysis tool', + severity: 'medium', + status: 'unknown' + } + ], + tasks: [] + }) + }) +}) diff --git a/__tests__/utils.test.js b/__tests__/utils.test.js index 5459140..485bf47 100644 --- a/__tests__/utils.test.js +++ b/__tests__/utils.test.js @@ -1,4 +1,4 @@ -const { validateGithubUrl, ensureGithubToken, groupArrayItemsByCriteria, getSeverityFromPriorityGroup, isDateWithinPolicy, redactSensitiveData, generatePercentage } = require('../src/utils/index') +const { validateGithubUrl, ensureGithubToken, groupArrayItemsByCriteria, getSeverityFromPriorityGroup, isDateWithinPolicy, redactSensitiveData, generatePercentage, processEntities } = require('../src/utils/index') describe('ensureGithubToken', () => { let originalGithubToken @@ -156,3 +156,435 @@ describe('redactSensitiveData', () => { expect(redactSensitiveData(input)).toBe(input) }) }) + +describe('processEntities', () => { + it('should process entities with relationships', () => { + const data = [ + { + org_id: 1, + org_name: 'Org1', + repo_id: 1, + repo_name: 'Repo1', + ossf_id: 1, + ossf_score: 'A' + }, + { + org_id: 1, + org_name: 'Org1', + repo_id: 2, + repo_name: 'Repo2', + ossf_id: 2, + ossf_score: 'B' + }, + { + org_id: 2, + org_name: 'Org2', + repo_id: 3, + repo_name: 'Repo3', + ossf_id: 3, + ossf_score: 'C' + } + ] + const entityConfig = { + idKey: 'org_id', + excludedKeys: ['repo_id'], + relationships: [ + { + name: 'repositories', + relatedIdKey: 'repo_id', + excludedKeys: ['org_id', 'repo_id'] + } + ] + } + const expected = [ + { + org_id: 1, + org_name: 'Org1', + ossf_id: 1, + ossf_score: 'A', + repo_name: 'Repo1', + repositories: [ + { + org_name: 'Org1', + ossf_id: 1, + ossf_score: 'A', + repo_name: 'Repo1' + }, + { + org_name: 'Org1', + repo_name: 'Repo2', + ossf_id: 2, + ossf_score: 'B' + } + ] + }, + { + org_id: 2, + org_name: 'Org2', + ossf_id: 3, + ossf_score: 'C', + repo_name: 'Repo3', + repositories: [ + { + org_name: 'Org2', + repo_name: 'Repo3', + ossf_id: 3, + ossf_score: 'C' + } + ] + } + ] + expect(processEntities(data, entityConfig)).toEqual(expected) + }) + + it('should process entities without relationships', () => { + const data = [ + { + org_id: 1, + org_name: 'Org1', + repo_id: 1, + repo_name: 'Repo1', + ossf_id: 1, + ossf_score: 'A' + }, + { + org_id: 2, + org_name: 'Org2', + repo_id: 2, + repo_name: 'Repo2', + ossf_id: 2, + ossf_score: 'B' + }, + { + org_id: 3, + org_name: 'Org1', + repo_id: 3, + repo_name: 'Repo3', + ossf_id: 3, + ossf_score: 'A' + } + ] + const entityConfig = { + idKey: 'org_id', + excludedKeys: ['repo_id'], + relationships: [] + } + const expected = [ + { + org_id: 1, + org_name: 'Org1', + ossf_id: 1, + ossf_score: 'A', + repo_name: 'Repo1' + }, + { + org_id: 2, + org_name: 'Org2', + ossf_id: 2, + ossf_score: 'B', + repo_name: 'Repo2' + }, { + org_id: 3, + org_name: 'Org1', + repo_name: 'Repo3', + ossf_id: 3, + ossf_score: 'A' + } + ] + expect(processEntities(data, entityConfig)).toEqual(expected) + }) + + it('should process entities without some properties', () => { + const data = [ + { + org_id: 1, + org_name: 'Org1', + repo_id: 1, + repo_name: 'Repo1' + }, + { + org_id: 1, + org_name: 'Org1', + repo_id: 2, + repo_name: 'Repo2', + ossf_score: 'B' + }, + { + org_id: 2, + org_name: 'Org2', + repo_id: 3, + repo_name: 'Repo3', + ossf_id: 3, + ossf_score: 'C' + } + ] + const entityConfig = { + idKey: 'org_id', + excludedKeys: ['repo_id'], + relationships: [ + { + name: 'repositories', + relatedIdKey: 'repo_id', + excludedKeys: ['org_id', 'repo_id'] + } + ] + } + const expected = [ + { + org_id: 1, + org_name: 'Org1', + repo_name: 'Repo1', + repositories: [ + { + org_name: 'Org1', + repo_name: 'Repo1' + }, + { + org_name: 'Org1', + repo_name: 'Repo2', + ossf_score: 'B' + } + ] + }, + { + org_id: 2, + org_name: 'Org2', + ossf_id: 3, + ossf_score: 'C', + repo_name: 'Repo3', + repositories: [ + { + org_name: 'Org2', + repo_name: 'Repo3', + ossf_id: 3, + ossf_score: 'C' + } + ] + } + ] + expect(processEntities(data, entityConfig)).toEqual(expected) + }) + + it('should process entities with nested relationships', () => { + const data = [ + { + org_id: 1, + org_name: 'Org1', + repo_id: 1, + repo_name: 'Repo1', + ossf_id: 1, + ossf_score: 'A', + rule_id: 1, + rule_name: 'Rule1' + }, + { + org_id: 1, + org_name: 'Org1', + repo_id: 2, + repo_name: 'Repo2', + ossf_id: 2, + ossf_score: 'B', + rule_id: 2, + rule_name: 'Rule2' + }, + { + org_id: 2, + org_name: 'Org2', + repo_id: 3, + repo_name: 'Repo3', + ossf_id: 3, + ossf_score: 'C', + rule_id: 3, + rule_name: 'Rule3' + } + ] + const entityConfig = { + idKey: 'org_id', + excludedKeys: ['repo_id'], + relationships: [ + { + name: 'repositories', + relatedIdKey: 'repo_id', + excludedKeys: ['org_id', 'repo_id'], + relationship: { + name: 'rules', + relatedIdKey: 'rule_id', + excludedKeys: ['org_id', 'repo_id', 'rule_id'] + } + } + ] + } + const expected = [ + { + org_id: 1, + org_name: 'Org1', + ossf_id: 1, + ossf_score: 'A', + repo_name: 'Repo1', + repositories: [ + { + org_name: 'Org1', + ossf_id: 1, + ossf_score: 'A', + repo_name: 'Repo1', + rule_id: 1, + rule_name: 'Rule1', + rules: + { + org_name: 'Org1', + ossf_id: 1, + ossf_score: 'A', + repo_name: 'Repo1', + rule_name: 'Rule1' + } + + }, + { + org_name: 'Org1', + repo_name: 'Repo2', + rule_name: 'Rule2', + rule_id: 2, + ossf_score: 'B', + ossf_id: 2, + rules: { + org_name: 'Org1', + ossf_id: 2, + ossf_score: 'B', + repo_name: 'Repo2', + rule_name: 'Rule2' + } + } + ], + rule_id: 1, + rule_name: 'Rule1' + }, + { + org_id: 2, + org_name: 'Org2', + ossf_id: 3, + ossf_score: 'C', + repo_name: 'Repo3', + repositories: [ + { + org_name: 'Org2', + repo_name: 'Repo3', + ossf_id: 3, + ossf_score: 'C', + rule_id: 3, + rule_name: 'Rule3', + rules: { + org_name: 'Org2', + repo_name: 'Repo3', + ossf_id: 3, + ossf_score: 'C', + rule_name: 'Rule3' + } + } + ], + rule_id: 3, + rule_name: 'Rule3' + } + ] + expect(processEntities(data, entityConfig)).toEqual(expected) + }) + + it('should process entities with nested relationships without some props', () => { + const data = [ + { + org_id: 1, + org_name: 'Org1', + repo_id: 1, + repo_name: 'Repo1', + ossf_id: 1, + ossf_score: 'A', + rule_name: 'Rule1' + }, + { + org_id: 1, + org_name: 'Org1', + repo_id: 2, + repo_name: 'Repo2', + ossf_id: 2, + ossf_score: 'B' + }, + { + org_id: 2, + org_name: 'Org2', + repo_id: 3, + repo_name: 'Repo3', + ossf_id: 3, + ossf_score: 'C', + rule_id: 3, + rule_name: 'Rule3' + } + ] + const entityConfig = { + idKey: 'org_id', + excludedKeys: ['repo_id'], + relationships: [ + { + name: 'repositories', + relatedIdKey: 'repo_id', + excludedKeys: ['org_id', 'repo_id'], + relationship: { + name: 'rules', + relatedIdKey: 'rule_id', + excludedKeys: ['org_id', 'repo_id', 'rule_id'] + } + } + ] + } + const expected = [ + { + org_id: 1, + org_name: 'Org1', + ossf_id: 1, + ossf_score: 'A', + repo_name: 'Repo1', + repositories: [ + { + org_name: 'Org1', + ossf_id: 1, + ossf_score: 'A', + repo_name: 'Repo1', + rule_name: 'Rule1' + }, + { + org_name: 'Org1', + repo_name: 'Repo2', + ossf_score: 'B', + ossf_id: 2 + } + ], + rule_name: 'Rule1' + }, + { + org_id: 2, + org_name: 'Org2', + ossf_id: 3, + ossf_score: 'C', + repo_name: 'Repo3', + repositories: [ + { + org_name: 'Org2', + repo_name: 'Repo3', + ossf_id: 3, + ossf_score: 'C', + rule_id: 3, + rule_name: 'Rule3', + rules: { + org_name: 'Org2', + repo_name: 'Repo3', + ossf_id: 3, + ossf_score: 'C', + rule_name: 'Rule3' + } + } + ], + rule_id: 3, + rule_name: 'Rule3' + } + ] + expect(processEntities(data, entityConfig)).toEqual(expected) + }) +}) diff --git a/__utils__/index.js b/__utils__/index.js index 55d7024..3cd0257 100644 --- a/__utils__/index.js +++ b/__utils__/index.js @@ -28,8 +28,21 @@ const generateGithubRepoData = (data) => { } } +const generateOSSFScorecardData = (data) => { + return { + analysis_score: 10, + analysis_time: '2024-12-11T23:55:17Z', + analysis_execution_time: 19876, + repo_commit: 'dacv23', + scorecard_version: 3, + scorecard_commit: 'dsad', + ...data + } +} + module.exports = { generateGithubRepoData, + generateOSSFScorecardData, resetDatabase, initializeStore } diff --git a/src/checks/complianceChecks/staticCodeAnalysis.js b/src/checks/complianceChecks/staticCodeAnalysis.js new file mode 100644 index 0000000..674a6d6 --- /dev/null +++ b/src/checks/complianceChecks/staticCodeAnalysis.js @@ -0,0 +1,31 @@ +const validators = require('../validators') +const { initializeStore } = require('../../store') +const debug = require('debug')('checks:staticCodeAnalysis') + +module.exports = async (knex, { projects } = {}) => { + const { + getAllOSSFResultsOfRepositoriesByProjectId, getCheckByCodeName, + getAllProjects, addAlert, addTask, upsertComplianceCheckResult, + deleteAlertsByComplianceCheckId, deleteTasksByComplianceCheckId + } = initializeStore(knex) + debug('Collecting relevant data...') + const check = await getCheckByCodeName('staticCodeAnalysis') + if (!projects || (Array.isArray(projects) && projects.length === 0)) { + projects = await getAllProjects() + } + const data = await getAllOSSFResultsOfRepositoriesByProjectId(projects.map(p => p.id)) + + debug('Extracting the validation results...') + const analysis = validators.staticCodeAnalysis({ data, check, projects }) + + debug('Deleting previous alerts and tasks to avoid orphaned records...') + await deleteAlertsByComplianceCheckId(check.id) + await deleteTasksByComplianceCheckId(check.id) + + debug('Upserting the new results...') + await Promise.all(analysis.results.map(result => upsertComplianceCheckResult(result))) + + debug('Inserting the new Alerts and Tasks...') + await Promise.all(analysis.alerts.map(alert => addAlert(alert))) + await Promise.all(analysis.tasks.map(task => addTask(task))) +} diff --git a/src/checks/validators/index.js b/src/checks/validators/index.js index 13acfd7..21cc496 100644 --- a/src/checks/validators/index.js +++ b/src/checks/validators/index.js @@ -3,6 +3,7 @@ const softwareDesignTraining = require('./softwareDesignTraining') const owaspTop10Training = require('./owaspTop10Training') const adminRepoCreationOnly = require('./adminRepoCreationOnly') const noSensitiveInfoInRepositories = require('./noSensitiveInfoInRepositories') +const staticCodeAnalysis = require('./staticCodeAnalysis') const genericProjectPolicyValidator = require('./genericProjectPolicyValidator') const validators = { @@ -11,7 +12,7 @@ const validators = { owaspTop10Training, adminRepoCreationOnly, noSensitiveInfoInRepositories, - // Generic Policies + staticCodeAnalysis, defineFunctionalRoles: genericProjectPolicyValidator('defineFunctionalRoles'), orgToolingMFA: genericProjectPolicyValidator('orgToolingMFA'), softwareArchitectureDocs: genericProjectPolicyValidator('softwareArchitectureDocs'), diff --git a/src/checks/validators/staticCodeAnalysis.js b/src/checks/validators/staticCodeAnalysis.js new file mode 100644 index 0000000..01c7eef --- /dev/null +++ b/src/checks/validators/staticCodeAnalysis.js @@ -0,0 +1,100 @@ +const debug = require('debug')('checks:validator:staticCodeAnalysis') +const { + groupArrayItemsByCriteria, + getSeverityFromPriorityGroup, + generatePercentage +} = require('../../utils') + +const groupByProject = groupArrayItemsByCriteria('project_id') + +const minumumStaticCodeAnalysis = 7 + +// @see: https://github.com/OpenPathfinder/visionBoard/issues/75 +module.exports = ({ data: ghOrgs, check, projects = [] }) => { + debug('Validating that the repositories have static code analysis...') + debug('Grouping repositories by project...') + const ghOrgsGroupedByProject = groupByProject(ghOrgs) + + const alerts = [] + const results = [] + const tasks = [] + + debug('Processing repositories...') + ghOrgsGroupedByProject.forEach((projectOrgs) => { + debug(`Processing Project (${projectOrgs[0].github_organization_id})`) + const project = projects.find(p => p.id === projectOrgs[0].project_id) + const projectRepositories = projectOrgs.map(org => org.repositories).flat() + + const baseData = { + project_id: project.id, + compliance_check_id: check.id, + severity: getSeverityFromPriorityGroup(check.default_priority_group) + } + + const allOSSFResultsPass = projectRepositories.every( + repo => repo.ossf_results?.sast_score >= minumumStaticCodeAnalysis + ) + + if (allOSSFResultsPass) { + results.push({ + ...baseData, + status: 'passed', + rationale: 'All repositories in all organizations have a static code analysis tool' + }) + debug(`Processed project (${project.id}) - All passed`) + return + } + + const failedRepos = projectRepositories.filter(repo => Number.parseInt(repo.ossf_results?.sast_score) < minumumStaticCodeAnalysis).map(org => org.full_name) + const unknownRepos = projectRepositories.filter(repo => repo.ossf_results?.sast_score === null).map(org => org.full_name) + const noGenerateResults = projectRepositories.filter(repo => repo?.ossf_results == null).map(org => org.full_name) + + const result = { ...baseData } + const task = { ...baseData } + const alert = { ...baseData } + + if (noGenerateResults.length === projectRepositories.length) { + result.status = 'unknown' + result.rationale = 'No results have been generated from the OSSF Scorecard' + } else if (failedRepos.length) { + const percentage = generatePercentage(projectRepositories.length, failedRepos.length) + + result.status = 'failed' + result.rationale = `${failedRepos.length} (${percentage}) repositories do not have a static code analysis tool` + alert.title = `${failedRepos.length} (${percentage}) repositories do not have a static code analysis tool` + alert.description = `Check the details on ${check.details_url}` + task.title = `Add a code analysis tool for ${failedRepos.length} (${percentage}) repositories (${failedRepos.join(', ')}) in GitHub` + task.description = `Check the details on ${check.details_url}` + } else if (unknownRepos.length) { + const percentage = generatePercentage(projectRepositories.length, unknownRepos.length) + + result.status = 'unknown' + result.rationale = `${unknownRepos.length} (${percentage}) repositories could not be determined to have a code analysis tool` + } else if (noGenerateResults.length) { + const percentage = generatePercentage(projectRepositories.length, noGenerateResults.length) + + result.status = 'unknown' + result.rationale = `${noGenerateResults.length} (${percentage}) repositories do not generated results from the OSSF Scorecard` + } + + // Include only the task if was populated + if (Object.keys(task).length > Object.keys(baseData).length) { + debug(`Adding task for project (${project.id})`) + tasks.push(task) + } + // Include only the alert if was populated + if (Object.keys(alert).length > Object.keys(baseData).length) { + debug(`Adding alert for project (${project.id})`) + alerts.push(alert) + } + // Always include the result + results.push(result) + debug(`Processed project (${project.id})`) + }) + + return { + alerts, + results, + tasks + } +} diff --git a/src/database/migrations/20250130161647_update_check_static_code_analysis.js b/src/database/migrations/20250130161647_update_check_static_code_analysis.js new file mode 100644 index 0000000..ccdf2ee --- /dev/null +++ b/src/database/migrations/20250130161647_update_check_static_code_analysis.js @@ -0,0 +1,19 @@ +exports.up = async (knex) => { + await knex('compliance_checks') + .where({ code_name: 'staticCodeAnalysis' }) + .update({ + implementation_status: 'completed', + implementation_type: 'computed', + implementation_details_reference: 'https://github.com/OpenPathfinder/visionBoard/issues/83' + }) +} + +exports.down = async (knex) => { + await knex('compliance_checks') + .where({ code_name: 'staticCodeAnalysis' }) + .update({ + implementation_status: 'pending', + implementation_type: null, + implementation_details_reference: null + }) +} diff --git a/src/store/index.js b/src/store/index.js index 0e84fed..07e50fe 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -1,5 +1,5 @@ const debug = require('debug')('store') -const { simplifyObject } = require('@ulisesgascon/simplify-object') +const { processEntities } = require('../utils') const getAllFn = knex => (table) => { debug(`Fetching all records from ${table}...`) @@ -153,32 +153,63 @@ const getAllGithubRepositoriesAndOrganizationByProjectId = async (knex, projectI 'github_organizations.id' ) - // @TODO: Refactor this into a helper function or part of the query - // Transform results into desired structure - const organizationsMap = new Map() - - results.forEach(row => { - const orgId = row.org_id - - if (!organizationsMap.has(orgId)) { - // Create org entry if not exists - const orgData = simplifyObject(row, { - exclude: ['repo_id'] - }) - orgData.repositories = [] - organizationsMap.set(orgId, orgData) - } - - // Add repository if it exists - if (row.repo_id) { - const repoData = simplifyObject(row, { - exclude: ['repo_id', 'org_id'] - }) - organizationsMap.get(orgId).repositories.push(repoData) - } + return processEntities(results, { + idKey: 'org_id', + excludedKeys: ['repo_id'], + relationships: [ + { + name: 'repositories', + relatedIdKey: 'repo_id', + excludedKeys: ['org_id', 'repo_id'] + } + ] }) +} + +const getAllOSSFResultsOfRepositoriesByProjectId = async (knex, projectIds) => { + debug(`Fetching all scorecards results of repositories by project id (${projectIds})...`) + + if (!Array.isArray(projectIds)) { + throw new Error('projectIds must be an array') + } + + const results = await knex('github_organizations') + .select( + 'github_organizations.id as org_id', + 'github_organizations.*', + 'github_repositories.id as repo_id', + 'github_repositories.*', + 'ossf_scorecard_results.id as ossf_id', + 'ossf_scorecard_results.*' + ) + .whereIn('github_organizations.project_id', projectIds) + .leftJoin( + 'github_repositories', + 'github_repositories.github_organization_id', + 'github_organizations.id' + ) + .leftJoin( + 'ossf_scorecard_results', + 'ossf_scorecard_results.github_repository_id', + 'github_repositories.id' + ) - return Array.from(organizationsMap.values()) + return processEntities(results, { + idKey: 'org_id', + excludedKeys: ['repo_id', 'ossf_id'], + relationships: [ + { + name: 'repositories', + relatedIdKey: 'repo_id', + excludedKeys: ['ossf_id', 'repo_id', 'org_id'], + relationship: { + name: 'ossf_results', + relatedIdKey: 'ossf_id', + excludedKeys: ['ossf_id', 'repo_id', 'org_id'] + } + } + ] + }) } const initializeStore = (knex) => { @@ -197,6 +228,7 @@ const initializeStore = (knex) => { getAllOwaspTop10Trainings: () => getAll('owasp_top10_training'), getAllGithubRepositories: () => getAll('github_repositories'), getAllGithubRepositoriesAndOrganizationByProjectId: (organizationId) => getAllGithubRepositoriesAndOrganizationByProjectId(knex, organizationId), + getAllOSSFResultsOfRepositoriesByProjectId: (projectId) => getAllOSSFResultsOfRepositoriesByProjectId(knex, projectId), getAllChecklists: () => getAll('compliance_checklists'), getAllResults: () => getAll('compliance_checks_results'), getAllTasks: () => getAll('compliance_checks_tasks'), diff --git a/src/utils/index.js b/src/utils/index.js index c17152d..0be50b8 100644 --- a/src/utils/index.js +++ b/src/utils/index.js @@ -1,5 +1,6 @@ const { add, parseISO, isBefore } = require('date-fns') const isURL = require('validator/lib/isURL.js') +const { simplifyObject } = require('@ulisesgascon/simplify-object') const pinoInit = require('pino') // GitHub token pattern: looks for patterns matching the GitHub token structure @@ -112,8 +113,40 @@ const isDateWithinPolicy = (targetDate, policy) => { return isBefore(currentDate, expirationDate) // Check if current date is before expiration } +const processEntities = (data, entityConfig) => { + const entityMap = new Map() + + data.forEach(item => { + const entityId = item[entityConfig.idKey] + const entity = simplifyObject(item, { exclude: entityConfig.excludedKeys }) + + if (!entityMap.has(entityId)) { + entityMap.set(entityId, entity) + } + + entityConfig.relationships.forEach(relationship => { + entity[relationship.name] = entity[relationship.name] || [] + + const relatedEntityId = item[relationship.relatedIdKey] + + if (relatedEntityId) { + const relatedEntity = simplifyObject(item, { exclude: relationship.excludedKeys }) + + if (item[relationship?.relationship?.relatedIdKey]) { + relatedEntity[relationship.relationship.name] = relatedEntity[relationship.relationship.name] || simplifyObject(item, { exclude: relationship.relationship.excludedKeys }) + } + + entityMap.get(entityId)[relationship.name].push(relatedEntity) + } + }) + }) + + return Array.from(entityMap.values()) +} + module.exports = { isDateWithinPolicy, + processEntities, validateGithubUrl, ensureGithubToken, getSeverityFromPriorityGroup,