diff --git a/backend/src/api/app.ts b/backend/src/api/app.ts index 025f8519..5f6c2e51 100644 --- a/backend/src/api/app.ts +++ b/backend/src/api/app.ts @@ -24,6 +24,7 @@ import * as savedSearches from './saved-searches'; import rateLimit from 'express-rate-limit'; import { createProxyMiddleware } from 'http-proxy-middleware'; import { Organization, User, UserType, connectToDatabase } from '../models'; +import * as assessments from './assessments'; import * as jwt from 'jsonwebtoken'; import { Request, Response, NextFunction } from 'express'; import fetch from 'node-fetch'; @@ -123,7 +124,8 @@ app.use( cors({ origin: [ 'http://localhost', - /^https:\/\/(.*\.)?crossfeed\.cyber\.dhs\.gov$/ + /^https:\/\/(.*\.)?crossfeed\.cyber\.dhs\.gov$/, + /^https:\/\/(.*\.)?readysetcyber\.cyber\.dhs\.gov$/ ], methods: 'GET,POST,PUT,DELETE,OPTIONS' }) @@ -139,7 +141,7 @@ app.use( `${process.env.COGNITO_URL}`, `${process.env.BACKEND_DOMAIN}` ], - frameSrc: ["'self'"], + frameSrc: ["'self'", 'https://www.dhs.gov/ntas/'], imgSrc: [ "'self'", 'data:', @@ -338,6 +340,7 @@ app.get('/', handlerToExpress(healthcheck)); app.post('/auth/login', handlerToExpress(auth.login)); app.post('/auth/callback', handlerToExpress(auth.callback)); app.post('/users/register', handlerToExpress(users.register)); +app.post('/readysetcyber/register', handlerToExpress(users.RSCRegister)); app.get('/notifications', handlerToExpress(notifications.list)); app.get( @@ -830,6 +833,10 @@ authenticatedRoute.put( '/notifications/:notificationId', handlerToExpress(notifications.update) ); +//Authenticated ReadySetCyber Routes +authenticatedRoute.get('/assessments', handlerToExpress(assessments.list)); + +authenticatedRoute.get('/assessments/:id', handlerToExpress(assessments.get)); //************* */ // V2 Routes // diff --git a/backend/src/api/assessments.ts b/backend/src/api/assessments.ts new file mode 100644 index 00000000..6ce4a6da --- /dev/null +++ b/backend/src/api/assessments.ts @@ -0,0 +1,118 @@ +import { validateBody, wrapHandler, NotFound, Unauthorized } from './helpers'; +import { Assessment, connectToDatabase } from '../models'; +import { isUUID } from 'class-validator'; + +/** + * @swagger + * + * /assessments: + * post: + * description: Save an RSC assessment to the XFD database. + * tags: + * - Assessments + */ +export const createAssessment = wrapHandler(async (event) => { + const body = await validateBody(Assessment, event.body); + + await connectToDatabase(); + + const assessment = Assessment.create(body); + await Assessment.save(assessment); + + return { + statusCode: 200, + body: JSON.stringify(assessment) + }; +}); + +/** + * @swagger + * + * /assessments: + * get: + * description: Lists all assessments for the logged-in user. + * tags: + * - Assessments + */ +export const list = wrapHandler(async (event) => { + const userId = event.requestContext.authorizer!.id; + + if (!userId) { + return Unauthorized; + } + + await connectToDatabase(); + + const assessments = await Assessment.find({ + where: { user: userId } + }); + + return { + statusCode: 200, + body: JSON.stringify(assessments) + }; +}); + +/** + * @swagger + * + * /assessments/{id}: + * get: + * description: Return user responses and questions organized by category for a specific assessment. + * parameters: + * - in: path + * name: id + * description: Assessment id + * tags: + * - Assessments + */ +export const get = wrapHandler(async (event) => { + const assessmentId = event.pathParameters?.id; + + if (!assessmentId || !isUUID(assessmentId)) { + return NotFound; + } + + await connectToDatabase(); + + const assessment = await Assessment.findOne(assessmentId, { + relations: [ + 'responses', + 'responses.question', + 'responses.question.category', + 'responses.question.resources' + ] + }); + + if (!assessment) { + return NotFound; + } + + // Sort responses by question.number and then by category.number + assessment.responses.sort((a, b) => { + const questionNumberComparison = a.question.number.localeCompare( + b.question.number + ); + if (questionNumberComparison !== 0) { + return questionNumberComparison; + } else { + return a.question.category.number.localeCompare( + b.question.category.number + ); + } + }); + + const responsesByCategory = assessment.responses.reduce((acc, response) => { + const categoryName = response.question.category.name; + if (!acc[categoryName]) { + acc[categoryName] = []; + } + acc[categoryName].push(response); + return acc; + }, {}); + + return { + statusCode: 200, + body: JSON.stringify(responsesByCategory) + }; +}); diff --git a/backend/src/api/scans.ts b/backend/src/api/scans.ts index dd9250ab..730c7969 100644 --- a/backend/src/api/scans.ts +++ b/backend/src/api/scans.ts @@ -165,6 +165,13 @@ export const SCAN_SCHEMA: ScanSchema = { description: 'Creates domains from root domains by doing a single DNS lookup for each root domain.' }, + rscSync: { + type: 'fargate', + isPassive: true, + global: true, + description: + 'Retrieves and saves assessments from ReadySetCyber mission instance.' + }, savedSearch: { type: 'fargate', isPassive: true, diff --git a/backend/src/api/users.ts b/backend/src/api/users.ts index 20a52c2f..5e48598a 100644 --- a/backend/src/api/users.ts +++ b/backend/src/api/users.ts @@ -36,6 +36,7 @@ import { isGlobalWriteAdmin, matchesUserRegion } from './auth'; +import { fetchAssessmentsByUser } from '../tasks/rscSync'; class UserSearch { @IsInt() @@ -269,6 +270,30 @@ If you encounter any difficulties, please feel free to reply to this email (or s ); }; +const sendRSCInviteEmail = async (email: string) => { + const staging = process.env.NODE_ENV !== 'production'; + + await sendEmail( + email, + 'ReadySetCyber Dashboard Invitation', + `Hi there, + +You've been invited to join ReadySetCyber Dashboard. To accept the invitation and start using your Dashboard, sign on at ${process.env.FRONTEND_DOMAIN}/readysetcyber/create-account. + +CyHy Dashboard access instructions: + +1. Visit ${process.env.FRONTEND_DOMAIN}/readysetcyber/create-account. +2. Select "Create Account." +3. Enter your email address and a new password for CyHy Dashboard. +4. A confirmation code will be sent to your email. Enter this code when you receive it. +5. You will be prompted to enable MFA. Scan the QR code with an authenticator app on your phone, such as Microsoft Authenticator. Enter the MFA code you see after scanning. +6. After configuring your account, you will be redirected to CyHy Dashboard. + +For more information on using CyHy Dashboard, view the CyHy Dashboard user guide at https://docs.crossfeed.cyber.dhs.gov/user-guide/quickstart/. + +If you encounter any difficulties, please feel free to reply to this email (or send an email to ${process.env.CROSSFEED_SUPPORT_EMAIL_REPLYTO}).` + ); +}; /** * @swagger * @@ -919,3 +944,54 @@ export const updateV2 = wrapHandler(async (event) => { } return NotFound; }); + +/** + * @swagger + * + * /readysetcyber/register: + * post: + * description: New ReadySetCyber user registration. + * tags: + * - RSCUsers + */ +export const RSCRegister = wrapHandler(async (event) => { + const body = await validateBody(NewUser, event.body); + const newRSCUser = { + firstName: body.firstName, + lastName: body.lastName, + email: body.email.toLowerCase(), + userType: UserType.READY_SET_CYBER + }; + + await connectToDatabase(); + + // Check if user already exists + let user = await User.findOne({ + email: newRSCUser.email + }); + if (user) { + console.log('User already exists.'); + return { + statusCode: 422, + body: 'User email already exists. Registration failed.' + }; + // Create if user does not exist + } else { + user = User.create(newRSCUser); + await User.save(user); + // Fetch RSC assessments for user + await fetchAssessmentsByUser(user.email); + // Send email notification + if (process.env.IS_LOCAL!) { + console.log('Cannot send invite email while running on local.'); + } else { + await sendRSCInviteEmail(user.email); + } + } + + const savedUser = await User.findOne(user.id); + return { + statusCode: 200, + body: JSON.stringify(savedUser) + }; +}); diff --git a/backend/src/models/assessment.ts b/backend/src/models/assessment.ts new file mode 100644 index 00000000..5e5eab15 --- /dev/null +++ b/backend/src/models/assessment.ts @@ -0,0 +1,34 @@ +import { + BaseEntity, + Column, + Entity, + ManyToOne, + OneToMany, + PrimaryGeneratedColumn +} from 'typeorm'; +import { Response } from './response'; +import { User } from './user'; + +@Entity() +export class Assessment extends BaseEntity { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + createdAt: Date; + + @Column() + updatedAt: Date; + + @Column({ unique: true }) + rscId: string; + + @Column() + type: string; + + @ManyToOne(() => User, (user) => user.assessments) + user: User; + + @OneToMany(() => Response, (response) => response.assessment) + responses: Response[]; +} diff --git a/backend/src/models/category.ts b/backend/src/models/category.ts new file mode 100644 index 00000000..bb04d414 --- /dev/null +++ b/backend/src/models/category.ts @@ -0,0 +1,26 @@ +import { + BaseEntity, + Column, + Entity, + OneToMany, + PrimaryGeneratedColumn +} from 'typeorm'; +import { Question } from './question'; + +@Entity() +export class Category extends BaseEntity { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + name: string; + + @Column({ unique: true }) + number: string; + + @Column({ nullable: true }) + shortName: string; + + @OneToMany(() => Question, (question) => question.category) + questions: Question[]; +} diff --git a/backend/src/models/connection.ts b/backend/src/models/connection.ts index bd97e3db..f9020749 100644 --- a/backend/src/models/connection.ts +++ b/backend/src/models/connection.ts @@ -3,11 +3,16 @@ import { // Models for the Crossfeed database ApiKey, Notification, + Assessment, + Category, Cpe, Cve, Domain, Organization, OrganizationTag, + Question, + Resource, + Response, Role, SavedSearch, Scan, @@ -179,11 +184,16 @@ const connectDb = async (logging?: boolean) => { database: process.env.DB_NAME, entities: [ ApiKey, + Assessment, + Category, Cpe, Cve, Domain, Organization, OrganizationTag, + Question, + Resource, + Response, Role, SavedSearch, OrganizationTag, diff --git a/backend/src/models/index.ts b/backend/src/models/index.ts index 64ab8c40..c010b606 100644 --- a/backend/src/models/index.ts +++ b/backend/src/models/index.ts @@ -1,4 +1,6 @@ export * from './api-key'; +export * from './assessment'; +export * from './category'; export * from './connection'; export * from './cpe'; export * from './cve'; @@ -7,6 +9,9 @@ export * from './organization'; export * from './organization-tag'; export * from './material-views'; export * from './notification'; +export * from './question'; +export * from './resource'; +export * from './response'; export * from './role'; export * from './saved-search'; export * from './scan'; diff --git a/backend/src/models/question.ts b/backend/src/models/question.ts new file mode 100644 index 00000000..750baf6c --- /dev/null +++ b/backend/src/models/question.ts @@ -0,0 +1,43 @@ +import { + BaseEntity, + Column, + Entity, + Index, + JoinTable, + ManyToMany, + ManyToOne, + OneToMany, + PrimaryGeneratedColumn +} from 'typeorm'; +import { Category } from './category'; +import { Resource } from './resource'; +import { Response } from './response'; + +@Entity() +@Index(['category', 'number'], { unique: true }) +export class Question extends BaseEntity { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + name: string; + + @Column({ nullable: true }) + description: string; + + @Column() + longForm: string; + + @Column() + number: string; + + @ManyToMany(() => Resource, (resource) => resource.questions) + @JoinTable() + resources: Resource[]; + + @ManyToOne(() => Category, (category) => category.questions) + category: Category; + + @OneToMany(() => Response, (response) => response.question) + responses: Response[]; +} diff --git a/backend/src/models/resource.ts b/backend/src/models/resource.ts new file mode 100644 index 00000000..719328d6 --- /dev/null +++ b/backend/src/models/resource.ts @@ -0,0 +1,30 @@ +import { + BaseEntity, + Column, + Entity, + ManyToMany, + ManyToOne, + PrimaryGeneratedColumn +} from 'typeorm'; +import { Question } from './question'; + +@Entity() +export class Resource extends BaseEntity { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + description: string; + + @Column() + name: string; + + @Column() + type: string; + + @Column({ unique: true }) + url: string; + + @ManyToMany(() => Question, (question) => question.resources) + questions: Question[]; +} diff --git a/backend/src/models/response.ts b/backend/src/models/response.ts new file mode 100644 index 00000000..0b6be6d0 --- /dev/null +++ b/backend/src/models/response.ts @@ -0,0 +1,26 @@ +import { + BaseEntity, + Column, + Entity, + Index, + ManyToOne, + PrimaryGeneratedColumn +} from 'typeorm'; +import { Assessment } from './assessment'; +import { Question } from './question'; + +@Entity() +@Index(['assessment', 'question'], { unique: true }) +export class Response extends BaseEntity { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + selection: string; + + @ManyToOne(() => Assessment, (assessment) => assessment.responses) + assessment: Assessment; + + @ManyToOne(() => Question, (question) => question.responses) + question: Question; +} diff --git a/backend/src/models/user.ts b/backend/src/models/user.ts index 79ecf78f..36ac721d 100644 --- a/backend/src/models/user.ts +++ b/backend/src/models/user.ts @@ -11,12 +11,14 @@ import { UpdateDateColumn } from 'typeorm'; import { ApiKey } from './api-key'; +import { Assessment } from './assessment'; import { Role } from './'; export enum UserType { GLOBAL_ADMIN = 'globalAdmin', GLOBAL_VIEW = 'globalView', REGIONAL_ADMIN = 'regionalAdmin', + READY_SET_CYBER = 'readySetCyber', STANDARD = 'standard' } @Entity() @@ -125,4 +127,7 @@ export class User extends BaseEntity { nullable: true }) state: string; + + @OneToMany(() => Assessment, (assessment) => assessment.user) + assessments: Assessment[]; } diff --git a/backend/src/tasks/rscSync.ts b/backend/src/tasks/rscSync.ts new file mode 100644 index 00000000..a47f0f7c --- /dev/null +++ b/backend/src/tasks/rscSync.ts @@ -0,0 +1,258 @@ +import axios from 'axios'; +import { Buffer } from 'buffer'; +import { plainToClass } from 'class-transformer'; +import { + Assessment, + connectToDatabase, + Question, + Response, + User +} from '../models'; +import { getRepository } from 'typeorm'; +import * as console from 'console'; + +interface AssessmentEntry { + metadata: Metadata; + questions: Questions; +} +interface Metadata { + sys_created_on: Date; + sys_id: string; + sys_updated_on: Date; + u_contact_email: string; +} +interface Questions { + [key: string]: string | null; +} + +// This should be regularly compared with mission instance's API response to ensure it is current +function castApiResponseToAssessmentInterface(data: any[]): AssessmentEntry[] { + return data.map((el) => ({ + metadata: { + sys_created_on: new Date(el.sys_created_on), + sys_id: el.sys_id, + sys_updated_on: new Date(el.sys_updated_on), + u_contact_email: el.u_contact_email + }, + questions: { + u_3rd_party_cyber_validation: el.u_3rd_party_cyber_validation || null, + u_annual_cyber_training: el.u_annual_cyber_training || null, + u_asset_inventory: el.u_asset_inventory || null, + u_asset_recovery_plan: el.u_asset_recovery_plan || null, + u_backup_access: el.u_backup_access || null, + u_backup_auto: el.u_backup_auto || null, + u_backup_data: el.u_backup_data || null, + u_backup_method: el.u_backup_method || null, + u_basline_config_documents: el.u_basline_config_documents || null, + u_cia_data: el.u_cia_data || null, + u_change_default_pw: el.u_change_default_pw || null, + u_connect_deny_default: el.u_connect_deny_default || null, + u_cyber_incident_plan: el.u_cyber_incident_plan || null, + u_departing_employee_return: el.u_departing_employee_return || null, + u_device_config: el.u_device_config || null, + u_device_type: el.u_device_type || null, + u_disable_svcs: el.u_disable_svcs || null, + u_email_tls_dkim: el.u_email_tls_dkim || null, + u_fw_av: el.u_fw_av || null, + u_governance_training: el.u_governance_training || null, + u_hw_software_firmware_approval: + el.u_hw_software_firmware_approval || null, + u_iam_security: el.u_iam_security || null, + u_inc_reporting_policy: el.u_inc_reporting_policy || null, + u_inc_response_plan: el.u_inc_response_plan || null, + u_it_org_size: el.u_it_org_size || null, + u_kev_mitagation: el.u_kev_mitagation || null, + u_log_storage: el.u_log_storage || null, + u_log_storage_detect_resp: el.u_log_storage_detect_resp || null, + u_log_unsuccessful_login: el.u_log_unsuccessful_login || null, + u_macros_disabled: el.u_macros_disabled || null, + u_mfa_enabled: el.u_mfa_enabled || null, + u_named_cyber_role: el.u_named_cyber_role || null, + u_network_diagrams: el.u_network_diagrams || null, + u_no_public_devices: el.u_no_public_devices || null, + u_ot_cyber_training: el.u_ot_cyber_training || null, + u_prohibit_unauth_devices: el.u_prohibit_unauth_devices || null, + u_public_services_disabled: el.u_public_services_disabled || null, + u_pw_different: el.u_pw_different || null, + u_pw_length: el.u_pw_length || null, + u_pw_strong: el.u_pw_strong || null, + u_regular_backups: el.u_regular_backups || null, + u_reputable_software: el.u_reputable_software || null, + u_secure_credential_storage: el.u_secure_credential_storage || null, + u_security_features: el.u_security_features || null, + u_security_inc_sla: el.u_security_inc_sla || null, + u_security_research_contact: el.u_security_research_contact || null, + u_security_vuln_sla: el.u_security_vuln_sla || null, + u_separate_admin: el.u_separate_admin || null, + u_software_updates: el.u_software_updates || null, + u_strong_mfa: el.u_strong_mfa || null, + u_supply_chain_risk: el.u_supply_chain_risk || null, + u_tls_enabled: el.u_tls_enabled || null, + u_ttp_list: el.u_ttp_list || null, + u_unique_svc_accounts: el.u_unique_svc_accounts || null, + u_vendor_eval_docs: el.u_vendor_eval_docs || null, + u_vul_management: el.u_vul_management || null + } + })); +} + +export const handler = async () => { + await fetchRecentAssessments(); +}; + +const authorizationHeader = Buffer.from( + `${process.env.MI_ACCOUNT_NAME}:${process.env.MI_PASSWORD}`, + 'utf8' +).toString('base64'); + +// Creates a temporary dictionary to reduce calls to the database +const generateQuestionDictionary = async (): Promise<{ + [key: string]: string; +}> => { + await connectToDatabase(); + const questionRepository = getRepository(Question); + const questions = await questionRepository.find({ select: ['id', 'name'] }); + return questions.reduce((acc, question) => { + acc[question.name] = question.id; + return acc; + }, {}); +}; +let questionDictionary: { [key: string]: string } = {}; + +const fetchRecentAssessments = async () => { + console.log('Fetching assessments'); + + // Create a date object and subtract 48 hours + const date = new Date(); + date.setHours(date.getHours() - 48); + const formattedDate = date.toISOString().replace('T', ' ').split('.')[0]; + + try { + const response = await axios({ + url: `https://cisadev.servicenowservices.com/api/now/table/x_g_dhs_rsc_rsc_data?sysparm_query=sys_updated_on>=${formattedDate}`, + method: 'GET', + headers: { + Authorization: `Basic ${authorizationHeader}` + } + }); + console.log('response: ', response); + await saveAssessmentsToDb(response.data.result); + } catch (error) { + console.error('Error fetching assessments', error); + } +}; + +// TODO: Tie this to RSC user registration +export const fetchAssessmentsByUser = async (email: string) => { + console.log('Fetching assessments for user'); + try { + const response = await axios({ + url: `https://cisadev.servicenowservices.com/api/now/table/x_g_dhs_rsc_rsc_data?sysparm_query=u_contact_email=${email}`, + method: 'GET', + headers: { + Authorization: `Basic ${authorizationHeader}` + } + }); + await saveAssessmentsToDb(response.data.result); + } catch (error) { + console.error('Error fetching assessments for user', error); + } +}; + +const getUserIdByEmail = async (email: string): Promise => { + const user = await User.findOne({ where: { email } }); + return user ? user.id : null; +}; + +const saveAssessmentsToDb = async (assessments: any[]) => { + console.log('Saving assessments to database'); + await connectToDatabase(); + const assessmentRepository = getRepository(Assessment); + questionDictionary = await generateQuestionDictionary(); + + assessments = castApiResponseToAssessmentInterface( + assessments + ) as AssessmentEntry[]; + + for (const assessment of assessments) { + console.log('assessment: ', assessment); + + const user = await getUserIdByEmail(assessment.metadata.u_contact_email); + if (!user) { + console.error( + `Assessment not saved: ${assessment.metadata.u_contact_email} is not registered` + ); + continue; + } + + const assessmentToSave = plainToClass(Assessment, { + createdAt: new Date(assessment.metadata.sys_created_on), + updatedAt: new Date(assessment.metadata.sys_updated_on), + rscId: assessment.metadata.sys_id, + type: assessment.questions.u_it_org_size, + user: user + }); + + let savedAssessment: Assessment; + const existingAssessment = await assessmentRepository.findOne({ + where: { rscId: assessmentToSave.rscId } + }); + + if (existingAssessment) { + if ( + existingAssessment.updatedAt.getTime() !== + new Date(assessmentToSave.updatedAt).getTime() + ) { + savedAssessment = await assessmentRepository.save({ + ...existingAssessment, + ...assessmentToSave + }); + } else { + savedAssessment = existingAssessment; + // Check if updatedAt dates are the same + if ( + existingAssessment.updatedAt.getTime() === + new Date(assessmentToSave.updatedAt).getTime() + ) { + console.log( + 'Existing assessment with same updatedAt date, skipping saveResponsesToDb' + ); + continue; // Skip saveResponsesToDb if updatedAt dates are the same + } + } + } else { + savedAssessment = await assessmentRepository.save(assessmentToSave); + } + + await saveResponsesToDb(assessment, savedAssessment.id); + } +}; + +const saveResponsesToDb = async ( + assessment: AssessmentEntry, + assessmentId: string +) => { + console.log('assessmentId: ', assessmentId); + const responseRepository = getRepository(Response); + await responseRepository + .createQueryBuilder() + .delete() + .from(Response) + .where('assessmentId = :assessmentId', { assessmentId }) + .execute(); + const responseArray: Response[] = []; + for (const [question, response] of Object.entries(assessment.questions)) { + const questionId = questionDictionary[question]; + if (response) { + responseArray.push( + plainToClass(Response, { + selection: response, + assessment: assessmentId, + question: questionId + }) + ); + } + } + console.log('responseArray: ', responseArray); + await Response.save(responseArray); +}; diff --git a/backend/src/worker.ts b/backend/src/worker.ts index beaa569d..3651fefe 100644 --- a/backend/src/worker.ts +++ b/backend/src/worker.ts @@ -15,6 +15,7 @@ import { handler as intrigueIdent } from './tasks/intrigue-ident'; import { handler as lookingGlass } from './tasks/lookingGlass'; import { handler as portscanner } from './tasks/portscanner'; import { handler as rootDomainSync } from './tasks/rootDomainSync'; +import { handler as rscSync } from './tasks/rscSync'; import { handler as savedSearch } from './tasks/saved-search'; import { handler as searchSync } from './tasks/search-sync-domains'; import { handler as shodan } from './tasks/shodan'; @@ -55,6 +56,7 @@ async function main() { trustymail, vulnScanningSync, vulnSync, + rscSync, savedSearch, searchSync, shodan, diff --git a/dev.env.example b/dev.env.example index aab8b818..46d250bd 100644 --- a/dev.env.example +++ b/dev.env.example @@ -14,6 +14,9 @@ MDL_USERNAME=mdl MDL_PASSWORD=password MDL_NAME=crossfeed_mini_datalake +MI_ACCOUNT_NAME= +MI_PASSWORD= + REACT_APP_API_URL=http://localhost:3000 REACT_APP_FARGATE_LOG_GROUP=crossfeed-staging-worker