From 732f262f4b8a6bfc0856bf2a2a0dcc5c255841f3 Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Mon, 25 May 2026 09:41:04 -0400 Subject: [PATCH 1/4] fix(api): include a default justification on SoA --- apps/api/src/soa/soa.service.ts | 12 ++- apps/api/src/soa/utils/constants.spec.ts | 74 +++++++++++++++++ apps/api/src/soa/utils/constants.ts | 89 +++++++++++++++++++++ apps/api/src/soa/utils/soa-answer-parser.ts | 30 +++++-- apps/api/src/soa/utils/soa-storage.ts | 6 +- 5 files changed, 197 insertions(+), 14 deletions(-) create mode 100644 apps/api/src/soa/utils/constants.spec.ts diff --git a/apps/api/src/soa/soa.service.ts b/apps/api/src/soa/soa.service.ts index ef43aea18b..aec8b39be4 100644 --- a/apps/api/src/soa/soa.service.ts +++ b/apps/api/src/soa/soa.service.ts @@ -557,12 +557,18 @@ export class SOAService { // Generate answer from pre-fetched content const soaResult = await generateSOAControlAnswer(question, similarContent); - // If no answer, default to YES + // If no answer, default to YES with a family-appropriate justification if (!soaResult.answer) { - return createDefaultYesResult(question.id, index, send); + return createDefaultYesResult(question.id, index, send, controlClosure); } - return parseAndProcessSOAAnswer(question.id, index, soaResult.answer, send); + return parseAndProcessSOAAnswer( + question.id, + index, + soaResult.answer, + send, + controlClosure, + ); } async saveAnswersToDatabase( diff --git a/apps/api/src/soa/utils/constants.spec.ts b/apps/api/src/soa/utils/constants.spec.ts new file mode 100644 index 0000000000..287509cbde --- /dev/null +++ b/apps/api/src/soa/utils/constants.spec.ts @@ -0,0 +1,74 @@ +import { + INCLUSION_JUSTIFICATIONS, + getInclusionJustification, +} from './constants'; + +describe('getInclusionJustification', () => { + it('returns the access-control justification for organisational and technical access controls', () => { + const accessClosures = ['5.15', '5.16', '5.17', '5.18', '8.2', '8.3', '8.4', '8.5']; + for (const closure of accessClosures) { + expect(getInclusionJustification(closure)).toBe( + INCLUSION_JUSTIFICATIONS.accessControl, + ); + } + }); + + it('returns the supplier/cloud justification for 5.19–5.23', () => { + for (const closure of ['5.19', '5.20', '5.21', '5.22', '5.23']) { + expect(getInclusionJustification(closure)).toBe( + INCLUSION_JUSTIFICATIONS.supplierCloud, + ); + } + }); + + it('returns the incident-management justification for 5.24–5.30 and 6.8', () => { + const incidentClosures = ['5.24', '5.25', '5.26', '5.27', '5.28', '5.29', '5.30', '6.8']; + for (const closure of incidentClosures) { + expect(getInclusionJustification(closure)).toBe( + INCLUSION_JUSTIFICATIONS.incidentManagement, + ); + } + }); + + it('returns the secure-development justification for 8.25–8.34', () => { + const devClosures = ['8.25', '8.26', '8.27', '8.28', '8.29', '8.30', '8.31', '8.32', '8.33', '8.34']; + for (const closure of devClosures) { + expect(getInclusionJustification(closure)).toBe( + INCLUSION_JUSTIFICATIONS.secureDevelopment, + ); + } + }); + + it('returns the legal/privacy/compliance justification for 5.31–5.36 and data-protection technical controls', () => { + const legalClosures = ['5.31', '5.32', '5.33', '5.34', '5.35', '5.36', '8.10', '8.11', '8.12']; + for (const closure of legalClosures) { + expect(getInclusionJustification(closure)).toBe( + INCLUSION_JUSTIFICATIONS.legalPrivacyCompliance, + ); + } + }); + + it('returns the physical/remote-working justification for 6.7 and every section-7 control', () => { + expect(getInclusionJustification('6.7')).toBe( + INCLUSION_JUSTIFICATIONS.physicalRemoteWorking, + ); + for (let n = 1; n <= 14; n += 1) { + expect(getInclusionJustification(`7.${n}`)).toBe( + INCLUSION_JUSTIFICATIONS.physicalRemoteWorking, + ); + } + }); + + it('returns null for controls outside the six named families', () => { + // Organisational policies, HR, general technical controls outside the named families. + for (const closure of ['5.1', '5.2', '6.1', '6.2', '8.1', '8.15', '8.20']) { + expect(getInclusionJustification(closure)).toBeNull(); + } + }); + + it('returns null when the closure is missing', () => { + expect(getInclusionJustification(null)).toBeNull(); + expect(getInclusionJustification(undefined)).toBeNull(); + expect(getInclusionJustification('')).toBeNull(); + }); +}); diff --git a/apps/api/src/soa/utils/constants.ts b/apps/api/src/soa/utils/constants.ts index 5fa5db8769..edb2311a3f 100644 --- a/apps/api/src/soa/utils/constants.ts +++ b/apps/api/src/soa/utils/constants.ts @@ -13,6 +13,95 @@ export const ISO27001_FRAMEWORK_NAMES = ['ISO 27001', 'iso27001', 'ISO27001']; export const FULLY_REMOTE_JUSTIFICATION = 'This control is not applicable as our organization operates fully remotely.'; +/** + * Default inclusion justifications by ISO 27001:2022 control family. + * Used when a control is deemed Applicable but the LLM did not supply a justification. + * Controls outside these named families intentionally receive no default justification. + */ +export const INCLUSION_JUSTIFICATIONS = { + accessControl: + 'Applicable because the organisation must restrict access to systems and information based on business need, user role, and information security risk.', + supplierCloud: + 'Applicable because third-party and cloud services are used within the ISMS scope and must be governed to manage supplier and service-provider risk.', + incidentManagement: + 'Applicable because the organisation requires defined processes to identify, report, assess, respond to, and learn from information security events and incidents.', + secureDevelopment: + 'Applicable because software or system changes are developed, configured, tested, or deployed within the ISMS scope.', + legalPrivacyCompliance: + 'Applicable because legal, regulatory, contractual, privacy, and records-protection obligations must be identified and met.', + physicalRemoteWorking: + 'Applicable only where physical, endpoint, home-working, or off-premises asset risks exist; otherwise the control should be excluded with a clear rationale.', +} as const; + +// Maps each ISO 27001:2022 control closure code to a family key in INCLUSION_JUSTIFICATIONS. +const CLOSURE_TO_FAMILY: Record = { + // Access control (organizational + technical) + '5.15': 'accessControl', + '5.16': 'accessControl', + '5.17': 'accessControl', + '5.18': 'accessControl', + '8.2': 'accessControl', + '8.3': 'accessControl', + '8.4': 'accessControl', + '8.5': 'accessControl', + // Supplier and cloud + '5.19': 'supplierCloud', + '5.20': 'supplierCloud', + '5.21': 'supplierCloud', + '5.22': 'supplierCloud', + '5.23': 'supplierCloud', + // Incident management and continuity + '5.24': 'incidentManagement', + '5.25': 'incidentManagement', + '5.26': 'incidentManagement', + '5.27': 'incidentManagement', + '5.28': 'incidentManagement', + '5.29': 'incidentManagement', + '5.30': 'incidentManagement', + '6.8': 'incidentManagement', + // Secure development + '8.25': 'secureDevelopment', + '8.26': 'secureDevelopment', + '8.27': 'secureDevelopment', + '8.28': 'secureDevelopment', + '8.29': 'secureDevelopment', + '8.30': 'secureDevelopment', + '8.31': 'secureDevelopment', + '8.32': 'secureDevelopment', + '8.33': 'secureDevelopment', + '8.34': 'secureDevelopment', + // Legal, privacy, compliance, data protection + '5.31': 'legalPrivacyCompliance', + '5.32': 'legalPrivacyCompliance', + '5.33': 'legalPrivacyCompliance', + '5.34': 'legalPrivacyCompliance', + '5.35': 'legalPrivacyCompliance', + '5.36': 'legalPrivacyCompliance', + '8.10': 'legalPrivacyCompliance', + '8.11': 'legalPrivacyCompliance', + '8.12': 'legalPrivacyCompliance', + // Physical and remote working (all section 7 plus 6.7) + '6.7': 'physicalRemoteWorking', +}; + +/** + * Returns a default inclusion justification appropriate to the control's family, + * or null when the control does not fall into one of the named families. + */ +export function getInclusionJustification( + closure: string | null | undefined, +): string | null { + if (!closure) return null; + + // All of section 7 (7.1–7.14) is physical security. + if (closure.startsWith('7.')) { + return INCLUSION_JUSTIFICATIONS.physicalRemoteWorking; + } + + const family = CLOSURE_TO_FAMILY[closure]; + return family ? INCLUSION_JUSTIFICATIONS[family] : null; +} + // System prompt for SOA RAG generation export const SOA_RAG_SYSTEM_PROMPT = `You are an expert organizational analyst conducting a comprehensive assessment of a company for ISO 27001 compliance. diff --git a/apps/api/src/soa/utils/soa-answer-parser.ts b/apps/api/src/soa/utils/soa-answer-parser.ts index bd3e57aeee..021f43a6de 100644 --- a/apps/api/src/soa/utils/soa-answer-parser.ts +++ b/apps/api/src/soa/utils/soa-answer-parser.ts @@ -1,6 +1,7 @@ import { isInsufficientDataAnswer, FULLY_REMOTE_JUSTIFICATION, + getInclusionJustification, } from './constants'; export interface SOAQuestionResult { @@ -35,19 +36,24 @@ export type SOAStreamSender = (data: { }) => void; /** - * Creates a default YES result (used when insufficient data) + * Creates a default YES result (used when insufficient data). + * Populates a family-appropriate inclusion justification so ISO 27001's + * requirement of a justification for every control is satisfied. */ export function createDefaultYesResult( questionId: string, index: number, send: SOAStreamSender, + closure?: string | null, ): SOAQuestionResult { + const justification = getInclusionJustification(closure); + send({ type: 'answer', questionId, questionIndex: index, isApplicable: true, - justification: null, + justification, success: true, insufficientData: false, }); @@ -55,7 +61,7 @@ export function createDefaultYesResult( return { questionId, isApplicable: true, - justification: null, + justification, success: true, insufficientData: false, }; @@ -104,6 +110,7 @@ export function parseAndProcessSOAAnswer( index: number, answerText: string, send: SOAStreamSender, + closure?: string | null, ): SOAQuestionResult { // Parse JSON response let parsedAnswer: { @@ -119,7 +126,7 @@ export function parseAndProcessSOAAnswer( // Check for insufficient data indicators - if insufficient, default to YES if (isInsufficientDataAnswer(trimmedAnswer)) { - return createDefaultYesResult(questionId, index, send); + return createDefaultYesResult(questionId, index, send, closure); } // Try to extract YES/NO and justification from text @@ -145,7 +152,7 @@ export function parseAndProcessSOAAnswer( parsedAnswer.isApplicable === 'INSUFFICIENT_DATA' || parsedAnswer.isApplicable.toUpperCase().includes('INSUFFICIENT') ) { - return createDefaultYesResult(questionId, index, send); + return createDefaultYesResult(questionId, index, send, closure); } // Parse isApplicable @@ -163,12 +170,19 @@ export function parseAndProcessSOAAnswer( finalIsApplicable = false; } else { // Can't determine YES/NO - default to YES - return createDefaultYesResult(questionId, index, send); + return createDefaultYesResult(questionId, index, send, closure); } - // Get justification (only if NO) + // Trim and normalise the LLM-provided justification. + const llmJustification = parsedAnswer.justification?.trim() || null; + + // For NO: keep the LLM's exclusion justification (may be null and edited later). + // For YES: keep the LLM's inclusion justification, or fall back to the family default + // so ISO 27001's "justify every control" requirement is always satisfied. const justification = - finalIsApplicable === false ? parsedAnswer.justification || null : null; + finalIsApplicable === false + ? llmJustification + : llmJustification || getInclusionJustification(closure); send({ type: 'answer', diff --git a/apps/api/src/soa/utils/soa-storage.ts b/apps/api/src/soa/utils/soa-storage.ts index e1e64309c7..70dd7c538c 100644 --- a/apps/api/src/soa/utils/soa-storage.ts +++ b/apps/api/src/soa/utils/soa-storage.ts @@ -52,9 +52,9 @@ export async function saveAnswersToDatabase( }); } - // Store justification in answer field only if isApplicable is NO - const answerValue = - result.isApplicable === false ? result.justification : null; + // Store justification in the answer field for both YES and NO so the + // SoA always carries a justification for every control (per ISO 27001). + const answerValue = result.justification ?? null; // Create new answer await db.sOAAnswer.create({ From 13f468a2855e1b272c92ce3272b8b3bdfb8d32ca Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Mon, 25 May 2026 09:42:30 -0400 Subject: [PATCH 2/4] fix(app): show default justification at all times on SoA --- .../components/SOAMobileRow.tsx | 18 ++++++--------- .../components/SOATableRow.tsx | 23 +++++++------------ 2 files changed, 15 insertions(+), 26 deletions(-) diff --git a/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/SOAMobileRow.tsx b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/SOAMobileRow.tsx index ec3dc4181b..733f6e8ca9 100644 --- a/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/SOAMobileRow.tsx +++ b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/SOAMobileRow.tsx @@ -69,9 +69,7 @@ export function SOAMobileRow({ } else if (answerData?.savedIsApplicable !== undefined) { displayIsApplicable = answerData.savedIsApplicable; justificationValue = - displayIsApplicable === false - ? (answerData.answer ?? question.columnMapping.justification ?? null) - : null; + answerData.answer ?? question.columnMapping.justification ?? null; } else { displayIsApplicable = processedResult?.isApplicable !== null && processedResult?.isApplicable !== undefined @@ -79,12 +77,10 @@ export function SOAMobileRow({ : (question.columnMapping.isApplicable ?? true); justificationValue = - displayIsApplicable === false - ? (processedResult?.justification || - answerData?.answer || - question.columnMapping.justification || - null) - : null; + processedResult?.justification || + answerData?.answer || + question.columnMapping.justification || + null; } return ( @@ -126,8 +122,8 @@ export function SOAMobileRow({ )} - {/* Justification (only when not applicable) */} - {displayIsApplicable === false && !isProcessing && ( + {/* Justification (shown for both Applicable and Not Applicable per ISO 27001) */} + {!isProcessing && (

Justification

diff --git a/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/SOATableRow.tsx b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/SOATableRow.tsx index ab44110299..481f0c223d 100644 --- a/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/SOATableRow.tsx +++ b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/SOATableRow.tsx @@ -77,9 +77,7 @@ export function SOATableRow({ // Manual save overrides autofill processedResult so the table updates without a full reload displayIsApplicable = answerData.savedIsApplicable; justificationValue = - displayIsApplicable === false - ? (answerData.answer ?? question.columnMapping.justification ?? null) - : null; + answerData.answer ?? question.columnMapping.justification ?? null; } else { // Normal logic: processedResult / column mapping until user saves (then branch above) const isApplicableValue = @@ -88,15 +86,12 @@ export function SOATableRow({ : (question.columnMapping.isApplicable ?? true); justificationValue = - (isApplicableValue === false && processedResult?.justification) || - (isApplicableValue === false && answerData?.answer) || - (isApplicableValue === false && question.columnMapping.justification) || + processedResult?.justification || + answerData?.answer || + question.columnMapping.justification || null; - displayIsApplicable = - processedResult?.isApplicable !== null && processedResult?.isApplicable !== undefined - ? processedResult.isApplicable - : (question.columnMapping.isApplicable ?? true); + displayIsApplicable = isApplicableValue; } return ( @@ -150,19 +145,17 @@ export function SOATableRow({ /> ) ) : column.name === 'justification' ? ( - // Justification is handled within EditableSOAFields when isApplicable is NO + // Show justification text for both Applicable and Not Applicable rows so ISO 27001's + // requirement of a justification for every control on the SoA is visible in the UI. isProcessing ? (

Processing...
- ) : displayIsApplicable === false ? ( - // Show justification text in this column when not editing + ) : (

{justificationValue || '—'}

- ) : ( - ) ) : ( // For other columns (title, control_objective), show "Insufficient data" if question has insufficient data From 2939178111ac4af55f886ab1bee312a3321e590b Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Mon, 25 May 2026 10:41:34 -0400 Subject: [PATCH 3/4] fix(app): able to edit the justification --- .../components/EditableSOAFields.tsx | 37 +++++++++---------- 1 file changed, 18 insertions(+), 19 deletions(-) diff --git a/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/EditableSOAFields.tsx b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/EditableSOAFields.tsx index aa2dc5d144..94b1e8c128 100644 --- a/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/EditableSOAFields.tsx +++ b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/EditableSOAFields.tsx @@ -83,13 +83,11 @@ export function EditableSOAFields({ ) => { setIsSaving(true); try { - const answerValue = nextIsApplicable === false ? nextJustification : null; - await saveAnswer({ questionId, - answer: answerValue, + answer: nextJustification, isApplicable: nextIsApplicable, - justification: nextIsApplicable === false ? nextJustification : null, + justification: nextJustification, }); // Update local state @@ -100,7 +98,7 @@ export function EditableSOAFields({ toast.success('Answer saved successfully'); onUpdate?.({ isApplicable: nextIsApplicable, - justification: nextIsApplicable === false ? nextJustification : null, + justification: nextJustification, }); } catch (err) { const message = err instanceof Error ? err.message : 'Failed to save answer'; @@ -133,14 +131,7 @@ export function EditableSOAFields({ setIsApplicable(newValue); setError(null); - if (newValue === true) { - setJustification(null); - setJustificationDialogOpen(false); - void executeSave(true, null); - return; - } - - if (newValue === false) { + if (newValue === true || newValue === false) { setJustificationDialogOpen(true); return; } @@ -150,13 +141,13 @@ export function EditableSOAFields({ }; const handleJustificationSave = async () => { - if (!justification || justification.trim().length === 0) { + if (isApplicable === false && (!justification || justification.trim().length === 0)) { setError('Justification is required when Applicable is NO'); justificationTextareaRef.current?.focus(); return; } - await executeSave(false, justification); + await executeSave(isApplicable, justification); dialogSavedRef.current = true; setJustificationDialogOpen(false); }; @@ -237,9 +228,13 @@ export function EditableSOAFields({ - Justification Required + + {isApplicable === false ? 'Justification Required' : 'Edit Justification'} + - Explain why this control is not applicable to your organization. + {isApplicable === false + ? 'Explain why this control is not applicable to your organization.' + : 'Explain why this control is applicable to your organization.'}