Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions apps/api/src/soa/soa.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
74 changes: 74 additions & 0 deletions apps/api/src/soa/utils/constants.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
91 changes: 91 additions & 0 deletions apps/api/src/soa/utils/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,97 @@ 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<string, keyof typeof INCLUSION_JUSTIFICATIONS> = {
// 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]
: 'Applicable because this control is within our ISMS scope and requires documented implementation and rationale.';
}

// 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.

Expand Down
33 changes: 25 additions & 8 deletions apps/api/src/soa/utils/soa-answer-parser.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
isInsufficientDataAnswer,
FULLY_REMOTE_JUSTIFICATION,
getInclusionJustification,
} from './constants';

export interface SOAQuestionResult {
Expand Down Expand Up @@ -35,27 +36,32 @@ 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,
});

return {
questionId,
isApplicable: true,
justification: null,
justification,
success: true,
insufficientData: false,
};
Expand Down Expand Up @@ -104,6 +110,7 @@ export function parseAndProcessSOAAnswer(
index: number,
answerText: string,
send: SOAStreamSender,
closure?: string | null,
): SOAQuestionResult {
// Parse JSON response
let parsedAnswer: {
Expand All @@ -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
Expand All @@ -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
Expand All @@ -163,12 +170,22 @@ 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 =
typeof parsedAnswer.justification === 'string'
? parsedAnswer.justification.trim() || null
: 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',
Expand Down
6 changes: 3 additions & 3 deletions apps/api/src/soa/utils/soa-storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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';
Expand Down Expand Up @@ -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;
}
Expand All @@ -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);
};
Expand Down Expand Up @@ -237,9 +228,13 @@ export function EditableSOAFields({
<Dialog open={isJustificationDialogOpen} onOpenChange={handleDialogOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Justification Required</DialogTitle>
<DialogTitle>
{isApplicable === false ? 'Justification Required' : 'Edit Justification'}
</DialogTitle>
<DialogDescription>
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.'}
</DialogDescription>
</DialogHeader>
<Textarea
Expand All @@ -249,9 +244,13 @@ export function EditableSOAFields({
setJustification(e.target.value);
setError(null);
}}
placeholder="Enter justification (required)"
placeholder={
isApplicable === false
? 'Enter justification (required)'
: 'Enter justification'
}
className="min-h-[120px]"
required
required={isApplicable === false}
/>
{error && (
<p className="text-xs text-destructive">{error}</p>
Expand Down
Loading
Loading