Version: 2.2.x (Vue 2.7 + Bootstrap 4.6) Reference Implementation: v2.3.0 (Vue 3 + TypeScript) Date: 2025-02-05
- Executive Summary
- Architecture Overview
- v2.3.0 Analysis
- v2.2.x Design
- Data Flow
- Component Specifications
- Implementation Plan
This document provides a complete design for backporting the v2.3.0 BenchmarkViewer pattern to v2.2.x. The v2.3.0 implementation uses TypeScript adapters to normalize STIG and SRG data into a unified interface, allowing shared components for viewing benchmarks.
Key Pattern: Adapter → Unified Interface → Shared Components
v2.2.x Constraints:
- Vue 2.7 (no
<script setup>, no TypeScript files) - JavaScript instead of TypeScript (adapters as functions, not typed interfaces)
- RULE_TERM constants instead of hardcoded strings
- Existing useBenchmarkViewer composable (needs enhancement)
Current State (v2.2.x):
- ✅ Has BenchmarkViewer.vue wrapper
- ✅ Has useBenchmarkViewer composable with config-driven approach
- ✅ Has StigRuleList, StigRuleDetails, StigRuleOverview components
- ❌ Components are STIG-specific (hardcoded labels)
- ❌ No adapter layer to normalize data
- ❌ No support for SRG viewing
- ❌ No RULE_TERM integration
Goal: Reusable BenchmarkViewer that works for both STIGs and SRGs with shared components.
┌─────────────────────────────────────────────────────────────┐
│ Page Component │
│ Stig.vue or Srg.vue - Minimal wrapper │
│ - Receives benchmark data from Rails (STIG or SRG) │
│ - Applies adapter: stigToBenchmark() or srgToBenchmark() │
│ - Passes unified IBenchmark to BenchmarkViewer │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ BenchmarkViewer.vue │
│ - Receives: type ('stig'|'srg'), benchmark (IBenchmark) │
│ - Manages: selectedRule state, rule sorting/selection │
│ - Renders: 3-column layout (List, Details, Overview) │
│ - Passes: type + rule to child components │
└─────────────────────────────────────────────────────────────┘
│
┌──────────────┼──────────────┐
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ RuleList │ │ Rule │ │ Rule │
│ │ │ Details │ │ Overview │
└──────────┘ └──────────┘ └──────────┘
ALL components use:
- type prop to customize labels ('stig' vs 'srg')
- Unified IBenchmarkRule interface
- Type-specific display logic (v-if="type === 'stig'")
┌─────────────────────────────────────────────────────────────┐
│ Page Component │
│ Stig.vue or Srg.vue - Minimal wrapper │
│ - Receives benchmark data from Rails (STIG or SRG) │
│ - Applies adapter: stigToBenchmark() or srgToBenchmark() │
│ - Passes unified benchmark to BenchmarkViewer │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ BenchmarkViewer.vue │
│ - Uses: useBenchmarkViewer composable (enhanced) │
│ - Receives: type ('stig'|'srg'), benchmark (adapted) │
│ - State: selectedRule, items, filteredItems │
│ - Renders: 3-column layout (List, Details, Overview) │
│ - Passes: type + rule to shared components │
└─────────────────────────────────────────────────────────────┘
│
┌──────────────┼──────────────┐
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ RuleList │ │ Rule │ │ Rule │
│ │ │ Details │ │ Overview │
└──────────┘ └──────────┘ └──────────┘
ALL components use:
- type prop to customize labels
- RULE_TERM constants for terminology
- Computed properties for type-specific display
The key insight: STIGs and SRGs have nearly identical structures but different field names.
STIG Schema:
{
id: number,
stig_id: string, // benchmark_id
title: string,
name: string,
version: string,
benchmark_date: string, // date
description: string,
stig_rules: [ // rules
{
id: number,
rule_id: string,
version: string, // STIG ID
title: string,
rule_severity: string,
vuln_id: string, // STIG-specific
srg_id: string, // STIG-specific
stig_id: number, // foreign key
disa_rule_descriptions_attributes: [...],
checks_attributes: [...]
}
]
}SRG Schema:
{
id: number,
srg_id: string, // benchmark_id
title: string,
name: string,
version: string,
release_date: string, // date
srg_rules: [ // rules
{
id: number,
rule_id: string,
version: string,
title: string,
rule_severity: string,
security_requirements_guide_id: number, // foreign key
disa_rule_descriptions_attributes: [...],
checks_attributes: [...]
}
]
}Unified Interface (IBenchmark):
{
id: number,
benchmark_id: string, // stig_id OR srg_id
title: string,
name: string,
version: string,
date: string, // benchmark_date OR release_date
description: string,
rules: [ // stig_rules OR srg_rules (normalized)
{
id: number,
rule_id: string,
version: string,
title: string,
rule_severity: string,
// STIG-specific (optional)
vuln_id: string,
srg_id: string,
stig_id: number,
// SRG-specific (optional)
security_requirements_guide_id: number,
// Shared
disa_rule_descriptions_attributes: [...],
checks_attributes: [...]
}
]
}stigToBenchmark:
export function stigToBenchmark(stig) {
return {
id: stig.id,
benchmark_id: stig.stig_id,
title: stig.title,
name: stig.name,
version: stig.version,
date: stig.benchmark_date,
description: stig.description,
rules: stig.stig_rules?.map(stigRuleToBenchmarkRule)
};
}srgToBenchmark:
export function srgToBenchmark(srg) {
return {
id: srg.id,
benchmark_id: srg.srg_id,
title: srg.title,
name: srg.name,
version: srg.version,
date: srg.release_date,
rules: srg.srg_rules?.map(srgRuleToBenchmarkRule)
};
}Key Insight: Rules are 95% identical. The adapters just normalize field names.
Components use type prop to switch labels:
<template>
<input
:placeholder="`Search by ${type === 'stig' ? 'STIG ID or SRG ID' : 'Rule ID or Version'}`"
/>
</template>
<script setup>
const fieldOptions = computed(() => [
{ value: 'rule_id', text: props.type === 'stig' ? 'SRG ID' : 'Rule ID' },
{ value: 'version', text: props.type === 'stig' ? 'STIG ID' : 'Version' }
]);
</script>STIG-specific content:
<!-- Only render for STIGs -->
<li v-if="type === 'stig' && rule.vuln_id" class="list-group-item">
<strong>Vuln ID</strong>: {{ rule.vuln_id }}
</li>State management is simple - no composable needed in v2.3.0:
// Selected rule state
const selectedRule = ref(null);
// Sort rules and select first one on mount
const sortedRules = computed(() => {
if (!props.benchmark.rules) return [];
return [...props.benchmark.rules].sort((a, b) =>
a.rule_id.localeCompare(b.rule_id)
);
});
// Select initial rule
watch(() => sortedRules.value, (rules) => {
if (rules.length > 0 && !selectedRule.value) {
selectedRule.value = rules[0];
}
}, { immediate: true });
// Handle rule selection from list
function onRuleSelected(rule) {
selectedRule.value = rule;
}No composable needed because:
- State is just
selectedRule(single ref) - Sorting is a computed property
- Selection is a simple event handler
The v2.2.x useBenchmarkViewer composable is over-engineered for this use case. We can simplify.
NEW FILE - Normalizes STIG/SRG data to unified format.
/**
* Benchmark Adapters
*
* Normalize STIG and SRG data into unified benchmark format.
* Adapts different field names to common interface.
*/
/**
* Convert STIG to unified benchmark format
* @param {Object} stig - STIG object from API
* @returns {Object} Unified benchmark
*/
export function stigToBenchmark(stig) {
return {
id: stig.id,
benchmark_id: stig.stig_id,
title: stig.title,
name: stig.name,
version: stig.version,
date: stig.benchmark_date,
description: stig.description,
created_at: stig.created_at,
updated_at: stig.updated_at,
rules: stig.stig_rules?.map(stigRuleToBenchmarkRule) || []
};
}
/**
* Convert SRG to unified benchmark format
* @param {Object} srg - SRG object from API
* @returns {Object} Unified benchmark
*/
export function srgToBenchmark(srg) {
return {
id: srg.id,
benchmark_id: srg.srg_id,
title: srg.title,
name: srg.name,
version: srg.version,
date: srg.release_date,
created_at: srg.created_at,
updated_at: srg.updated_at,
rules: srg.srg_rules?.map(srgRuleToBenchmarkRule) || []
};
}
/**
* Convert STIG rule to unified benchmark rule format
* @param {Object} rule - STIG rule from API
* @returns {Object} Normalized rule
*/
export function stigRuleToBenchmarkRule(rule) {
return {
id: rule.id,
rule_id: rule.rule_id || '',
version: rule.version,
title: rule.title,
rule_severity: rule.rule_severity || 'medium',
rule_weight: rule.rule_weight,
ident: rule.ident,
ident_system: rule.ident_system,
legacy_ids: rule.legacy_ids,
fixtext: rule.fixtext,
fixtext_fixref: rule.fixtext_fixref,
fix_id: rule.fix_id,
nist_control_family: rule.nist_control_family,
// STIG-specific fields
vuln_id: rule.vuln_id,
srg_id: rule.srg_id,
stig_id: rule.stig_id,
vendor_comments: rule.vendor_comments,
// Nested attributes (pass through)
checks_attributes: rule.checks_attributes,
disa_rule_descriptions_attributes: rule.disa_rule_descriptions_attributes
};
}
/**
* Convert SRG rule to unified benchmark rule format
* @param {Object} rule - SRG rule from API
* @returns {Object} Normalized rule
*/
export function srgRuleToBenchmarkRule(rule) {
return {
id: rule.id,
rule_id: rule.rule_id,
version: rule.version,
title: rule.title,
rule_severity: rule.rule_severity,
rule_weight: rule.rule_weight,
ident: rule.ident,
ident_system: rule.ident_system,
legacy_ids: rule.legacy_ids,
fixtext: rule.fixtext,
fixtext_fixref: rule.fixtext_fixref,
fix_id: rule.fix_id,
nist_control_family: rule.nist_control_family,
// SRG-specific fields
security_requirements_guide_id: rule.security_requirements_guide_id,
// Nested attributes (pass through)
checks_attributes: rule.checks_attributes,
disa_rule_descriptions_attributes: rule.disa_rule_descriptions_attributes
};
}NEW FILE - Port from v2.3.0 (TypeScript → JavaScript).
/**
* Ident Parser Utility
*
* Parses XCCDF ident strings into categorized arrays for display.
*
* XCCDF idents include multiple identifier types:
* - CCIs (CCI-000000): DISA Control Correlation Identifiers
* - CIS Controls v7 (7:X.Y): CIS Critical Security Controls v7
* - CIS Controls v8 (8:X.Y): CIS Critical Security Controls v8
* - MITRE ATT&CK Techniques (T0000): Attack techniques
* - MITRE ATT&CK Tactics (TA0000): Attack tactics
* - MITRE ATT&CK Mitigations (M0000): Mitigations
*/
/**
* Parse a comma-separated ident string into categorized arrays
*
* @param {string|null|undefined} ident - Comma-separated string of identifiers
* @returns {Object} Categorized ident arrays
*
* @example
* const parsed = parseIdents('CCI-000366, 8:3.14, 7:14.9, T1565, TA0001, M1022')
* // Returns:
* // {
* // ccis: ['CCI-000366'],
* // cisV7: ['7:14.9'],
* // cisV8: ['8:3.14'],
* // mitreTechniques: ['T1565'],
* // mitreTactics: ['TA0001'],
* // mitreMitigations: ['M1022'],
* // other: []
* // }
*/
export function parseIdents(ident) {
const result = {
ccis: [],
cisV7: [],
cisV8: [],
mitreTechniques: [],
mitreTactics: [],
mitreMitigations: [],
other: []
};
if (!ident) return result;
const idents = ident.split(/,\s*/);
for (const item of idents) {
const trimmed = item.trim();
if (!trimmed) continue;
if (trimmed.startsWith('CCI-')) {
result.ccis.push(trimmed);
} else if (trimmed.startsWith('7:')) {
result.cisV7.push(trimmed);
} else if (trimmed.startsWith('8:')) {
result.cisV8.push(trimmed);
} else if (/^T\d/.test(trimmed)) {
result.mitreTechniques.push(trimmed);
} else if (/^TA\d/.test(trimmed)) {
result.mitreTactics.push(trimmed);
} else if (/^M\d/.test(trimmed)) {
result.mitreMitigations.push(trimmed);
} else {
result.other.push(trimmed);
}
}
return result;
}
/**
* Check if parsed idents has any CIS Controls data
* @param {Object} parsed - Parsed idents object
* @returns {boolean}
*/
export function hasCisControls(parsed) {
return parsed.cisV7.length > 0 || parsed.cisV8.length > 0;
}
/**
* Check if parsed idents has any MITRE ATT&CK data
* @param {Object} parsed - Parsed idents object
* @returns {boolean}
*/
export function hasMitreData(parsed) {
return parsed.mitreTechniques.length > 0
|| parsed.mitreTactics.length > 0
|| parsed.mitreMitigations.length > 0;
}
/**
* Format CIS Control for display (strips version prefix)
* @param {string} control - CIS control string (e.g., '8:3.14')
* @returns {string} Formatted control (e.g., '3.14')
* @example formatCisControl('8:3.14') => '3.14'
*/
export function formatCisControl(control) {
return control.replace(/^\d:/, '');
}Location: app/javascript/components/shared/RuleList.vue
Changes from StigRuleList:
- Add
typeprop - Use RULE_TERM constants
- Computed properties for type-specific labels
- Remove hardcoded "STIG ID" / "SRG ID" labels
<template>
<div class="p-3">
<!-- Filter Section -->
<div class="mb-3">
<h5 class="card-title">Filter & Search</h5>
<div class="input-group">
<p class="card-text">
<strong>Search</strong><br />
<input
v-model="searchText"
type="text"
class="form-control"
:placeholder="searchPlaceholder"
/><br />
<strong>Filter by Severity</strong><br />
<button class="btn btn-danger mb-2" @click="setSeverity('high')">
High <span class="badge badge-light">{{ high_count }}</span>
</button>
<button class="btn btn-warning mb-2" @click="setSeverity('medium')">
Medium <span class="badge badge-light">{{ medium_count }}</span>
</button>
<button class="btn btn-success mb-2" @click="setSeverity('low')">
Low <span class="badge badge-light">{{ low_count }}</span>
</button>
<button class="btn btn-info mb-2" @click="setSeverity('')">
All <span class="badge badge-light">{{ rules.length }}</span>
</button>
</p>
</div>
</div>
<!-- Table of Rules -->
<div class="mt-3" style="max-height: 700px; overflow-y: auto">
<h5 class="card-title">{{ RULE_TERM.plural }}</h5>
<table class="table table-hover">
<thead>
<tr>
<th class="d-flex">
<b-form-select v-model="field" :options="fieldOptions" />
<b-icon
v-if="sortOrder === 'asc'"
icon="arrow-down-circle"
aria-hidden="true"
@click="sortOrder = 'desc'"
/>
<b-icon
v-if="sortOrder === 'desc'"
icon="arrow-up-circle"
aria-hidden="true"
@click="sortOrder = 'asc'"
/>
</th>
</tr>
</thead>
<tbody>
<tr
v-for="rule in sortedRules"
:key="rule.id"
:class="selectedRule && selectedRule.id === rule.id ? 'bg-secondary text-white' : ''"
@click="selectRule(rule)"
>
<td>{{ displayField(rule) }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<script>
import { RULE_TERM } from '../../constants/terminology';
export default {
name: 'RuleList',
props: {
type: {
type: String,
required: true,
validator: (value) => ['stig', 'srg'].includes(value)
},
rules: {
type: Array,
required: true
},
initialSelectedRule: {
type: Object,
required: true
}
},
data() {
return {
RULE_TERM,
searchText: '',
selectedSeverity: '',
low_count: this.filterBySeverity('low').length,
medium_count: this.filterBySeverity('medium').length,
high_count: this.filterBySeverity('high').length,
field: 'rule_id',
sortOrder: 'asc',
selectedRule: this.initialSelectedRule
};
},
computed: {
searchPlaceholder() {
return this.type === 'stig'
? 'Search by STIG ID or SRG ID'
: 'Search by Rule ID or Version';
},
fieldOptions() {
return [
{
value: 'rule_id',
text: this.type === 'stig' ? 'SRG ID' : 'Rule ID'
},
{
value: 'version',
text: this.type === 'stig' ? 'STIG ID' : 'Version'
}
];
},
filteredRules() {
if (this.searchText) {
return this.rules.filter((rule) => {
const searchText = this.searchText.toLowerCase();
return (
rule.rule_id.toLowerCase().includes(searchText) ||
rule.version.toLowerCase().includes(searchText)
);
});
} else if (this.selectedSeverity) {
return this.filterBySeverity(this.selectedSeverity);
} else {
return this.rules;
}
},
sortedRules() {
const rules = this.filteredRules;
return rules.sort((a, b) => {
const aVal = this.field === 'rule_id' ? a.rule_id : a.version;
const bVal = this.field === 'rule_id' ? b.rule_id : b.version;
const comparison = aVal.localeCompare(bVal);
return this.sortOrder === 'asc' ? comparison : -comparison;
});
}
},
methods: {
setSeverity(severity) {
this.selectedSeverity = severity;
},
filterBySeverity(severity) {
return this.rules.filter((rule) => rule.rule_severity === severity);
},
selectRule(rule) {
this.selectedRule = rule;
this.$emit('rule-selected', rule);
},
displayField(rule) {
return this.field === 'rule_id' ? rule.rule_id : rule.version;
}
}
};
</script>Location: app/javascript/components/shared/RuleDetails.vue
Changes from StigRuleDetails:
- Add
typeprop (for future expansion) - Use selectedRule prop name consistently
- No hardcoded changes needed (already generic)
<template>
<div class="card h-100">
<div class="card-header">
<h5 class="card-title">{{ selectedRule.title }}</h5>
</div>
<div class="card-body">
<b-form>
<!-- Vulnerability Discussion -->
<DisaRuleDescriptionForm
:rule="selectedRule"
:index="0"
:description="selectedRule.disa_rule_descriptions_attributes[0]"
:disabled="true"
:fields="disaDescriptionFormFields"
/>
<!-- Check Content -->
<CheckForm
:rule="selectedRule"
:index="0"
:disabled="true"
:fields="checkFormFields"
/>
<!-- Fix Text -->
<b-form-group>
<label :for="`rule-fixtext-${selectedRule.id}`">
Fix
<b-icon
v-b-tooltip.hover.html="
'Describe how to correctly configure the requirement to remediate the system vulnerability'
"
icon="info-circle"
aria-hidden="true"
/>
</label>
<b-form-textarea
:id="`rule-fixtext-${selectedRule.id}`"
:value="selectedRule.fixtext"
placeholder=""
:disabled="true"
rows="1"
max-rows="99"
/>
</b-form-group>
<!-- Vendor Comment (if present) -->
<b-form-group v-if="selectedRule.vendor_comments">
<label :for="`rule-vendor-comments-${selectedRule.id}`">
Vendor Comments
<b-icon
v-b-tooltip.hover.html="
'Provide context to a reviewing authority; not a published field'
"
icon="info-circle"
aria-hidden="true"
/>
</label>
<b-form-textarea
:id="`rule-vendor-comments-${selectedRule.id}`"
:value="selectedRule.vendor_comments"
placeholder=""
:disabled="true"
rows="1"
max-rows="99"
/>
</b-form-group>
</b-form>
</div>
</div>
</template>
<script>
import DisaRuleDescriptionForm from '../rules/forms/DisaRuleDescriptionForm';
import CheckForm from '../rules/forms/CheckForm';
export default {
name: 'RuleDetails',
components: { DisaRuleDescriptionForm, CheckForm },
props: {
type: {
type: String,
required: true,
validator: (value) => ['stig', 'srg'].includes(value)
},
selectedRule: {
type: Object,
required: true
}
},
computed: {
disaDescriptionFormFields() {
return { displayed: ['vuln_discussion'], disabled: [] };
},
checkFormFields() {
return {
displayed: ['content'],
disabled: []
};
}
}
};
</script>Location: app/javascript/components/shared/RuleOverview.vue
Changes from StigRuleOverview:
- Add
typeprop - Use RULE_TERM constants
- Conditional rendering for STIG-specific fields
- Add CIS Controls and MITRE ATT&CK parsing
<template>
<div class="card h-100 w-100">
<div class="card-header">
<h5 class="card-title">{{ RULE_TERM.singular }} Overview</h5>
</div>
<div class="card-body">
<ul class="list-group list-group-flush">
<!-- STIG-specific: Vuln ID -->
<li v-if="type === 'stig' && selectedRule.vuln_id" class="list-group-item">
<strong>Vuln ID</strong>: {{ selectedRule.vuln_id }}
</li>
<!-- Rule ID -->
<li class="list-group-item">
<strong>Rule ID</strong>: {{ selectedRule.rule_id }}
</li>
<!-- Version / STIG ID -->
<li class="list-group-item">
<strong>{{ versionLabel }}</strong>: {{ selectedRule.version }}
</li>
<!-- STIG-specific: SRG ID -->
<li v-if="type === 'stig' && selectedRule.srg_id" class="list-group-item">
<strong>SRG ID</strong>: {{ selectedRule.srg_id }}
</li>
<!-- Severity -->
<li class="list-group-item">
<strong>Severity</strong>:
<span class="badge" :class="severityBgColor">
{{ selectedRule.rule_severity }}
</span>
</li>
<!-- Legacy IDs -->
<li v-if="selectedRule.legacy_ids" class="list-group-item">
<strong>Legacy IDs</strong>: {{ selectedRule.legacy_ids }}
</li>
<!-- CCIs (DISA Control Correlation Identifiers) -->
<li v-if="parsedIdents.ccis.length > 0" class="list-group-item">
<strong>CCI</strong>: {{ parsedIdents.ccis.join(', ') }}
</li>
<!-- NIST Control Family / IA Control -->
<li v-if="selectedRule.nist_control_family" class="list-group-item">
<strong>IA Control</strong>: {{ selectedRule.nist_control_family }}
</li>
<!-- CIS Controls v8 -->
<li v-if="parsedIdents.cisV8.length > 0" class="list-group-item">
<strong>CIS Controls v8</strong>:
<span v-for="(control, idx) in parsedIdents.cisV8" :key="control">
<a
href="https://www.cisecurity.org/controls/v8"
target="_blank"
rel="noopener noreferrer"
class="text-decoration-none"
>{{ formatCisControl(control) }}</a>
<span v-if="idx < parsedIdents.cisV8.length - 1">, </span>
</span>
</li>
<!-- CIS Controls v7 -->
<li v-if="parsedIdents.cisV7.length > 0" class="list-group-item">
<strong>CIS Controls v7</strong>:
<span v-for="(control, idx) in parsedIdents.cisV7" :key="control">
<a
href="https://www.cisecurity.org/controls/v7"
target="_blank"
rel="noopener noreferrer"
class="text-decoration-none"
>{{ formatCisControl(control) }}</a>
<span v-if="idx < parsedIdents.cisV7.length - 1">, </span>
</span>
</li>
<!-- MITRE ATT&CK Techniques -->
<li v-if="parsedIdents.mitreTechniques.length > 0" class="list-group-item">
<strong>ATT&CK Techniques</strong>:
<span v-for="(tech, idx) in parsedIdents.mitreTechniques" :key="tech">
<a
:href="`https://attack.mitre.org/techniques/${tech.replace('.', '/')}`"
target="_blank"
rel="noopener noreferrer"
class="text-decoration-none"
>{{ tech }}</a>
<span v-if="idx < parsedIdents.mitreTechniques.length - 1">, </span>
</span>
</li>
<!-- MITRE ATT&CK Tactics -->
<li v-if="parsedIdents.mitreTactics.length > 0" class="list-group-item">
<strong>ATT&CK Tactics</strong>:
<span v-for="(tactic, idx) in parsedIdents.mitreTactics" :key="tactic">
<a
:href="`https://attack.mitre.org/tactics/${tactic}`"
target="_blank"
rel="noopener noreferrer"
class="text-decoration-none"
>{{ tactic }}</a>
<span v-if="idx < parsedIdents.mitreTactics.length - 1">, </span>
</span>
</li>
<!-- MITRE ATT&CK Mitigations -->
<li v-if="parsedIdents.mitreMitigations.length > 0" class="list-group-item">
<strong>ATT&CK Mitigations</strong>:
<span v-for="(mit, idx) in parsedIdents.mitreMitigations" :key="mit">
<a
:href="`https://attack.mitre.org/mitigations/${mit}`"
target="_blank"
rel="noopener noreferrer"
class="text-decoration-none"
>{{ mit }}</a>
<span v-if="idx < parsedIdents.mitreMitigations.length - 1">, </span>
</span>
</li>
<!-- Other/Unknown Idents (fallback) -->
<li v-if="parsedIdents.other.length > 0" class="list-group-item">
<strong>Other</strong>: {{ parsedIdents.other.join(', ') }}
</li>
<!-- Status (if present) -->
<li v-if="selectedRule.status" class="list-group-item">
<strong>Status</strong>: {{ selectedRule.status }}
</li>
</ul>
</div>
</div>
</template>
<script>
import { RULE_TERM } from '../../constants/terminology';
import { parseIdents, formatCisControl } from '../../utils/ident-parser';
export default {
name: 'RuleOverview',
props: {
type: {
type: String,
required: true,
validator: (value) => ['stig', 'srg'].includes(value)
},
selectedRule: {
type: Object,
required: true
}
},
data() {
return {
RULE_TERM
};
},
computed: {
versionLabel() {
return this.type === 'stig' ? 'STIG ID' : 'Version';
},
severityBgColor() {
const severity = this.selectedRule.rule_severity;
if (severity === 'high') {
return 'bg-danger';
} else if (severity === 'medium') {
return 'bg-warning text-dark';
} else {
return 'bg-success';
}
},
parsedIdents() {
return parseIdents(this.selectedRule.ident);
}
},
methods: {
formatCisControl
}
};
</script>Location: app/javascript/components/shared/BenchmarkViewer.vue
Changes:
- Remove useBenchmarkViewer composable (over-engineered)
- Add simple state management (selectedRule ref)
- Use shared RuleList/RuleDetails/RuleOverview components
- Pass
typeprop to all child components
<template>
<div>
<b-breadcrumb :items="breadcrumbs" />
<!-- Command Bar -->
<BaseCommandBar>
<template #left>
<b-button
variant="outline-secondary"
size="sm"
:href="listPath"
>
<b-icon icon="arrow-left" /> Back to {{ typeLabel }}s
</b-button>
<b-button
variant="outline-secondary"
size="sm"
class="ml-2"
@click="openExportModal"
>
<b-icon icon="download" /> Download
</b-button>
</template>
<template #right>
<!-- No panels for viewer page -->
</template>
</BaseCommandBar>
<!-- Three-Column Layout -->
<b-row>
<!-- Left: Rule List -->
<b-col md="3">
<RuleList
:type="type"
:rules="sortedRules"
:initial-selected-rule="selectedRule"
@rule-selected="selectRule"
/>
</b-col>
<!-- Middle: Rule Details -->
<b-col md="6">
<RuleDetails
v-if="selectedRule"
:type="type"
:selected-rule="selectedRule"
/>
<div v-else class="alert alert-info">
Select a {{ RULE_TERM.singular.toLowerCase() }} to view details
</div>
</b-col>
<!-- Right: Rule Overview -->
<b-col md="3">
<RuleOverview
v-if="selectedRule"
:type="type"
:selected-rule="selectedRule"
/>
</b-col>
</b-row>
<!-- Export Modal -->
<ExportModal
v-if="showExportModal"
v-model="showExportModal"
:components="[benchmark]"
@export="handleExport"
@cancel="showExportModal = false"
/>
</div>
</template>
<script>
import axios from 'axios';
import BaseCommandBar from './BaseCommandBar.vue';
import ExportModal from './ExportModal.vue';
import RuleList from './RuleList.vue';
import RuleDetails from './RuleDetails.vue';
import RuleOverview from './RuleOverview.vue';
import AlertMixinVue from '../../mixins/AlertMixin.vue';
import { RULE_TERM } from '../../constants/terminology';
export default {
name: 'BenchmarkViewer',
components: {
BaseCommandBar,
ExportModal,
RuleList,
RuleDetails,
RuleOverview
},
mixins: [AlertMixinVue],
props: {
benchmark: {
type: Object,
required: true
},
type: {
type: String,
required: true,
validator: (value) => ['stig', 'srg'].includes(value)
}
},
data() {
return {
RULE_TERM,
selectedRule: null,
showExportModal: false
};
},
computed: {
breadcrumbs() {
return [
{ text: this.typeLabel + 's', href: this.listPath },
{ text: `${this.benchmark.title} ${this.benchmark.version || ''}`, active: true }
];
},
typeLabel() {
const labels = {
stig: 'STIG',
srg: 'SRG'
};
return labels[this.type] || 'Benchmark';
},
listPath() {
const paths = {
stig: '/stigs',
srg: '/srgs'
};
return paths[this.type] || '/';
},
sortedRules() {
if (!this.benchmark.rules) return [];
return [...this.benchmark.rules].sort((a, b) =>
a.rule_id.localeCompare(b.rule_id)
);
}
},
watch: {
sortedRules: {
handler(rules) {
// Select first rule on mount
if (rules.length > 0 && !this.selectedRule) {
this.selectedRule = rules[0];
}
},
immediate: true
}
},
methods: {
selectRule(rule) {
this.selectedRule = rule;
},
openExportModal() {
this.showExportModal = true;
},
handleExport({ type }) {
const benchmarkType = this.type === 'srg' ? 'srgs' : 'stigs';
axios
.get(`/${benchmarkType}/${this.benchmark.id}/export/${type}`)
.then(() => {
window.open(`/${benchmarkType}/${this.benchmark.id}/export/${type}`);
})
.catch(this.alertOrNotifyResponse);
}
}
};
</script>Location: app/javascript/components/stigs/Stig.vue
Changes:
- Import stigToBenchmark adapter
- Apply adapter before passing to BenchmarkViewer
<template>
<BenchmarkViewer :benchmark="adaptedStig" type="stig" />
</template>
<script>
import BenchmarkViewer from '../shared/BenchmarkViewer.vue';
import { stigToBenchmark } from '../../adapters/benchmark';
export default {
name: 'Stig',
components: { BenchmarkViewer },
props: {
stig: {
type: Object,
required: true
}
},
computed: {
adaptedStig() {
return stigToBenchmark(this.stig);
}
}
};
</script>Location: app/javascript/components/srgs/Srg.vue
Pattern: Identical to Stig.vue but for SRGs.
<template>
<BenchmarkViewer :benchmark="adaptedSrg" type="srg" />
</template>
<script>
import BenchmarkViewer from '../shared/BenchmarkViewer.vue';
import { srgToBenchmark } from '../../adapters/benchmark';
export default {
name: 'Srg',
components: { BenchmarkViewer },
props: {
srg: {
type: Object,
required: true
}
},
computed: {
adaptedSrg() {
return srgToBenchmark(this.srg);
}
}
};
</script>1. User visits /stigs/:id
↓
2. Rails renders views/stigs/show.html.haml
↓
3. HAML passes @stig (with stig_rules) to Stig.vue
↓
4. Stig.vue applies stigToBenchmark adapter:
- stig_id → benchmark_id
- benchmark_date → date
- stig_rules → rules (mapped with stigRuleToBenchmarkRule)
↓
5. Passes adapted benchmark + type='stig' to BenchmarkViewer
↓
6. BenchmarkViewer:
- Sorts rules by rule_id
- Selects first rule
- Renders RuleList, RuleDetails, RuleOverview with type='stig'
↓
7. Components use type prop to customize:
- Labels: "SRG ID" vs "Rule ID"
- Conditional fields: vuln_id, srg_id (STIG-only)
1. User visits /srgs/:id
↓
2. Rails renders views/srgs/show.html.haml
↓
3. HAML passes @srg (with srg_rules) to Srg.vue
↓
4. Srg.vue applies srgToBenchmark adapter:
- srg_id → benchmark_id
- release_date → date
- srg_rules → rules (mapped with srgRuleToBenchmarkRule)
↓
5. Passes adapted benchmark + type='srg' to BenchmarkViewer
↓
6. BenchmarkViewer:
- Sorts rules by rule_id
- Selects first rule
- Renders RuleList, RuleDetails, RuleOverview with type='srg'
↓
7. Components use type prop to customize:
- Labels: "Rule ID", "Version"
- Hides STIG-specific fields (vuln_id, srg_id)
BenchmarkViewer:
benchmark(Object, required) - Adapted benchmark data (unified format)type(String, required) - 'stig' | 'srg'
RuleList, RuleDetails, RuleOverview:
type(String, required) - 'stig' | 'srg'selectedRule(Object, required) - Current rule from adapted benchmark
RuleList:
@rule-selected- Emits selected rule object
All components use RULE_TERM constants:
import { RULE_TERM } from '../../constants/terminology';
// Usage in template:
<h5>{{ RULE_TERM.plural }}</h5>
<div>Select a {{ RULE_TERM.singular.toLowerCase() }} to view</div>Components use computed properties and conditional rendering:
<script>
computed: {
searchPlaceholder() {
return this.type === 'stig'
? 'Search by STIG ID or SRG ID'
: 'Search by Rule ID or Version';
}
}
</script>
<template>
<!-- STIG-specific field -->
<li v-if="type === 'stig' && rule.vuln_id">
<strong>Vuln ID</strong>: {{ rule.vuln_id }}
</li>
</template>Tests: app/javascript/__tests__/adapters/benchmark.spec.js
-
Test stigToBenchmark adapter:
describe('stigToBenchmark', () => { it('normalizes stig_id to benchmark_id', () => { const stig = { stig_id: 'test-stig', /* ... */ }; const result = stigToBenchmark(stig); expect(result.benchmark_id).toBe('test-stig'); }); it('normalizes benchmark_date to date', () => { const stig = { benchmark_date: '2024-01-01', /* ... */ }; const result = stigToBenchmark(stig); expect(result.date).toBe('2024-01-01'); }); it('maps stig_rules to rules array', () => { const stig = { stig_rules: [{ rule_id: 'SRG-001', /* ... */ }], /* ... */ }; const result = stigToBenchmark(stig); expect(result.rules).toHaveLength(1); expect(result.rules[0].rule_id).toBe('SRG-001'); }); it('handles missing stig_rules gracefully', () => { const stig = { /* no stig_rules */ }; const result = stigToBenchmark(stig); expect(result.rules).toEqual([]); }); });
-
Test srgToBenchmark adapter:
describe('srgToBenchmark', () => { it('normalizes srg_id to benchmark_id', () => { const srg = { srg_id: 'test-srg', /* ... */ }; const result = srgToBenchmark(srg); expect(result.benchmark_id).toBe('test-srg'); }); it('normalizes release_date to date', () => { const srg = { release_date: '2024-01-01', /* ... */ }; const result = srgToBenchmark(srg); expect(result.date).toBe('2024-01-01'); }); it('maps srg_rules to rules array', () => { const srg = { srg_rules: [{ rule_id: 'SRG-001', /* ... */ }], /* ... */ }; const result = srgToBenchmark(srg); expect(result.rules).toHaveLength(1); expect(result.rules[0].rule_id).toBe('SRG-001'); }); });
-
Test rule adapters:
describe('stigRuleToBenchmarkRule', () => { it('preserves all common fields', () => { const rule = { id: 1, rule_id: 'SRG-001', version: 'V-001', title: 'Test Rule', rule_severity: 'high', /* ... */ }; const result = stigRuleToBenchmarkRule(rule); expect(result.id).toBe(1); expect(result.rule_id).toBe('SRG-001'); expect(result.version).toBe('V-001'); }); it('preserves STIG-specific fields', () => { const rule = { vuln_id: 'V-001', srg_id: 'SRG-001', stig_id: 123, /* ... */ }; const result = stigRuleToBenchmarkRule(rule); expect(result.vuln_id).toBe('V-001'); expect(result.srg_id).toBe('SRG-001'); expect(result.stig_id).toBe(123); }); }); describe('srgRuleToBenchmarkRule', () => { it('preserves SRG-specific fields', () => { const rule = { security_requirements_guide_id: 456, /* ... */ }; const result = srgRuleToBenchmarkRule(rule); expect(result.security_requirements_guide_id).toBe(456); }); });
-
Implementation:
- Create
app/javascript/adapters/benchmark.js - Implement stigToBenchmark, srgToBenchmark
- Implement stigRuleToBenchmarkRule, srgRuleToBenchmarkRule
- Run tests:
yarn test:unit adapters/benchmark.spec.js - All tests pass ✓
- Create
Tests: app/javascript/__tests__/utils/ident-parser.spec.js
-
Test parseIdents utility:
describe('parseIdents', () => { it('parses CCIs correctly', () => { const result = parseIdents('CCI-000366, CCI-001234'); expect(result.ccis).toEqual(['CCI-000366', 'CCI-001234']); }); it('parses CIS Controls v8', () => { const result = parseIdents('8:3.14, 8:5.1'); expect(result.cisV8).toEqual(['8:3.14', '8:5.1']); }); it('parses MITRE ATT&CK techniques', () => { const result = parseIdents('T1565, T1003.001'); expect(result.mitreTechniques).toEqual(['T1565', 'T1003.001']); }); it('handles null/undefined gracefully', () => { expect(parseIdents(null)).toEqual({ ccis: [], cisV7: [], cisV8: [], mitreTechniques: [], mitreTactics: [], mitreMitigations: [], other: [] }); }); it('parses mixed identifiers', () => { const result = parseIdents('CCI-000366, 8:3.14, T1565, TA0001, M1022'); expect(result.ccis).toEqual(['CCI-000366']); expect(result.cisV8).toEqual(['8:3.14']); expect(result.mitreTechniques).toEqual(['T1565']); expect(result.mitreTactics).toEqual(['TA0001']); expect(result.mitreMitigations).toEqual(['M1022']); }); }); describe('formatCisControl', () => { it('strips version prefix from CIS control', () => { expect(formatCisControl('8:3.14')).toBe('3.14'); expect(formatCisControl('7:14.9')).toBe('14.9'); }); });
-
Implementation:
- Create
app/javascript/utils/ident-parser.js - Port TypeScript implementation to JavaScript
- Run tests:
yarn test:unit utils/ident-parser.spec.js - All tests pass ✓
- Create
Strategy: Rename and enhance existing STIG components.
Tests: app/javascript/__tests__/components/shared/RuleList.spec.js
-
Test type-specific behavior:
import { mount } from '@vue/test-utils'; import RuleList from '@/components/shared/RuleList.vue'; describe('RuleList', () => { const mockRules = [ { id: 1, rule_id: 'SRG-001', version: 'V-001', title: 'Test', rule_severity: 'high' }, { id: 2, rule_id: 'SRG-002', version: 'V-002', title: 'Test 2', rule_severity: 'low' } ]; describe('STIG mode', () => { it('displays STIG-specific placeholder', () => { const wrapper = mount(RuleList, { propsData: { type: 'stig', rules: mockRules, initialSelectedRule: mockRules[0] } }); expect(wrapper.find('input').attributes('placeholder')) .toBe('Search by STIG ID or SRG ID'); }); it('displays STIG-specific field options', () => { const wrapper = mount(RuleList, { propsData: { type: 'stig', rules: mockRules, initialSelectedRule: mockRules[0] } }); expect(wrapper.vm.fieldOptions[0].text).toBe('SRG ID'); expect(wrapper.vm.fieldOptions[1].text).toBe('STIG ID'); }); }); describe('SRG mode', () => { it('displays SRG-specific placeholder', () => { const wrapper = mount(RuleList, { propsData: { type: 'srg', rules: mockRules, initialSelectedRule: mockRules[0] } }); expect(wrapper.find('input').attributes('placeholder')) .toBe('Search by Rule ID or Version'); }); it('displays SRG-specific field options', () => { const wrapper = mount(RuleList, { propsData: { type: 'srg', rules: mockRules, initialSelectedRule: mockRules[0] } }); expect(wrapper.vm.fieldOptions[0].text).toBe('Rule ID'); expect(wrapper.vm.fieldOptions[1].text).toBe('Version'); }); }); it('emits rule-selected event when rule clicked', () => { const wrapper = mount(RuleList, { propsData: { type: 'stig', rules: mockRules, initialSelectedRule: mockRules[0] } }); wrapper.findAll('tr').at(1).trigger('click'); expect(wrapper.emitted('rule-selected')[0][0]).toEqual(mockRules[1]); }); });
-
Implementation:
- Rename
app/javascript/components/stigs/StigRuleList.vue→app/javascript/components/shared/RuleList.vue - Add
typeprop - Add computed properties for type-specific labels
- Import RULE_TERM constants
- Run tests:
yarn test:unit components/shared/RuleList.spec.js - All tests pass ✓
- Rename
Tests: app/javascript/__tests__/components/shared/RuleOverview.spec.js
-
Test conditional rendering:
describe('RuleOverview', () => { const stigRule = { id: 1, rule_id: 'SRG-001', version: 'V-001', title: 'Test', rule_severity: 'high', vuln_id: 'V-123456', srg_id: 'SRG-001', ident: 'CCI-000366, 8:3.14, T1565' }; const srgRule = { id: 2, rule_id: 'SRG-001', version: 'V1R1', title: 'Test', rule_severity: 'medium', ident: 'CCI-000366' }; describe('STIG mode', () => { it('displays Vuln ID', () => { const wrapper = mount(RuleOverview, { propsData: { type: 'stig', selectedRule: stigRule } }); expect(wrapper.text()).toContain('Vuln ID'); expect(wrapper.text()).toContain('V-123456'); }); it('displays SRG ID', () => { const wrapper = mount(RuleOverview, { propsData: { type: 'stig', selectedRule: stigRule } }); expect(wrapper.text()).toContain('SRG ID'); expect(wrapper.text()).toContain('SRG-001'); }); it('displays STIG ID label for version', () => { const wrapper = mount(RuleOverview, { propsData: { type: 'stig', selectedRule: stigRule } }); expect(wrapper.text()).toContain('STIG ID'); }); }); describe('SRG mode', () => { it('does not display Vuln ID', () => { const wrapper = mount(RuleOverview, { propsData: { type: 'srg', selectedRule: srgRule } }); expect(wrapper.text()).not.toContain('Vuln ID'); }); it('does not display SRG ID', () => { const wrapper = mount(RuleOverview, { propsData: { type: 'srg', selectedRule: srgRule } }); expect(wrapper.text()).not.toContain('SRG ID'); }); it('displays Version label for version', () => { const wrapper = mount(RuleOverview, { propsData: { type: 'srg', selectedRule: srgRule } }); expect(wrapper.text()).toContain('Version'); }); }); describe('ident parsing', () => { it('displays parsed CCI', () => { const wrapper = mount(RuleOverview, { propsData: { type: 'stig', selectedRule: stigRule } }); expect(wrapper.text()).toContain('CCI-000366'); }); it('displays parsed CIS Controls', () => { const wrapper = mount(RuleOverview, { propsData: { type: 'stig', selectedRule: stigRule } }); expect(wrapper.text()).toContain('CIS Controls v8'); expect(wrapper.text()).toContain('3.14'); }); it('displays parsed MITRE techniques', () => { const wrapper = mount(RuleOverview, { propsData: { type: 'stig', selectedRule: stigRule } }); expect(wrapper.text()).toContain('ATT&CK Techniques'); expect(wrapper.text()).toContain('T1565'); }); }); });
-
Implementation:
- Rename
app/javascript/components/stigs/StigRuleOverview.vue→app/javascript/components/shared/RuleOverview.vue - Add
typeprop - Add conditional rendering for STIG-specific fields
- Import parseIdents, formatCisControl utilities
- Add CIS Controls and MITRE ATT&CK sections
- Run tests:
yarn test:unit components/shared/RuleOverview.spec.js - All tests pass ✓
- Rename
Tests: app/javascript/__tests__/components/shared/RuleDetails.spec.js
-
Test basic rendering:
describe('RuleDetails', () => { const mockRule = { id: 1, title: 'Test Rule', fixtext: 'Fix instructions here', vendor_comments: 'Vendor note', disa_rule_descriptions_attributes: [ { vuln_discussion: 'Vulnerability details' } ], checks_attributes: [ { content: 'Check content' } ] }; it('renders rule title', () => { const wrapper = mount(RuleDetails, { propsData: { type: 'stig', selectedRule: mockRule } }); expect(wrapper.text()).toContain('Test Rule'); }); it('renders fix text', () => { const wrapper = mount(RuleDetails, { propsData: { type: 'stig', selectedRule: mockRule } }); expect(wrapper.find('textarea[id^="rule-fixtext"]').element.value) .toBe('Fix instructions here'); }); it('renders vendor comments when present', () => { const wrapper = mount(RuleDetails, { propsData: { type: 'stig', selectedRule: mockRule } }); expect(wrapper.text()).toContain('Vendor Comments'); }); });
-
Implementation:
- Rename
app/javascript/components/stigs/StigRuleDetails.vue→app/javascript/components/shared/RuleDetails.vue - Add
typeprop (for future expansion) - Update ID prefixes from
stig-rule-*torule-* - Run tests:
yarn test:unit components/shared/RuleDetails.spec.js - All tests pass ✓
- Rename
Tests: app/javascript/__tests__/components/shared/BenchmarkViewer.spec.js
-
Test state management:
describe('BenchmarkViewer', () => { const mockBenchmark = { id: 1, title: 'Test STIG', version: 'V1R1', rules: [ { id: 1, rule_id: 'SRG-001', version: 'V-001', title: 'Rule 1', rule_severity: 'high' }, { id: 2, rule_id: 'SRG-002', version: 'V-002', title: 'Rule 2', rule_severity: 'low' } ] }; it('selects first rule on mount', () => { const wrapper = mount(BenchmarkViewer, { propsData: { type: 'stig', benchmark: mockBenchmark } }); expect(wrapper.vm.selectedRule).toEqual(mockBenchmark.rules[0]); }); it('sorts rules by rule_id', () => { const unsortedBenchmark = { ...mockBenchmark, rules: [ { id: 2, rule_id: 'SRG-002', version: 'V-002', title: 'Rule 2' }, { id: 1, rule_id: 'SRG-001', version: 'V-001', title: 'Rule 1' } ] }; const wrapper = mount(BenchmarkViewer, { propsData: { type: 'stig', benchmark: unsortedBenchmark } }); expect(wrapper.vm.sortedRules[0].rule_id).toBe('SRG-001'); expect(wrapper.vm.sortedRules[1].rule_id).toBe('SRG-002'); }); it('updates selectedRule when rule-selected event emitted', () => { const wrapper = mount(BenchmarkViewer, { propsData: { type: 'stig', benchmark: mockBenchmark } }); wrapper.vm.selectRule(mockBenchmark.rules[1]); expect(wrapper.vm.selectedRule).toEqual(mockBenchmark.rules[1]); }); it('passes type prop to child components', () => { const wrapper = mount(BenchmarkViewer, { propsData: { type: 'stig', benchmark: mockBenchmark } }); expect(wrapper.findComponent(RuleList).props('type')).toBe('stig'); expect(wrapper.findComponent(RuleDetails).props('type')).toBe('stig'); expect(wrapper.findComponent(RuleOverview).props('type')).toBe('stig'); }); });
-
Implementation:
- Update
app/javascript/components/shared/BenchmarkViewer.vue - Remove useBenchmarkViewer composable
- Add simple state management (selectedRule ref, sortedRules computed)
- Update component imports (RuleList, RuleDetails, RuleOverview from shared/)
- Pass
typeprop to all child components - Run tests:
yarn test:unit components/shared/BenchmarkViewer.spec.js - All tests pass ✓
- Update
Tests: app/javascript/__tests__/components/stigs/Stig.spec.js
-
Test adapter integration:
import { stigToBenchmark } from '@/adapters/benchmark'; describe('Stig.vue', () => { const mockStig = { id: 1, stig_id: 'TEST_STIG', title: 'Test STIG', version: 'V1R1', benchmark_date: '2024-01-01', stig_rules: [ { id: 1, rule_id: 'SRG-001', version: 'V-001', title: 'Rule 1' } ] }; it('applies stigToBenchmark adapter', () => { const wrapper = mount(Stig, { propsData: { stig: mockStig } }); const adapted = wrapper.vm.adaptedStig; expect(adapted.benchmark_id).toBe('TEST_STIG'); expect(adapted.date).toBe('2024-01-01'); expect(adapted.rules).toHaveLength(1); }); it('passes adapted data to BenchmarkViewer', () => { const wrapper = mount(Stig, { propsData: { stig: mockStig } }); const benchmarkViewer = wrapper.findComponent(BenchmarkViewer); expect(benchmarkViewer.props('benchmark').benchmark_id).toBe('TEST_STIG'); expect(benchmarkViewer.props('type')).toBe('stig'); }); });
-
Implementation:
- Update
app/javascript/components/stigs/Stig.vue - Import stigToBenchmark adapter
- Add computed property:
adaptedStig - Pass
:benchmark="adaptedStig"to BenchmarkViewer - Run tests:
yarn test:unit components/stigs/Stig.spec.js - All tests pass ✓
- Update
Tests: app/javascript/__tests__/components/srgs/Srg.spec.js
-
Test adapter integration:
import { srgToBenchmark } from '@/adapters/benchmark'; describe('Srg.vue', () => { const mockSrg = { id: 1, srg_id: 'TEST_SRG', title: 'Test SRG', version: 'V1R1', release_date: '2024-01-01', srg_rules: [ { id: 1, rule_id: 'SRG-001', version: 'V1R1', title: 'Rule 1' } ] }; it('applies srgToBenchmark adapter', () => { const wrapper = mount(Srg, { propsData: { srg: mockSrg } }); const adapted = wrapper.vm.adaptedSrg; expect(adapted.benchmark_id).toBe('TEST_SRG'); expect(adapted.date).toBe('2024-01-01'); expect(adapted.rules).toHaveLength(1); }); it('passes adapted data to BenchmarkViewer', () => { const wrapper = mount(Srg, { propsData: { srg: mockSrg } }); const benchmarkViewer = wrapper.findComponent(BenchmarkViewer); expect(benchmarkViewer.props('benchmark').benchmark_id).toBe('TEST_SRG'); expect(benchmarkViewer.props('type')).toBe('srg'); }); });
-
Implementation:
- Create
app/javascript/components/srgs/Srg.vue - Import srgToBenchmark adapter
- Add computed property:
adaptedSrg - Render BenchmarkViewer with
:benchmark="adaptedSrg"andtype="srg" - Run tests:
yarn test:unit components/srgs/Srg.spec.js - All tests pass ✓
- Create
Manual Testing Checklist:
-
STIG Viewing (
/stigs/:id):- Page loads without errors
- Breadcrumb shows "STIGs > {Title} {Version}"
- Left panel shows rule list with "SRG ID" / "STIG ID" toggle
- Middle panel shows rule details (vuln discussion, check, fix)
- Right panel shows rule overview with:
- Vuln ID (STIG-specific)
- Rule ID
- STIG ID
- SRG ID (STIG-specific)
- Severity badge
- CCI identifiers
- CIS Controls (if present)
- MITRE ATT&CK (if present)
- Clicking rule in list updates details/overview
- Search filters rules
- Severity filters work (High, Medium, Low, All)
- Download button opens export modal
-
SRG Viewing (
/srgs/:id):- Page loads without errors
- Breadcrumb shows "SRGs > {Title} {Version}"
- Left panel shows rule list with "Rule ID" / "Version" toggle
- Middle panel shows rule details
- Right panel shows rule overview with:
- Rule ID
- Version (not labeled "STIG ID")
- Severity badge
- CCI identifiers
- NO Vuln ID field
- NO SRG ID field
- All interactions work same as STIG
-
Terminology:
- All uses of "Rule" come from RULE_TERM constants
- No hardcoded "Rules" strings in components
-
Accessibility:
- Tooltips work on info icons
- Links to CIS Controls and MITRE ATT&CK open in new tab
- Keyboard navigation works
- Screen reader friendly (ARIA labels correct)
-
Delete old files:
- Remove
app/javascript/components/stigs/StigRuleList.vue(moved to shared/RuleList.vue) - Remove
app/javascript/components/stigs/StigRuleDetails.vue(moved to shared/RuleDetails.vue) - Remove
app/javascript/components/stigs/StigRuleOverview.vue(moved to shared/RuleOverview.vue)
- Remove
-
Update imports:
- Search for any remaining imports of old component paths
- Update to new shared/ paths
-
Documentation:
- Update CLAUDE.md with BenchmarkViewer architecture
- Add JSDoc comments to adapter functions
- Update component README if exists
-
Final test run:
# Unit tests yarn test:unit # Lint yarn lint # Full suite bundle exec rspec
For each phase, follow this exact pattern:
-
RED Phase - Write failing tests first
- Write test file before implementation
- Run tests:
yarn test:unit <file> - Tests FAIL (expected) ❌
-
GREEN Phase - Implement minimum code to pass
- Write implementation
- Run tests:
yarn test:unit <file> - Tests PASS ✓
-
REFACTOR Phase - Clean up code
- Improve readability
- Extract duplications
- Run tests: Still PASS ✓
-
COMMIT - Save progress
- Commit test file + implementation together
- Message:
test: Add [component] tests+feat: Implement [component]
DO NOT:
- Write implementation before tests
- Skip test files
- Modify tests just to make them pass
- Move to next phase with failing tests
This design provides a complete, test-driven path to backport v2.3.0's BenchmarkViewer pattern to v2.2.x:
Key Differences from v2.3.0:
- JavaScript adapters instead of TypeScript interfaces
- Vue 2.7 patterns (no
<script setup>) - RULE_TERM constants for terminology
- Simplified state management (no complex composable)
Benefits:
- Reusable components for STIG and SRG viewing
- Single source of truth for adapter logic
- Type-safe (via prop validation, not TypeScript)
- Test-driven implementation (TDD)
- Maintainable and extensible
Implementation Order:
- Adapters (stigToBenchmark, srgToBenchmark)
- Utilities (parseIdents, formatCisControl)
- Shared Components (RuleList, RuleDetails, RuleOverview)
- BenchmarkViewer (state management)
- Page Wrappers (Stig.vue, Srg.vue)
- Integration Testing
- Cleanup
Follow TDD strictly: Tests → Implementation → Refactor → Commit