diff --git a/api/src/additional-findings/data-source.js b/api/src/additional-findings/data-source.js index e466b46191..2ad650c15b 100644 --- a/api/src/additional-findings/data-source.js +++ b/api/src/additional-findings/data-source.js @@ -1,8 +1,7 @@ -import { loadAdditionalFindingsByDomainId, loadTop25Reports } from './loaders' +import { loadAdditionalFindingsByDomainId } from './loaders' export class AdditionalFindingsDataSource { - constructor({ query, userKey, i18n, language }) { - this.getByDomainId = loadAdditionalFindingsByDomainId({ query, userKey, i18n }) - this.getTop25Reports = loadTop25Reports({ query, userKey, i18n, language }) + constructor({ query, userKey, cleanseInput, i18n }) { + this.getByDomainId = loadAdditionalFindingsByDomainId({ query, userKey, cleanseInput, i18n }) } } diff --git a/api/src/additional-findings/input/additional-finding-filter.js b/api/src/additional-findings/input/additional-finding-filter.js new file mode 100644 index 0000000000..a0f062d62c --- /dev/null +++ b/api/src/additional-findings/input/additional-finding-filter.js @@ -0,0 +1,23 @@ +import { GraphQLInputObjectType } from 'graphql' + +import { ComparisonEnums, AdditionalFindingFilterCategory } from '../../enums' +import { FilterValueScalar } from '../../scalars/filter-value' + +export const additionalFindingFilter = new GraphQLInputObjectType({ + name: 'AdditionalFindingFilter', + description: 'This object is used to provide filtering options when querying additional findings.', + fields: () => ({ + filterCategory: { + type: AdditionalFindingFilterCategory, + description: 'Category of filter to be applied.', + }, + comparison: { + type: ComparisonEnums, + description: 'First value equals or does not equal second value.', + }, + filterValue: { + type: FilterValueScalar, + description: 'Additional finding filter value.', + }, + }), +}) diff --git a/api/src/additional-findings/input/additional-finding-order.js b/api/src/additional-findings/input/additional-finding-order.js new file mode 100644 index 0000000000..9cdb45d0cc --- /dev/null +++ b/api/src/additional-findings/input/additional-finding-order.js @@ -0,0 +1,18 @@ +import { GraphQLInputObjectType, GraphQLNonNull } from 'graphql' + +import { OrderDirection, AdditionalFindingOrderField } from '../../enums' + +export const additionalFindingOrder = new GraphQLInputObjectType({ + name: 'AdditionalFindingOrder', + description: 'Ordering options for additional findings.', + fields: () => ({ + field: { + type: new GraphQLNonNull(AdditionalFindingOrderField), + description: 'The field to order additional findings by.', + }, + direction: { + type: new GraphQLNonNull(OrderDirection), + description: 'The ordering direction.', + }, + }), +}) diff --git a/api/src/additional-findings/input/index.js b/api/src/additional-findings/input/index.js index 6c8ee05330..5495bb1e68 100644 --- a/api/src/additional-findings/input/index.js +++ b/api/src/additional-findings/input/index.js @@ -1 +1,3 @@ export * from './cvd-enrollment-options' +export * from './additional-finding-order' +export * from './additional-finding-filter' diff --git a/api/src/additional-findings/loaders/__tests__/load-additional-findings-by-domain-id.test.js b/api/src/additional-findings/loaders/__tests__/load-additional-findings-by-domain-id.test.js index 7e86d2495e..681ad329e8 100644 --- a/api/src/additional-findings/loaders/__tests__/load-additional-findings-by-domain-id.test.js +++ b/api/src/additional-findings/loaders/__tests__/load-additional-findings-by-domain-id.test.js @@ -20,9 +20,31 @@ describe('loadAdditionalFindingsByDomainId', () => { it('throws an error when domainId is not provided', async () => { const loadAdditionalFindingsByDomainId = require('../load-additional-findings-by-domain-id').loadAdditionalFindingsByDomainId - const func = loadAdditionalFindingsByDomainId({ query: jest.fn(), userKey: 'userKey', i18n }) + const func = loadAdditionalFindingsByDomainId({ + query: jest.fn(), + userKey: 'userKey', + cleanseInput: jest.fn((input) => input), + i18n, + }) + + await expect(func({ limit: 10 })).rejects.toThrow( + "You must provide a `domainId` to retrieve a domain's additional findings.", + ) + }) + + it('throws an error when limit is not provided', async () => { + const loadAdditionalFindingsByDomainId = + require('../load-additional-findings-by-domain-id').loadAdditionalFindingsByDomainId + const func = loadAdditionalFindingsByDomainId({ + query: jest.fn(), + userKey: 'userKey', + cleanseInput: jest.fn((input) => input), + i18n, + }) - await expect(func({})).rejects.toThrow("You must provide a `domainId` to retrieve a domain's additional findings.") + await expect(func({ domainId: 'domainId' })).rejects.toThrow( + 'You must provide a `limit` value to properly paginate additional findings.', + ) }) it('throws an error when a database error occurs', async () => { @@ -31,9 +53,14 @@ describe('loadAdditionalFindingsByDomainId', () => { }) const loadAdditionalFindingsByDomainId = require('../load-additional-findings-by-domain-id').loadAdditionalFindingsByDomainId - const func = loadAdditionalFindingsByDomainId({ query, userKey: 'userKey', i18n }) + const func = loadAdditionalFindingsByDomainId({ + query, + userKey: 'userKey', + cleanseInput: jest.fn((input) => input), + i18n, + }) - await expect(func({ domainId: 'domainId' })).rejects.toThrow( + await expect(func({ domainId: 'domainId', limit: 10 })).rejects.toThrow( 'Unable to load additional findings. Please try again.', ) }) @@ -47,23 +74,114 @@ describe('loadAdditionalFindingsByDomainId', () => { const query = jest.fn(() => cursor) const loadAdditionalFindingsByDomainId = require('../load-additional-findings-by-domain-id').loadAdditionalFindingsByDomainId - const func = loadAdditionalFindingsByDomainId({ query, userKey: 'userKey', i18n }) + const func = loadAdditionalFindingsByDomainId({ + query, + userKey: 'userKey', + cleanseInput: jest.fn((input) => input), + i18n, + }) - await expect(func({ domainId: 'domainId' })).rejects.toThrow( + await expect(func({ domainId: 'domainId', limit: 10 })).rejects.toThrow( 'Unable to load additional findings. Please try again.', ) }) - it('returns the finding when everything is correct', async () => { - const finding = { id: 'findingId' } - const cursor = { next: jest.fn(() => finding) } + it('returns a connection when everything is correct', async () => { + const finding = { _key: '1', source: 'scanner-a' } + const cursor = { + next: jest.fn(() => ({ + findings: [finding], + totalCount: 1, + hasMoreRelayPage: false, + hasReversePage: false, + })), + } + const query = jest.fn(() => cursor) + const loadAdditionalFindingsByDomainId = + require('../load-additional-findings-by-domain-id').loadAdditionalFindingsByDomainId + const func = loadAdditionalFindingsByDomainId({ + query, + userKey: 'userKey', + cleanseInput: jest.fn((input) => input), + i18n, + }) + + const result = await func({ domainId: 'domainId', limit: 10 }) + + expect(result.totalCount).toEqual(1) + expect(result.edges).toHaveLength(1) + expect(result.edges[0].node).toEqual(finding) + expect(result.pageInfo).toEqual({ + hasPreviousPage: false, + hasNextPage: false, + startCursor: result.edges[0].cursor, + endCursor: result.edges[0].cursor, + }) + }) + + it('returns an empty connection when there are no findings', async () => { + const cursor = { + next: jest.fn(() => ({ + findings: [], + totalCount: 0, + hasMoreRelayPage: false, + hasReversePage: false, + })), + } + const query = jest.fn(() => cursor) + const loadAdditionalFindingsByDomainId = + require('../load-additional-findings-by-domain-id').loadAdditionalFindingsByDomainId + const func = loadAdditionalFindingsByDomainId({ + query, + userKey: 'userKey', + cleanseInput: jest.fn((input) => input), + i18n, + }) + + const result = await func({ domainId: 'domainId', limit: 10 }) + + expect(result).toEqual({ + edges: [], + totalCount: 0, + pageInfo: { + hasPreviousPage: false, + hasNextPage: false, + startCursor: null, + endCursor: null, + }, + }) + }) + + it('accepts AND filters and orderBy arguments', async () => { + const cursor = { + next: jest.fn(() => ({ + findings: [{ _key: '2', source: 'scanner-b', severity: 'high' }], + totalCount: 1, + hasMoreRelayPage: false, + hasReversePage: false, + })), + } const query = jest.fn(() => cursor) const loadAdditionalFindingsByDomainId = require('../load-additional-findings-by-domain-id').loadAdditionalFindingsByDomainId - const func = loadAdditionalFindingsByDomainId({ query, userKey: 'userKey', i18n }) + const func = loadAdditionalFindingsByDomainId({ + query, + userKey: 'userKey', + cleanseInput: jest.fn((input) => input), + i18n, + }) - const result = await func({ domainId: 'domainId' }) + const result = await func({ + domainId: 'domainId', + limit: 10, + orderBy: { field: 'severity', direction: 'DESC' }, + filters: [ + { filterCategory: 'source', comparison: '==', filterValue: 'scanner-b' }, + { filterCategory: 'severity', comparison: '==', filterValue: 'high' }, + ], + }) - expect(result).toEqual(finding) + expect(query).toHaveBeenCalled() + expect(result.edges).toHaveLength(1) }) }) diff --git a/api/src/additional-findings/loaders/index.js b/api/src/additional-findings/loaders/index.js index 004813d693..c76d70ec1b 100644 --- a/api/src/additional-findings/loaders/index.js +++ b/api/src/additional-findings/loaders/index.js @@ -1,2 +1 @@ export * from './load-additional-findings-by-domain-id' -export * from './load-top-25-reports' diff --git a/api/src/additional-findings/loaders/load-additional-findings-by-domain-id.js b/api/src/additional-findings/loaders/load-additional-findings-by-domain-id.js index 9e99303ee0..a5ba63567c 100644 --- a/api/src/additional-findings/loaders/load-additional-findings-by-domain-id.js +++ b/api/src/additional-findings/loaders/load-additional-findings-by-domain-id.js @@ -1,22 +1,147 @@ +import { aql } from 'arangojs' import { t } from '@lingui/macro' export const loadAdditionalFindingsByDomainId = - ({ query, userKey, i18n }) => - async ({ domainId }) => { + ({ query, userKey, cleanseInput, i18n }) => + async ({ domainId, limit, after, before, orderBy, filters = [] }) => { if (domainId === undefined) { console.warn(`User: ${userKey} did not set \`domainId\` argument for: loadAdditionalFindingsByDomainId.`) throw new Error(i18n._(t`You must provide a \`domainId\` to retrieve a domain's additional findings.`)) } - let cursor - try { - cursor = await query` - WITH additionalFindings, domains + if (limit === undefined) { + console.warn(`User: ${userKey} did not set \`limit\` argument for: loadAdditionalFindingsByDomainId.`) + throw new Error(i18n._(t`You must provide a \`limit\` value to properly paginate additional findings.`)) + } + + if (limit <= 0 || limit > 100) { + console.warn(`User: ${userKey} set \`limit\` argument outside accepted range: loadAdditionalFindingsByDomainId.`) + throw new Error( + i18n._(t`You must provide a \`limit\` value in the range of 1-100 to properly paginate additional findings.`), + ) + } + + before = cleanseInput(before) + after = cleanseInput(after) + + const usingRelayExplicitly = !!(before || after) + + const resolveCursor = (cursor) => { + const cursorString = Buffer.from(cursor, 'base64').toString('utf8').split('|') + + return cursorString.reduce((acc, currentValue) => { + const [type, id] = currentValue.split('::') + acc.push({ type, id }) + return acc + }, []) + } + + const buildComparison = (comparison) => { + return comparison === '==' ? aql`==` : aql`!=` + } + + const buildSingleFilter = (accumulated, { filterCategory, comparison, filterValue }) => { + const cmp = buildComparison(comparison) + + switch (filterCategory) { + case 'source': + return aql`${accumulated} FILTER finding.source ${cmp} ${filterValue}` + case 'findingType': + return aql`${accumulated} FILTER finding.findingType ${cmp} ${filterValue}` + case 'subject': + return aql`${accumulated} FILTER finding.subject ${cmp} ${filterValue}` + case 'confidence': + return aql`${accumulated} FILTER finding.confidence ${cmp} ${filterValue}` + case 'severity': + return aql`${accumulated} FILTER finding.severity ${cmp} ${filterValue}` + case 'status': + return aql`${accumulated} FILTER finding.status ${cmp} ${filterValue}` + default: + return accumulated + } + } + + const findingFilters = filters.reduce(buildSingleFilter, aql``) + + let relayBeforeTemplate = aql`` + let relayAfterTemplate = aql`` + if (usingRelayExplicitly) { + const cursorList = resolveCursor(after || before) + + const orderByDirectionArrow = + orderBy?.direction === 'DESC' ? aql`<` : orderBy?.direction === 'ASC' ? aql`>` : null + const reverseOrderByDirectionArrow = + orderBy?.direction === 'DESC' ? aql`>` : orderBy?.direction === 'ASC' ? aql`<` : null + + relayBeforeTemplate = aql`FILTER TO_NUMBER(finding._key) < TO_NUMBER(${cursorList[0].id})` + relayAfterTemplate = aql`FILTER TO_NUMBER(finding._key) > TO_NUMBER(${cursorList[0].id})` + + if (cursorList.length === 2) { + relayAfterTemplate = aql` + FILTER finding.${cursorList[0].type} ${orderByDirectionArrow || aql`>`} ${cursorList[0].id} + OR (finding.${cursorList[0].type} == ${cursorList[0].id} + AND TO_NUMBER(finding._key) > TO_NUMBER(${cursorList[1].id})) + ` + + relayBeforeTemplate = aql` + FILTER finding.${cursorList[0].type} ${reverseOrderByDirectionArrow || aql`<`} ${cursorList[0].id} + OR (finding.${cursorList[0].type} == ${cursorList[0].id} + AND TO_NUMBER(finding._key) < TO_NUMBER(${cursorList[1].id})) + ` + } + } + + const relayDirectionString = before ? aql`DESC` : aql`ASC` + + let sortTemplate + if (!orderBy) { + sortTemplate = aql`SORT TO_NUMBER(finding._key) ${relayDirectionString}` + } else { + sortTemplate = aql` + SORT finding.${orderBy.field} ${orderBy.direction}, TO_NUMBER(finding._key) ${relayDirectionString} + ` + } + + const removeExtraSliceTemplate = aql`SLICE(findingsPlusOne, 0, ${limit})` + const findingsQuery = aql` + WITH additionalFindings + LET findingsPlusOne = ( FOR finding IN additionalFindings - FILTER finding.domain == ${domainId} + FILTER finding.domainKey == ${domainId} + ${findingFilters} + ${before ? relayBeforeTemplate : relayAfterTemplate} + ${sortTemplate} + LIMIT ${limit + 1} + RETURN MERGE({ id: finding._key, _type: "additionalFinding" }, finding) + ) + LET hasMoreRelayPage = LENGTH(findingsPlusOne) == ${limit} + 1 + LET hasReversePage = ${!usingRelayExplicitly} ? false : (LENGTH( + FOR finding IN additionalFindings + FILTER finding.domainKey == ${domainId} + ${findingFilters} + ${before ? relayAfterTemplate : relayBeforeTemplate} LIMIT 1 - RETURN finding + RETURN true + ) > 0) ? true : false + LET totalCount = COUNT( + FOR finding IN additionalFindings + FILTER finding.domainKey == ${domainId} + ${findingFilters} + RETURN true + ) + LET findings = ${removeExtraSliceTemplate} + + RETURN { + "findings": findings, + "hasMoreRelayPage": hasMoreRelayPage, + "hasReversePage": hasReversePage, + "totalCount": totalCount + } ` + + let findingsCursor + try { + findingsCursor = await query`${findingsQuery}` } catch (err) { console.error( `Database error occurred while user: ${userKey} was trying to gather additional findings for domain: ${domainId}. Error: ${err}`, @@ -24,9 +149,9 @@ export const loadAdditionalFindingsByDomainId = throw new Error(i18n._(t`Unable to load additional findings. Please try again.`)) } - let finding + let findingsInfo try { - finding = await cursor.next() + findingsInfo = await findingsCursor.next() } catch (err) { console.error( `Cursor error occurred while user: ${userKey} was trying to gather additional findings for domain: ${domainId}. Error: ${err}`, @@ -34,5 +159,74 @@ export const loadAdditionalFindingsByDomainId = throw new Error(i18n._(t`Unable to load additional findings. Please try again.`)) } - return finding + const findings = findingsInfo.findings + + if (findings.length === 0) { + return { + edges: [], + totalCount: findingsInfo.totalCount, + pageInfo: { + hasPreviousPage: !usingRelayExplicitly + ? false + : after + ? findingsInfo.hasReversePage + : findingsInfo.hasMoreRelayPage, + hasNextPage: after || !usingRelayExplicitly ? findingsInfo.hasMoreRelayPage : findingsInfo.hasReversePage, + startCursor: null, + endCursor: null, + }, + } + } + + const toCursorString = (cursorObjects) => { + const cursorStringArray = cursorObjects.reduce((acc, cursorObject) => { + acc.push(`${cursorObject.type}::${cursorObject.id}`) + return acc + }, []) + const cursorString = cursorStringArray.join('|') + return Buffer.from(cursorString, 'utf8').toString('base64') + } + + const edges = findings.map((finding) => { + let cursor + if (orderBy) { + cursor = toCursorString([ + { + type: orderBy.field, + id: finding[orderBy.field], + }, + { + type: 'id', + id: finding._key, + }, + ]) + } else { + cursor = toCursorString([ + { + type: 'id', + id: finding._key, + }, + ]) + } + + return { + cursor, + node: finding, + } + }) + + return { + edges, + totalCount: findingsInfo.totalCount, + pageInfo: { + hasPreviousPage: !usingRelayExplicitly + ? false + : after + ? findingsInfo.hasReversePage + : findingsInfo.hasMoreRelayPage, + hasNextPage: after || !usingRelayExplicitly ? findingsInfo.hasMoreRelayPage : findingsInfo.hasReversePage, + endCursor: edges.length > 0 ? edges.at(-1).cursor : null, + startCursor: edges.length > 0 ? edges[0].cursor : null, + }, + } } diff --git a/api/src/additional-findings/loaders/load-top-25-reports.js b/api/src/additional-findings/loaders/load-top-25-reports.js deleted file mode 100644 index c511a5a9a8..0000000000 --- a/api/src/additional-findings/loaders/load-top-25-reports.js +++ /dev/null @@ -1,33 +0,0 @@ -import { t } from '@lingui/macro' - -export const loadTop25Reports = - ({ query, userKey, i18n, language }) => - async () => { - let top25Report - - try { - top25Report = ( - await query` - LET verifiedOrgs = ( - FOR org IN organizations - FILTER org.verified == true - LET orgDetails = TRANSLATE(${language}, org.orgDetails) - RETURN { id: org._id, orgName: orgDetails.name, orgAcronym: orgDetails.acronym } - ) - FOR org IN verifiedOrgs - LET vulnDomainCount = COUNT( - FOR v, e IN 1..1 OUTBOUND org.id claims - OPTIONS { "bfs": true } - FILTER v.cveDetected == true - RETURN v.domain - ) - RETURN MERGE({ assetCount: vulnDomainCount }, org) - ` - ).all() - } catch (err) { - console.error(`Database error occurred when user: ${userKey} running loadTop25Reports: ${err}`) - throw new Error(i18n._(t`Unable to load organization domain statuses. Please try again.`)) - } - - return top25Report - } diff --git a/api/src/additional-findings/objects/__tests__/additional-finding.test.js b/api/src/additional-findings/objects/__tests__/additional-finding.test.js new file mode 100644 index 0000000000..8b2aa3d04e --- /dev/null +++ b/api/src/additional-findings/objects/__tests__/additional-finding.test.js @@ -0,0 +1,84 @@ +import { GraphQLInt, GraphQLObjectType, GraphQLString } from 'graphql' +import { GraphQLJSONObject } from 'graphql-scalars' + +import { additionalFinding } from '../additional-finding' +import { domainType } from '../../../domain/objects' + +describe('additionalFinding GraphQLObjectType', () => { + it('is an instance of GraphQLObjectType', () => { + expect(additionalFinding).toBeInstanceOf(GraphQLObjectType) + }) + + it('has the correct name and description', () => { + expect(additionalFinding.name).toBe('AdditionalFinding') + expect(additionalFinding.description).toBe('Out-of-policy security finding emitted by additional scanners.') + }) + + describe('fields', () => { + const fields = additionalFinding.getFields() + + it('includes all expected fields', () => { + expect(fields).toHaveProperty('domain') + expect(fields).toHaveProperty('source') + expect(fields).toHaveProperty('findingType') + expect(fields).toHaveProperty('subject') + expect(fields).toHaveProperty('confidence') + expect(fields).toHaveProperty('severity') + expect(fields).toHaveProperty('reasonCode') + expect(fields).toHaveProperty('firstSeen') + expect(fields).toHaveProperty('lastSeen') + expect(fields).toHaveProperty('evidence') + expect(fields).toHaveProperty('attributes') + expect(fields).toHaveProperty('occurrenceCount') + expect(fields).toHaveProperty('raw') + expect(fields).toHaveProperty('status') + }) + + it('assigns the domain field type correctly', () => { + expect(fields.domain.type).toMatchObject(domainType) + }) + + it('assigns scalar field types correctly', () => { + expect(fields.source.type).toBe(GraphQLString) + expect(fields.findingType.type).toBe(GraphQLString) + expect(fields.subject.type).toBe(GraphQLString) + expect(fields.confidence.type).toBe(GraphQLString) + expect(fields.severity.type).toBe(GraphQLString) + expect(fields.reasonCode.type).toBe(GraphQLString) + expect(fields.firstSeen.type).toBe(GraphQLString) + expect(fields.lastSeen.type).toBe(GraphQLString) + expect(fields.occurrenceCount.type).toBe(GraphQLInt) + expect(fields.status.type).toBe(GraphQLString) + }) + + it('assigns json object fields correctly', () => { + expect(fields.evidence.type).toBe(GraphQLJSONObject) + expect(fields.attributes.type).toBe(GraphQLJSONObject) + expect(fields.raw.type).toBe(GraphQLJSONObject) + }) + + describe('testing the domain resolver', () => { + it('returns the resolved field', async () => { + const domain = { + _id: 'domains/1', + _rev: 'rev', + _key: '1', + id: '1', + domain: 'test.domain.gc.ca', + } + + await expect( + fields.domain.resolve( + { domainKey: domain._key }, + {}, + { + dataSources: { + domain: { byKey: { load: jest.fn().mockReturnValue(domain) } }, + }, + }, + ), + ).resolves.toEqual(domain) + }) + }) + }) +}) diff --git a/api/src/additional-findings/objects/__tests__/additonal-finding.test.js b/api/src/additional-findings/objects/__tests__/additonal-finding.test.js deleted file mode 100644 index 3d3e0906e7..0000000000 --- a/api/src/additional-findings/objects/__tests__/additonal-finding.test.js +++ /dev/null @@ -1,114 +0,0 @@ -import { - additionalFinding, - webConnectionType, - webComponentLocationType, - webComponentPortType, - webComponentCveType, -} from '../additional-finding.js' -import { GraphQLList, GraphQLString } from 'graphql' -import { GraphQLDateTime } from 'graphql-scalars' - -describe('additionalFinding', () => { - it('should have correct fields', () => { - const fields = additionalFinding.getFields() - - expect(fields.timestamp.type).toBe(GraphQLDateTime) - expect(fields.timestamp.description).toBe('The date the finding was discovered.') - - expect(fields.locations.type).toBeInstanceOf(GraphQLList) - expect(fields.locations.description).toBe('The locations the finding was discovered.') - - expect(fields.ports.type).toBeInstanceOf(GraphQLList) - expect(fields.ports.description).toBe('The ports the finding was discovered.') - - expect(fields.headers.type).toBeInstanceOf(GraphQLList) - expect(fields.headers.description).toBe('The headers the finding was discovered.') - - expect(fields.webComponents.type).toBeInstanceOf(GraphQLList) - expect(fields.webComponents.description).toBe('The web components the finding was discovered.') - - expect(fields.vulnerabilities.type).toBeInstanceOf(GraphQLList) - expect(fields.vulnerabilities.description).toBe('The vulnerabilities the finding was discovered.') - }) -}) - -describe('webConnectionType', () => { - it('should have correct fields', () => { - const fields = webConnectionType.getFields() - - expect(fields.webComponentName.type).toBe(GraphQLString) - expect(fields.webComponentName.description).toBe('The URL of the web component.') - - expect(fields.webComponentCategory.type).toBe(GraphQLString) - expect(fields.webComponentCategory.description).toBe('The type of web component.') - - expect(fields.webComponentVersion.type).toBe(GraphQLString) - expect(fields.webComponentVersion.description).toBe('The status of the web component.') - - expect(fields.webComponentCves.type).toBeInstanceOf(GraphQLList) - expect(fields.webComponentPorts.type).toBeInstanceOf(GraphQLList) - - expect(fields.webComponentFirstSeen.type).toBe(GraphQLString) - expect(fields.webComponentLastSeen.type).toBe(GraphQLString) - }) -}) - -describe('webComponentLocationType', () => { - it('should have correct fields', () => { - const fields = webComponentLocationType.getFields() - - expect(fields.region.type).toBe(GraphQLString) - expect(fields.region.description).toBe('The location of the finding.') - - expect(fields.city.type).toBe(GraphQLString) - expect(fields.city.description).toBe('The location of the finding.') - - expect(fields.latitude.type).toBe(GraphQLString) - expect(fields.latitude.description).toBe('The location of the finding.') - - expect(fields.longitude.type).toBe(GraphQLString) - expect(fields.longitude.description).toBe('The location of the finding.') - - expect(fields.firstSeen.type).toBe(GraphQLString) - expect(fields.firstSeen.description).toBe('The location of the finding.') - - expect(fields.lastSeen.type).toBe(GraphQLString) - expect(fields.lastSeen.description).toBe('The location of the finding.') - }) -}) - -describe('webComponentPortType', () => { - it('should have correct fields', () => { - const fields = webComponentPortType.getFields() - - expect(fields.port.type).toBe(GraphQLString) - expect(fields.port.description).toBe('The port the finding was discovered.') - - expect(fields.lastPortState.type).toBe(GraphQLString) - expect(fields.lastPortState.description).toBe('The protocol the finding was discovered.') - - expect(fields.portStateFirstSeen.type).toBe(GraphQLString) - expect(fields.portStateFirstSeen.description).toBe('The date the finding was discovered.') - - expect(fields.portStateLastSeen.type).toBe(GraphQLString) - expect(fields.portStateLastSeen.description).toBe('The date the finding was discovered.') - }) -}) - -describe('webComponentCveType', () => { - it('should have correct fields', () => { - const fields = webComponentCveType.getFields() - - expect(fields.cve.type).toBe(GraphQLString) - expect(fields.cve.description).toBe('The CVE of the finding.') - - expect(fields.cwe.type).toBe(GraphQLString) - expect(fields.cwe.description).toBe('The description of the CVE.') - - expect(fields.cvssScore.type).toBe(GraphQLString) - expect(fields.cvssScore.description).toBe('The severity of the CVE.') - - expect(fields.cvss3Score.type).toBe(GraphQLString) - expect(fields.cvss3Score.description).toBe('The severity of the CVE.') - }) -}) diff --git a/api/src/additional-findings/objects/additional-finding-connection.js b/api/src/additional-findings/objects/additional-finding-connection.js new file mode 100644 index 0000000000..5f12afcfec --- /dev/null +++ b/api/src/additional-findings/objects/additional-finding-connection.js @@ -0,0 +1,16 @@ +import { GraphQLInt } from 'graphql' +import { connectionDefinitions } from 'graphql-relay' + +import { additionalFinding } from './additional-finding' + +export const additionalFindingConnection = connectionDefinitions({ + name: 'AdditionalFinding', + nodeType: additionalFinding, + connectionFields: () => ({ + totalCount: { + type: GraphQLInt, + description: 'The total amount of additional findings related to a given domain.', + resolve: ({ totalCount }) => totalCount, + }, + }), +}) diff --git a/api/src/additional-findings/objects/additional-finding.js b/api/src/additional-findings/objects/additional-finding.js index e842a01836..b1bf9c93c6 100644 --- a/api/src/additional-findings/objects/additional-finding.js +++ b/api/src/additional-findings/objects/additional-finding.js @@ -1,196 +1,70 @@ -import { GraphQLObjectType, GraphQLList, GraphQLString } from 'graphql' -import { GraphQLDateTime } from 'graphql-scalars' +import { GraphQLInt, GraphQLObjectType, GraphQLString } from 'graphql' +import { GraphQLJSONObject } from 'graphql-scalars' + +import { domainType } from '../../domain/objects' export const additionalFinding = new GraphQLObjectType({ name: 'AdditionalFinding', + description: 'Out-of-policy security finding emitted by additional scanners.', fields: () => ({ - timestamp: { - type: GraphQLDateTime, - description: `The date the finding was discovered.`, - resolve: ({ timestamp }) => new Date(timestamp), - }, - locations: { - type: new GraphQLList(webComponentLocationType), - description: `The locations the finding was discovered.`, - resolve: ({ locations }) => locations, - }, - ports: { - type: new GraphQLList(webComponentPortType), - description: `The ports the finding was discovered.`, - resolve: ({ ports }) => ports, - }, - headers: { - type: new GraphQLList(GraphQLString), - description: `The headers the finding was discovered.`, - resolve: ({ headers }) => headers, - }, - webComponents: { - type: new GraphQLList(webConnectionType), - description: `The web components the finding was discovered.`, - resolve: ({ webComponents }) => webComponents, - }, - vulnerabilities: { - type: new GraphQLList(webComponentCveType), - description: `The vulnerabilities the finding was discovered.`, - resolve: ({ webComponents }) => { - const vulnerabilities = [] - for (const webComponent of webComponents) { - vulnerabilities.push(...webComponent.WebComponentCves) - } - - const jsonObject = vulnerabilities.map(JSON.stringify) - const uniqueSet = new Set(jsonObject) - const uniqueVulns = Array.from(uniqueSet).map(JSON.parse) - uniqueVulns.sort((a, b) => Number(a.Cvss3Score) - Number(b.Cvss3Score)) - - return uniqueVulns + domain: { + type: domainType, + description: 'The domain the finding is attributed to.', + resolve: async ({ domainKey }, _args, { dataSources: { domain: domainDataSource } }) => { + return await domainDataSource.byKey.load(domainKey) }, }, - }), - description: `A finding imported from an external ASM tool.`, -}) - -export const webConnectionType = new GraphQLObjectType({ - name: 'WebConnectionType', - fields: () => ({ - webComponentName: { + source: { type: GraphQLString, - description: `The URL of the web component.`, - resolve: ({ WebComponentName }) => WebComponentName, + description: 'Scanner that emitted the finding.', }, - webComponentCategory: { + findingType: { type: GraphQLString, - description: `The type of web component.`, - resolve: ({ WebComponentCategory }) => WebComponentCategory, + description: 'Specific vulnerability found by scanner.', }, - webComponentVersion: { + subject: { type: GraphQLString, - description: `The status of the web component.`, - resolve: ({ WebComponentVersion }) => WebComponentVersion, - }, - webComponentCves: { - type: new GraphQLList(webComponentCveType), - description: '', - resolve: ({ WebComponentCves }) => WebComponentCves, + description: 'Domain/entity (e.g. hostname) the finding was observed on, as reported by the scanner.', }, - webComponentPorts: { - type: new GraphQLList(webComponentPortType), - description: '', - resolve: ({ WebComponentPorts }) => WebComponentPorts, - }, - webComponentFirstSeen: { - type: GraphQLString, - description: '', - resolve: ({ WebComponentFirstSeen }) => WebComponentFirstSeen, - }, - webComponentLastSeen: { - type: GraphQLString, - description: '', - resolve: ({ WebComponentLastSeen }) => WebComponentLastSeen, - }, - }), -}) - -export const webComponentLocationType = new GraphQLObjectType({ - name: 'WebComponentLocation', - fields: () => ({ - region: { + confidence: { type: GraphQLString, - description: `The location of the finding.`, - resolve: ({ Region }) => Region, + description: 'Confidence level of finding being a true positive/exploitable.', }, - city: { - type: GraphQLString, - description: `The location of the finding.`, - resolve: ({ City }) => City, - }, - latitude: { + severity: { type: GraphQLString, - description: `The location of the finding.`, - resolve: ({ Latitude }) => Latitude, + description: 'The level of risk associated with the vulnerability.', }, - longitude: { + reasonCode: { type: GraphQLString, - description: `The location of the finding.`, - resolve: ({ Longitude }) => Longitude, + description: 'Scanner-generated string used to identify reason for emitting the finding.', }, firstSeen: { type: GraphQLString, - description: `The location of the finding.`, - resolve: ({ FirstSeen }) => FirstSeen, + description: 'Date/time when finding was first detected.', }, lastSeen: { type: GraphQLString, - description: `The location of the finding.`, - resolve: ({ LastSeen }) => LastSeen, + description: 'Most recent date/time when finding was detected.', }, - }), -}) - -export const webComponentPortType = new GraphQLObjectType({ - name: 'WebComponentPort', - fields: () => ({ - port: { - type: GraphQLString, - description: `The port the finding was discovered.`, - resolve: ({ Port }) => Port, - }, - lastPortState: { - type: GraphQLString, - description: `The protocol the finding was discovered.`, - resolve: ({ LastPortState }) => LastPortState, - }, - portStateFirstSeen: { - type: GraphQLString, - description: `The date the finding was discovered.`, - resolve: ({ PortStateFirstSeen }) => PortStateFirstSeen, - }, - portStateLastSeen: { - type: GraphQLString, - description: `The date the finding was discovered.`, - resolve: ({ PortStateLastSeen }) => PortStateLastSeen, - }, - }), -}) - -export const webComponentCveType = new GraphQLObjectType({ - name: 'WebComponentCVE', - fields: () => ({ - cve: { - type: GraphQLString, - description: `The CVE of the finding.`, - resolve: ({ Cve }) => Cve, - }, - cwe: { - type: GraphQLString, - description: `The description of the CVE.`, - resolve: ({ Cwe }) => Cwe, + evidence: { + type: GraphQLJSONObject, + description: 'Map of values used to determine confidence level of vulnerability.', }, - cvssScore: { - type: GraphQLString, - description: `The severity of the CVE.`, - resolve: ({ CvssScore }) => CvssScore, + attributes: { + type: GraphQLJSONObject, + description: 'Additional notable information about the detected vulnerability and/or affiliated resource.', }, - cvss3Score: { - type: GraphQLString, - description: `The severity of the CVE.`, - resolve: ({ Cvss3Score }) => Number(Cvss3Score).toFixed(1), + occurrenceCount: { + type: GraphQLInt, + description: 'Amount of times the vulnerability has been detected.', }, - severity: { - type: GraphQLString, - description: `The severity of the CVE.`, - resolve: ({ Cvss3Score }) => { - const score = Number(Cvss3Score) - if (score >= 9) return 'critical' - else if (score >= 7 && score < 9) return 'high' - else if (score >= 4 && score < 7) return 'medium' - else return 'low' - }, + raw: { + type: GraphQLJSONObject, + description: 'Raw JSON of emitted finding event.', }, - confidenceLevel: { + status: { type: GraphQLString, - description: 'Level of confidence that finding is accurate.', - resolve: ({ ConfidenceLevel }) => ConfidenceLevel, + description: 'Enum value to show if a finding is ongoing, resolved, or dismissed.', }, }), }) diff --git a/api/src/additional-findings/objects/index.js b/api/src/additional-findings/objects/index.js index 18e5dd687a..645720b279 100644 --- a/api/src/additional-findings/objects/index.js +++ b/api/src/additional-findings/objects/index.js @@ -1,2 +1,3 @@ export * from './additional-finding' +export * from './additional-finding-connection' export * from './cvd-enrollment' diff --git a/api/src/additional-findings/queries/__tests__/get-top-25-reports.test.js b/api/src/additional-findings/queries/__tests__/get-top-25-reports.test.js deleted file mode 100644 index 3ecc2ee53d..0000000000 --- a/api/src/additional-findings/queries/__tests__/get-top-25-reports.test.js +++ /dev/null @@ -1,435 +0,0 @@ -import { dbNameFromFile } from 'arango-tools' -import { ensureDatabase as ensure } from '../../../testUtilities' -import { graphql, GraphQLSchema, GraphQLError } from 'graphql' - -import { createQuerySchema } from '../../../query' -import { createMutationSchema } from '../../../mutation' -import { checkSuperAdmin, superAdminRequired, userRequired, verifiedRequired } from '../../../auth' -import { loadUserByKey } from '../../../user/loaders' -import { loadTop25Reports } from '../../loaders' -import dbschema from '../../../../database.json' -import { setupI18n } from '@lingui/core' -import englishMessages from '../../../locale/en/messages' -import frenchMessages from '../../../locale/fr/messages' - -const { DB_PASS: rootPass, DB_URL: url } = process.env - -describe('given getTop25Reports', () => { - // eslint-disable-next-line no-unused-vars - let query, drop, truncate, schema, collections, superAdminOrg, domainOne, domainTwo, orgOne, orgTwo, i18n, user - - const consoleOutput = [] - const mockedInfo = (output) => consoleOutput.push(output) - const mockedWarn = (output) => consoleOutput.push(output) - const mockedError = (output) => consoleOutput.push(output) - beforeAll(async () => { - // Create GQL Schema - schema = new GraphQLSchema({ - query: createQuerySchema(), - mutation: createMutationSchema(), - }) - }) - beforeAll(() => { - i18n = setupI18n({ - locale: 'en', - localeData: { - en: { plurals: {} }, - fr: { plurals: {} }, - }, - locales: ['en', 'fr'], - messages: { - en: englishMessages.messages, - fr: frenchMessages.messages, - }, - }) - }) - beforeAll(async () => { - // Generate DB Items - ;({ query, drop, truncate, collections } = await ensure({ - variables: { - dbname: dbNameFromFile(__filename), - username: 'root', - rootPassword: rootPass, - password: rootPass, - url, - }, - - schema: dbschema, - })) - }) - beforeEach(async () => { - console.info = mockedInfo - console.warn = mockedWarn - console.error = mockedError - consoleOutput.length = 0 - }) - beforeEach(async () => { - user = await collections.users.save({ - displayName: 'Test Account', - userName: 'test.account@istio.actually.exists', - emailValidated: true, - }) - superAdminOrg = await collections.organizations.save({ - orgDetails: { - en: { - slug: 'super-admin', - acronym: 'SA', - name: 'Super Admin', - zone: 'NFED', - sector: 'NTBS', - country: 'Canada', - province: 'Ontario', - city: 'Ottawa', - }, - fr: { - slug: 'super-admin', - acronym: 'SA', - name: 'Super Admin', - zone: 'NPFED', - sector: 'NPTBS', - country: 'Canada', - province: 'Ontario', - city: 'Ottawa', - }, - }, - }) - - orgOne = await collections.organizations.save({ - verified: true, - orgDetails: { - en: { - slug: 'org-one', - acronym: 'OO', - name: 'Org One', - zone: 'NFED', - sector: 'NTBS', - country: 'Canada', - province: 'Ontario', - city: 'Ottawa', - }, - fr: { - slug: 'org-one', - acronym: 'OO', - name: 'Org One', - zone: 'NPFED', - sector: 'NPTBS', - country: 'Canada', - province: 'Ontario', - city: 'Ottawa', - }, - }, - }) - - orgTwo = await collections.organizations.save({ - verified: true, - orgDetails: { - en: { - slug: 'org-two', - acronym: 'OT', - name: 'Org Two', - zone: 'NFED', - sector: 'NTBS', - country: 'Canada', - province: 'Ontario', - city: 'Ottawa', - }, - fr: { - slug: 'org-two', - acronym: 'OT', - name: 'Org Two', - zone: 'NPFED', - sector: 'NPTBS', - country: 'Canada', - province: 'Ontario', - city: 'Ottawa', - }, - }, - }) - - domainOne = await collections.domains.save({ - domain: 'domain.one', - status: { - https: 'fail', - hsts: 'pass', - certificates: 'pass', - ciphers: 'pass', - curves: 'pass', - protocols: 'pass', - spf: 'pass', - dkim: 'pass', - dmarc: 'pass', - }, - rcode: 'NOERROR', - blocked: false, - wildcardSibling: false, - hasEntrustCertificate: false, - cveDetected: true, - }) - domainTwo = await collections.domains.save({ - domain: 'domain.two', - status: { - https: 'pass', - hsts: 'fail', - certificates: 'pass', - ciphers: 'fail', - curves: 'pass', - protocols: 'fail', - spf: 'pass', - dkim: 'pass', - dmarc: 'fail', - }, - rcode: 'NOERROR', - blocked: false, - wildcardSibling: false, - hasEntrustCertificate: false, - cveDetected: true, - }) - - await collections.claims.save({ - _from: orgOne._id, - _to: domainOne._id, - }) - await collections.claims.save({ - _from: orgTwo._id, - _to: domainTwo._id, - }) - }) - afterEach(async () => { - await truncate() - }) - afterAll(async () => { - await drop() - }) - let loginRequiredBool - describe('login is not required', () => { - beforeEach(async () => { - loginRequiredBool = false - }) - describe('the user is not a super admin', () => { - it('returns a permission error', async () => { - const response = await graphql({ - schema, - source: ` - query { - getTop25Reports - } - `, - rootValue: null, - contextValue: { - i18n, - userKey: user._key, - language: 'en', - auth: { - checkSuperAdmin: checkSuperAdmin({ - i18n, - userKey: user._key, - query, - }), - userRequired: userRequired({ - i18n, - userKey: user._key, - loadUserByKey: loadUserByKey({ - query, - userKey: user._key, - i18n, - }), - }), - verifiedRequired: verifiedRequired({}), - superAdminRequired: superAdminRequired({ i18n }), - loginRequiredBool: loginRequiredBool, - }, - dataSources: { - additionalFindings: { - getTop25Reports: loadTop25Reports({ query, userKey: user._key, i18n, language: 'en' }), - }, - }, - }, - }) - const error = [ - new GraphQLError('Permissions error. You do not have sufficient permissions to access this data.'), - ] - expect(response.errors).toEqual(error) - expect(consoleOutput).toEqual([ - `User: ${user._key} attempted to access controlled functionality without sufficient privileges.`, - ]) - }) - }) - describe('the user is a super admin', () => { - beforeEach(async () => { - await collections.affiliations.save({ - _from: superAdminOrg._id, - _to: user._id, - permission: 'super_admin', - }) - }) - - it('returns all domain status results', async () => { - const response = await graphql({ - schema, - source: ` - query { - getTop25Reports - } - `, - rootValue: null, - contextValue: { - i18n, - language: 'en', - userKey: user._key, - auth: { - checkSuperAdmin: checkSuperAdmin({ - i18n, - userKey: user._key, - query, - }), - userRequired: userRequired({ - i18n, - userKey: user._key, - loadUserByKey: loadUserByKey({ - query, - userKey: user._key, - i18n, - }), - }), - verifiedRequired: verifiedRequired({}), - superAdminRequired: superAdminRequired({ i18n }), - loginRequiredBool: loginRequiredBool, - }, - dataSources: { - additionalFindings: { - getTop25Reports: loadTop25Reports({ query, userKey: user._key, i18n, language: 'en' }), - }, - }, - }, - }) - - const expectedResponse = { - data: { - getTop25Reports: `orgName,orgAcronym,assetCount -"Org One","OO","1" -"Org Two","OT","1" -Government of Canada,GC,2`, - }, - } - - expect(response).toEqual(expectedResponse) - expect(consoleOutput).toEqual([`User ${user._key} successfully retrieved all top 25 reports.`]) - }) - }) - }) - describe('login is required', () => { - beforeEach(async () => { - loginRequiredBool = true - }) - describe('the user is not a super admin', () => { - it('returns a permission error', async () => { - const response = await graphql({ - schema, - source: ` - query { - getTop25Reports - } - `, - rootValue: null, - contextValue: { - i18n, - language: 'en', - userKey: user._key, - auth: { - checkSuperAdmin: checkSuperAdmin({ - i18n, - userKey: user._key, - query, - }), - userRequired: userRequired({ - i18n, - userKey: user._key, - loadUserByKey: loadUserByKey({ - query, - userKey: user._key, - i18n, - }), - }), - verifiedRequired: verifiedRequired({}), - superAdminRequired: superAdminRequired({ i18n }), - loginRequiredBool: loginRequiredBool, - }, - dataSources: { - additionalFindings: { - getTop25Reports: loadTop25Reports({ query, userKey: user._key, i18n, language: 'en' }), - }, - }, - }, - }) - const error = [ - new GraphQLError('Permissions error. You do not have sufficient permissions to access this data.'), - ] - - expect(response.errors).toEqual(error) - expect(consoleOutput).toEqual([ - `User: ${user._key} attempted to access controlled functionality without sufficient privileges.`, - ]) - }) - }) - describe('the user is a super admin', () => { - beforeEach(async () => { - await collections.affiliations.save({ - _from: superAdminOrg._id, - _to: user._id, - permission: 'super_admin', - }) - }) - - it('returns all domain status results', async () => { - const response = await graphql({ - schema, - source: ` - query { - getTop25Reports - } - `, - rootValue: null, - contextValue: { - i18n, - language: 'en', - userKey: user._key, - auth: { - checkSuperAdmin: checkSuperAdmin({ - i18n, - userKey: user._key, - query, - }), - userRequired: userRequired({ - i18n, - userKey: user._key, - loadUserByKey: loadUserByKey({ - query, - userKey: user._key, - i18n, - }), - }), - verifiedRequired: verifiedRequired({}), - superAdminRequired: superAdminRequired({ i18n }), - loginRequiredBool: loginRequiredBool, - }, - dataSources: { - additionalFindings: { - getTop25Reports: loadTop25Reports({ query, userKey: user._key, i18n, language: 'en' }), - }, - }, - }, - }) - const expectedResponse = { - data: { - getTop25Reports: `orgName,orgAcronym,assetCount -"Org One","OO","1" -"Org Two","OT","1" -Government of Canada,GC,2`, - }, - } - expect(response).toEqual(expectedResponse) - expect(consoleOutput).toEqual([`User ${user._key} successfully retrieved all top 25 reports.`]) - }) - }) - }) -}) diff --git a/api/src/additional-findings/queries/get-top-25-report.js b/api/src/additional-findings/queries/get-top-25-report.js deleted file mode 100644 index ec770e1419..0000000000 --- a/api/src/additional-findings/queries/get-top-25-report.js +++ /dev/null @@ -1,45 +0,0 @@ -import { GraphQLString } from 'graphql' - -export const getTop25Reports = { - type: GraphQLString, - description: 'CSV formatted output of top 25 reports.', - resolve: async ( - _, - args, - { - userKey, - auth: { checkSuperAdmin, userRequired, verifiedRequired, superAdminRequired }, - dataSources: { additionalFindings }, - language, - }, - ) => { - const user = await userRequired() - verifiedRequired({ user }) - - const isSuperAdmin = await checkSuperAdmin() - superAdminRequired({ user, isSuperAdmin }) - - const top25Reports = await additionalFindings.getTop25Reports({ ...args }) - - console.info(`User ${userKey} successfully retrieved all top 25 reports.`) - - if (top25Reports === undefined) return top25Reports - - const headers = ['orgName', 'orgAcronym', 'assetCount'] - let csvOutput = headers.join(',') - let totalAssetCount = 0 - top25Reports.forEach((domainStatus) => { - const csvLine = headers - .map((header) => { - return `"${domainStatus[header]}"` - }) - .join(',') - csvOutput += `\n${csvLine}` - totalAssetCount += domainStatus.assetCount - }) - const govName = language === 'en' ? 'Government of Canada' : 'Gouvernement du Canada' - csvOutput += `\n${govName},GC,${totalAssetCount}` - - return csvOutput - }, -} diff --git a/api/src/additional-findings/queries/index.js b/api/src/additional-findings/queries/index.js deleted file mode 100644 index 8542ff42c1..0000000000 --- a/api/src/additional-findings/queries/index.js +++ /dev/null @@ -1 +0,0 @@ -export * from './get-top-25-report' diff --git a/api/src/create-context.js b/api/src/create-context.js index 4f66bc71cc..133bdeaf57 100644 --- a/api/src/create-context.js +++ b/api/src/create-context.js @@ -147,7 +147,7 @@ export async function createContext({ dataSources: { auth: new AuthDataSource({ query, userKey, i18n }), summaries: new SummariesDataSource({ query, userKey, cleanseInput, i18n }), - additionalFindings: new AdditionalFindingsDataSource({ query, userKey, i18n, language: request.language }), + additionalFindings: new AdditionalFindingsDataSource({ query, userKey, cleanseInput, i18n, language: request.language }), auditLogs: new AuditLogsDataSource({ query, userKey, cleanseInput, i18n, transaction, collections }), dnsScan: new DnsScanDataSource({ query, userKey, cleanseInput, i18n }), guidanceTag: new GuidanceTagDataSource({ query, userKey, i18n, language: request.language, cleanseInput }), diff --git a/api/src/domain/objects/domain.js b/api/src/domain/objects/domain.js index 4bc535174a..42120a5694 100644 --- a/api/src/domain/objects/domain.js +++ b/api/src/domain/objects/domain.js @@ -14,9 +14,9 @@ import { organizationConnection } from '../../organization/objects' import { GraphQLDateTime } from 'graphql-scalars' import { dnsOrder } from '../../dns-scan/inputs' import { webOrder } from '../../web-scan/inputs/web-order' -import { additionalFinding } from '../../additional-findings/objects/additional-finding' +import { additionalFindingConnection, cvdEnrollment } from '../../additional-findings/objects' +import { additionalFindingFilter, additionalFindingOrder } from '../../additional-findings/input' import { tagType } from '../../tags/objects' -import { cvdEnrollment } from '../../additional-findings/objects' export const domainType = new GraphQLObjectType({ name: 'Domain', @@ -213,11 +213,26 @@ export const domainType = new GraphQLObjectType({ }, }, additionalFindings: { - type: additionalFinding, + type: additionalFindingConnection.connectionType, description: 'Additional findings imported from an external ASM tool.', + args: { + orderBy: { + type: additionalFindingOrder, + description: 'Ordering options for additional findings.', + }, + filters: { + type: new GraphQLList(additionalFindingFilter), + description: 'Filters used to limit additional findings returned.', + }, + limit: { + type: GraphQLInt, + description: 'Number of additional findings to retrieve.', + }, + ...connectionArgs, + }, resolve: async ( - { _id }, - _, + { _id, _key }, + args, { userKey, auth: { userRequired }, dataSources: { auth: authDS, additionalFindings } }, ) => { await userRequired() @@ -230,7 +245,8 @@ export const domainType = new GraphQLObjectType({ } return await additionalFindings.getByDomainId({ - domainId: _id, + domainId: _key, + ...args, }) }, }, diff --git a/api/src/enums/additional-finding-filter-category.js b/api/src/enums/additional-finding-filter-category.js new file mode 100644 index 0000000000..5dd340125a --- /dev/null +++ b/api/src/enums/additional-finding-filter-category.js @@ -0,0 +1,17 @@ +import { GraphQLEnumType } from 'graphql' +import { AdditionalFindingOrderField } from './additional-finding-order-field' + +export const AdditionalFindingFilterCategory = new GraphQLEnumType({ + name: 'AdditionalFindingFilterCategory', + description: 'Properties by which additional findings can be filtered.', + values: { + ...AdditionalFindingOrderField.getValues().reduce((acc, { name, value, description }) => { + acc[name] = { value, description } + return acc + }, {}), + DOMAIN: { + value: 'domain', + description: 'Filter additional findings by domain.', + }, + }, +}) diff --git a/api/src/enums/additional-finding-order-field.js b/api/src/enums/additional-finding-order-field.js new file mode 100644 index 0000000000..a1864213b6 --- /dev/null +++ b/api/src/enums/additional-finding-order-field.js @@ -0,0 +1,48 @@ +import { GraphQLEnumType } from 'graphql' + +export const AdditionalFindingOrderField = new GraphQLEnumType({ + name: 'AdditionalFindingOrderField', + description: 'Properties by which additional findings can be ordered.', + values: { + SOURCE: { + value: 'source', + description: 'Order additional findings by source.', + }, + FINDING_TYPE: { + value: 'findingType', + description: 'Order additional findings by finding type.', + }, + SUBJECT: { + value: 'subject', + description: 'Order additional findings by subject.', + }, + CONFIDENCE: { + value: 'confidence', + description: 'Order additional findings by confidence.', + }, + SEVERITY: { + value: 'severity', + description: 'Order additional findings by severity.', + }, + REASON_CODE: { + value: 'reasonCode', + description: 'Order additional findings by reason code.', + }, + FIRST_SEEN: { + value: 'firstSeen', + description: 'Order additional findings by first seen.', + }, + LAST_SEEN: { + value: 'lastSeen', + description: 'Order additional findings by last seen.', + }, + OCCURRENCE_COUNT: { + value: 'occurrenceCount', + description: 'Order additional findings by occurrence count.', + }, + STATUS: { + value: 'status', + description: 'Order additional findings by status.', + }, + }, +}) diff --git a/api/src/enums/index.js b/api/src/enums/index.js index 89c87f1dad..1e47352658 100644 --- a/api/src/enums/index.js +++ b/api/src/enums/index.js @@ -37,3 +37,5 @@ export * from './domain-filter-category' export * from './dmarc-phase' export * from './enrollment-status' export * from './cvd-requirement' +export * from './additional-finding-order-field' +export * from './additional-finding-filter-category' diff --git a/api/src/locale/en/messages.po b/api/src/locale/en/messages.po index 4e7b025431..be9e7cc0e1 100644 --- a/api/src/locale/en/messages.po +++ b/api/src/locale/en/messages.po @@ -123,7 +123,7 @@ msgstr "`{argSet}` on the `VerifiedDomain` connection cannot be less than zero." msgid "`{argSet}` on the `VerifiedOrganization` connection cannot be less than zero." msgstr "`{argSet}` on the `VerifiedOrganization` connection cannot be less than zero." -#: src/organization/objects/organization.js:246 +#: src/organization/objects/organization.js:256 #: src/organization/queries/get-all-organization-domain-statuses.js:69 msgid "Assess" msgstr "Assess" @@ -138,11 +138,11 @@ msgstr "Assess" msgid "Authentication error. Please sign in." msgstr "Authentication error. Please sign in." -#: src/domain/objects/domain.js:229 +#: src/domain/objects/domain.js:245 msgid "Cannot query additional findings without permission." msgstr "Cannot query additional findings without permission." -#: src/organization/objects/organization.js:364 +#: src/organization/objects/organization.js:374 msgid "Cannot query affiliations on organization without admin permission or higher." msgstr "Cannot query affiliations on organization without admin permission or higher." @@ -151,15 +151,15 @@ msgstr "Cannot query affiliations on organization without admin permission or hi msgid "Cannot query audit logs on organization without admin permission or higher." msgstr "Cannot query audit logs on organization without admin permission or higher." -#: src/domain/objects/domain.js:164 +#: src/domain/objects/domain.js:165 msgid "Cannot query dns scan results without permission." msgstr "Cannot query dns scan results without permission." -#: src/domain/objects/domain.js:65 +#: src/domain/objects/domain.js:66 msgid "Cannot query domain selectors without permission." msgstr "Cannot query domain selectors without permission." -#: src/domain/objects/domain.js:206 +#: src/domain/objects/domain.js:207 msgid "Cannot query web scan results without permission." msgstr "Cannot query web scan results without permission." @@ -171,7 +171,7 @@ msgstr "CVE is already ignored for this domain." msgid "CVE is not ignored for this domain." msgstr "CVE is not ignored for this domain." -#: src/organization/objects/organization.js:248 +#: src/organization/objects/organization.js:258 #: src/organization/queries/get-all-organization-domain-statuses.js:71 msgid "Deploy" msgstr "Deploy" @@ -180,7 +180,7 @@ msgstr "Deploy" msgid "Email already in use." msgstr "Email already in use." -#: src/organization/objects/organization.js:250 +#: src/organization/objects/organization.js:260 #: src/organization/queries/get-all-organization-domain-statuses.js:73 msgid "Enforce" msgstr "Enforce" @@ -217,7 +217,7 @@ msgstr "Incorrect username or password. Please try again." msgid "Invalid token, please sign in." msgstr "Invalid token, please sign in." -#: src/organization/objects/organization.js:252 +#: src/organization/objects/organization.js:262 #: src/organization/queries/get-all-organization-domain-statuses.js:75 msgid "Maintain" msgstr "Maintain" @@ -454,7 +454,7 @@ msgstr "Permission Denied: Please contact organization user for help with creati #~ msgstr "Permission Denied: Please contact organization user for help with retrieving tags." #: src/domain/queries/find-domain-by-domain.js:51 -#: src/organization/objects/organization.js:201 +#: src/organization/objects/organization.js:211 msgid "Permission Denied: Please contact organization user for help with retrieving this domain." msgstr "Permission Denied: Please contact organization user for help with retrieving this domain." @@ -871,7 +871,7 @@ msgstr "Unable to dismiss message. Please try again." #~ msgid "Unable to dispatch one time scan. Please try again." #~ msgstr "Unable to dispatch one time scan. Please try again." -#: src/organization/objects/organization.js:271 +#: src/organization/objects/organization.js:281 msgid "Unable to export organization. Please try again." msgstr "Unable to export organization. Please try again." @@ -1018,8 +1018,8 @@ msgstr "Unable to invite yourself to an org." msgid "Unable to leave undefined organization." msgstr "Unable to leave undefined organization." -#: src/additional-findings/loaders/load-additional-findings-by-domain-id.js:24 -#: src/additional-findings/loaders/load-additional-findings-by-domain-id.js:34 +#: src/additional-findings/loaders/load-additional-findings-by-domain-id.js:149 +#: src/additional-findings/loaders/load-additional-findings-by-domain-id.js:159 msgid "Unable to load additional findings. Please try again." msgstr "Unable to load additional findings. Please try again." @@ -1041,8 +1041,8 @@ msgstr "Unable to load Aggregate guidance tag(s). Please try again." #~ msgid "Unable to load all organization domain statuses. Please try again." #~ msgstr "Unable to load all organization domain statuses. Please try again." -#: src/summaries/loaders/load-chart-summaries-by-period.js:56 -#: src/summaries/loaders/load-chart-summaries-by-period.js:66 +#: src/summaries/loaders/load-chart-summaries-by-period.js:58 +#: src/summaries/loaders/load-chart-summaries-by-period.js:68 msgid "Unable to load chart summary data. Please try again." msgstr "Unable to load chart summary data. Please try again." @@ -1167,7 +1167,6 @@ msgstr "Unable to load log(s). Please try again." #~ msgid "Unable to load mail summary. Please try again." #~ msgstr "Unable to load mail summary. Please try again." -#: src/additional-findings/loaders/load-top-25-reports.js:29 #: src/organization/loaders/load-all-organization-domain-statuses.js:164 #: src/organization/loaders/load-organization-domain-statuses.js:172 msgid "Unable to load organization domain statuses. Please try again." @@ -1178,8 +1177,8 @@ msgstr "Unable to load organization domain statuses. Please try again." msgid "Unable to load organization names. Please try again." msgstr "Unable to load organization names. Please try again." -#: src/organization/loaders/load-organization-summaries-by-period.js:50 -#: src/organization/loaders/load-organization-summaries-by-period.js:60 +#: src/organization/loaders/load-organization-summaries-by-period.js:52 +#: src/organization/loaders/load-organization-summaries-by-period.js:62 msgid "Unable to load organization summary data. Please try again." msgstr "Unable to load organization summary data. Please try again." @@ -1460,8 +1459,8 @@ msgstr "Unable to request invite. Please try again." msgid "Unable to reset password. Please try again." msgstr "Unable to reset password. Please try again." -#: src/domain/objects/domain.js:274 -#: src/domain/objects/domain.js:309 +#: src/domain/objects/domain.js:291 +#: src/domain/objects/domain.js:326 msgid "Unable to retrieve DMARC report information for: {domain}" msgstr "Unable to retrieve DMARC report information for: {domain}" @@ -1755,7 +1754,7 @@ msgstr "Verification error. Please activate multi-factor authentication to acces msgid "Verification error. Please verify your account via email to access content." msgstr "Verification error. Please verify your account via email to access content." -#: src/additional-findings/loaders/load-additional-findings-by-domain-id.js:8 +#: src/additional-findings/loaders/load-additional-findings-by-domain-id.js:9 msgid "You must provide a `domainId` to retrieve a domain's additional findings." msgstr "You must provide a `domainId` to retrieve a domain's additional findings." @@ -1845,6 +1844,10 @@ msgstr "You must provide a `first` or `last` value to properly paginate the `Ver msgid "You must provide a `first` or `last` value to properly paginate the `VerifiedOrganization` connection." msgstr "You must provide a `first` or `last` value to properly paginate the `VerifiedOrganization` connection." +#: src/additional-findings/loaders/load-additional-findings-by-domain-id.js:20 +msgid "You must provide a `limit` value in the range of 1-100 to properly paginate additional findings." +msgstr "You must provide a `limit` value in the range of 1-100 to properly paginate additional findings." + #: src/dns-scan/loaders/load-dns-connections-by-domain-id.js:16 msgid "You must provide a `limit` value in the range of 1-100 to properly paginate the `DNS` connection." msgstr "You must provide a `limit` value in the range of 1-100 to properly paginate the `DNS` connection." @@ -1857,6 +1860,10 @@ msgstr "You must provide a `limit` value in the range of 1-100 to properly pagin msgid "You must provide a `limit` value in the range of 1-100 to properly paginate the `web` connection." msgstr "You must provide a `limit` value in the range of 1-100 to properly paginate the `web` connection." +#: src/additional-findings/loaders/load-additional-findings-by-domain-id.js:14 +msgid "You must provide a `limit` value to properly paginate additional findings." +msgstr "You must provide a `limit` value to properly paginate additional findings." + #: src/dns-scan/loaders/load-dns-connections-by-domain-id.js:9 msgid "You must provide a `limit` value to properly paginate the `DNS` connection." msgstr "You must provide a `limit` value to properly paginate the `DNS` connection." diff --git a/api/src/locale/fr/messages.po b/api/src/locale/fr/messages.po index 95af02174b..b4a7f61105 100644 --- a/api/src/locale/fr/messages.po +++ b/api/src/locale/fr/messages.po @@ -123,7 +123,7 @@ msgstr "`{argSet}` sur la connexion `VerifiedDomain` ne peut être inférieur à msgid "`{argSet}` on the `VerifiedOrganization` connection cannot be less than zero." msgstr "`{argSet}` sur la connexion `VerifiedOrganization` ne peut être inférieur à zéro." -#: src/organization/objects/organization.js:246 +#: src/organization/objects/organization.js:256 #: src/organization/queries/get-all-organization-domain-statuses.js:69 msgid "Assess" msgstr "Évaluez" @@ -138,11 +138,11 @@ msgstr "Évaluez" msgid "Authentication error. Please sign in." msgstr "Erreur d'authentification. Veuillez vous connecter." -#: src/domain/objects/domain.js:229 +#: src/domain/objects/domain.js:245 msgid "Cannot query additional findings without permission." msgstr "Il n'est pas possible de demander des résultats supplémentaires sans autorisation." -#: src/organization/objects/organization.js:364 +#: src/organization/objects/organization.js:374 msgid "Cannot query affiliations on organization without admin permission or higher." msgstr "Impossible d'interroger les affiliations sur l'organisation sans l'autorisation de l'administrateur ou plus." @@ -151,15 +151,15 @@ msgstr "Impossible d'interroger les affiliations sur l'organisation sans l'autor msgid "Cannot query audit logs on organization without admin permission or higher." msgstr "Impossible d'interroger les journaux d'audit sur l'organisation sans l'autorisation d'administrateur ou plus." -#: src/domain/objects/domain.js:164 +#: src/domain/objects/domain.js:165 msgid "Cannot query dns scan results without permission." msgstr "Impossible d'interroger les résultats de l'analyse DNS sans autorisation." -#: src/domain/objects/domain.js:65 +#: src/domain/objects/domain.js:66 msgid "Cannot query domain selectors without permission." msgstr "Impossible d'interroger les sélecteurs de domaine sans autorisation." -#: src/domain/objects/domain.js:206 +#: src/domain/objects/domain.js:207 msgid "Cannot query web scan results without permission." msgstr "Impossible d'interroger les résultats de l'analyse web sans autorisation." @@ -171,7 +171,7 @@ msgstr "CVE est déjà ignoré pour ce domaine." msgid "CVE is not ignored for this domain." msgstr "Le CVE n'est pas ignoré dans ce domaine." -#: src/organization/objects/organization.js:248 +#: src/organization/objects/organization.js:258 #: src/organization/queries/get-all-organization-domain-statuses.js:71 msgid "Deploy" msgstr "msgstr Déployer" @@ -180,7 +180,7 @@ msgstr "msgstr Déployer" msgid "Email already in use." msgstr "Courriel déjà utilisé." -#: src/organization/objects/organization.js:250 +#: src/organization/objects/organization.js:260 #: src/organization/queries/get-all-organization-domain-statuses.js:73 msgid "Enforce" msgstr "Appliquer" @@ -217,7 +217,7 @@ msgstr "Le nom d'utilisateur ou le mot de passe est incorrect. Veuillez réessay msgid "Invalid token, please sign in." msgstr "Jeton invalide, veuillez vous connecter." -#: src/organization/objects/organization.js:252 +#: src/organization/objects/organization.js:262 #: src/organization/queries/get-all-organization-domain-statuses.js:75 msgid "Maintain" msgstr "Maintenir" @@ -454,7 +454,7 @@ msgstr "Permission refusée : Veuillez contacter l'utilisateur de l'organisation #~ msgstr "Permission refusée : Veuillez contacter l'utilisateur de l'organisation pour obtenir de l'aide afin de récupérer les étiquettes." #: src/domain/queries/find-domain-by-domain.js:51 -#: src/organization/objects/organization.js:201 +#: src/organization/objects/organization.js:211 msgid "Permission Denied: Please contact organization user for help with retrieving this domain." msgstr "Permission refusée : Veuillez contacter l'utilisateur de l'organisation pour obtenir de l'aide pour récupérer ce domaine." @@ -871,7 +871,7 @@ msgstr "Impossible de rejeter le message. Veuillez réessayer." #~ msgid "Unable to dispatch one time scan. Please try again." #~ msgstr "Impossible d'envoyer un scan unique. Veuillez réessayer." -#: src/organization/objects/organization.js:271 +#: src/organization/objects/organization.js:281 msgid "Unable to export organization. Please try again." msgstr "Impossible d'exporter l'organisation. Veuillez réessayer." @@ -1018,8 +1018,8 @@ msgstr "Impossible de s'inviter à un org." msgid "Unable to leave undefined organization." msgstr "Impossible de quitter une organisation non définie." -#: src/additional-findings/loaders/load-additional-findings-by-domain-id.js:24 -#: src/additional-findings/loaders/load-additional-findings-by-domain-id.js:34 +#: src/additional-findings/loaders/load-additional-findings-by-domain-id.js:149 +#: src/additional-findings/loaders/load-additional-findings-by-domain-id.js:159 msgid "Unable to load additional findings. Please try again." msgstr "Impossible de charger des résultats supplémentaires. Veuillez réessayer." @@ -1041,8 +1041,8 @@ msgstr "Impossible de charger le(s) tag(s) d'orientation des agrégats. Veuillez #~ msgid "Unable to load all organization domain statuses. Please try again." #~ msgstr "Impossible de charger tous les statuts de domaine d'organisation. Veuillez réessayer." -#: src/summaries/loaders/load-chart-summaries-by-period.js:56 -#: src/summaries/loaders/load-chart-summaries-by-period.js:66 +#: src/summaries/loaders/load-chart-summaries-by-period.js:58 +#: src/summaries/loaders/load-chart-summaries-by-period.js:68 msgid "Unable to load chart summary data. Please try again." msgstr "Impossible de charger les données du résumé du graphique. Veuillez réessayer." @@ -1167,7 +1167,6 @@ msgstr "Impossible de charger le(s) journal(s). Veuillez réessayer." #~ msgid "Unable to load mail summary. Please try again." #~ msgstr "Impossible de charger le résumé du courrier. Veuillez réessayer." -#: src/additional-findings/loaders/load-top-25-reports.js:29 #: src/organization/loaders/load-all-organization-domain-statuses.js:164 #: src/organization/loaders/load-organization-domain-statuses.js:172 msgid "Unable to load organization domain statuses. Please try again." @@ -1178,8 +1177,8 @@ msgstr "Impossible de charger les statuts des domaines d'organisation. Veuillez msgid "Unable to load organization names. Please try again." msgstr "Impossible de charger les noms des organisations. Veuillez réessayer." -#: src/organization/loaders/load-organization-summaries-by-period.js:50 -#: src/organization/loaders/load-organization-summaries-by-period.js:60 +#: src/organization/loaders/load-organization-summaries-by-period.js:52 +#: src/organization/loaders/load-organization-summaries-by-period.js:62 msgid "Unable to load organization summary data. Please try again." msgstr "Impossible de charger les données de synthèse de l'organisation. Veuillez réessayer." @@ -1460,8 +1459,8 @@ msgstr "Impossible de demander une invitation. Veuillez réessayer." msgid "Unable to reset password. Please try again." msgstr "Impossible de réinitialiser le mot de passe. Veuillez réessayer." -#: src/domain/objects/domain.js:274 -#: src/domain/objects/domain.js:309 +#: src/domain/objects/domain.js:291 +#: src/domain/objects/domain.js:326 msgid "Unable to retrieve DMARC report information for: {domain}" msgstr "Impossible de récupérer les informations du rapport DMARC pour : {domain}" @@ -1749,7 +1748,7 @@ msgstr "Erreur de vérification. Veuillez activer l'authentification multifactor msgid "Verification error. Please verify your account via email to access content." msgstr "Erreur de vérification. Veuillez vérifier votre compte par e-mail pour accéder au contenu." -#: src/additional-findings/loaders/load-additional-findings-by-domain-id.js:8 +#: src/additional-findings/loaders/load-additional-findings-by-domain-id.js:9 msgid "You must provide a `domainId` to retrieve a domain's additional findings." msgstr "Vous devez fournir un `domainId` pour récupérer les résultats supplémentaires d'un domaine." @@ -1839,6 +1838,10 @@ msgstr "Vous devez fournir une valeur `first` ou `last` pour paginer correctemen msgid "You must provide a `first` or `last` value to properly paginate the `VerifiedOrganization` connection." msgstr "Vous devez fournir une valeur `first` ou `last` pour paginer correctement la connexion `VerifiedOrganization`." +#: src/additional-findings/loaders/load-additional-findings-by-domain-id.js:20 +msgid "You must provide a `limit` value in the range of 1-100 to properly paginate additional findings." +msgstr "Vous devez indiquer une valeur « limit » comprise entre 1 et 100 pour paginer correctement les résultats supplémentaires." + #: src/dns-scan/loaders/load-dns-connections-by-domain-id.js:16 msgid "You must provide a `limit` value in the range of 1-100 to properly paginate the `DNS` connection." msgstr "Vous devez fournir une valeur `limit` comprise entre 1 et 100 pour paginer correctement la connexion `DNS`." @@ -1851,6 +1854,10 @@ msgstr "Vous devez fournir une valeur `limit` comprise entre 1 et 100 pour pagin msgid "You must provide a `limit` value in the range of 1-100 to properly paginate the `web` connection." msgstr "Vous devez fournir une valeur `limit` comprise entre 1 et 100 pour paginer correctement la connexion `web`." +#: src/additional-findings/loaders/load-additional-findings-by-domain-id.js:14 +msgid "You must provide a `limit` value to properly paginate additional findings." +msgstr "Vous devez indiquer une valeur pour le paramètre `limit` afin de paginer correctement les résultats supplémentaires." + #: src/dns-scan/loaders/load-dns-connections-by-domain-id.js:9 msgid "You must provide a `limit` value to properly paginate the `DNS` connection." msgstr "Vous devez fournir une valeur `limit` pour paginer correctement la connexion `DNS`." diff --git a/api/src/query.js b/api/src/query.js index 03430825eb..cd29facbcf 100644 --- a/api/src/query.js +++ b/api/src/query.js @@ -9,7 +9,6 @@ import * as userQueries from './user/queries' import * as verifiedDomainQueries from './verified-domains/queries' import * as verifiedOrgQueries from './verified-organizations/queries' import * as auditLogQueries from './audit-logs/queries' -import * as additionalFindingsQueries from './additional-findings/queries' import * as tagsQueries from './tags/queries' export const createQuerySchema = () => { @@ -34,7 +33,6 @@ export const createQuerySchema = () => { ...verifiedDomainQueries, // Verified Organization Queries ...verifiedOrgQueries, - ...additionalFindingsQueries, ...tagsQueries, }), })