Security Scoring Algorithm#
The Security Scoring Algorithm is a penalty-based security assessment system in opnDossier that evaluates OPNsense firewall configurations and produces a standardized 0-100 security score. Implemented in the internal/converter/formatters/security.go file, the algorithm begins with a base score of 100 points and applies specific penalties for identified security issues including missing firewall rules, exposed management interfaces, insecure system tunables, and default user accounts.
The algorithm is designed with conservative heuristics for audit readability and operates completely offline with no external dependencies, making it suitable for air-gapped environments. The security scoring functions are standalone functions in the formatters package, with MarkdownBuilder delegating to them for report generation.
Beyond scoring, the algorithm provides complementary risk assessment functions that convert severity strings to emoji-labeled risk levels and map service names to security risk classifications, enabling consistent security posture communication across different report formats and audiences.
Core Algorithm: CalculateSecurityScore()#
The CalculateSecurityScore() function is the primary entry point for security assessment, computing an overall score between 0 and 100 based on configuration analysis.
Algorithm Flow#
func CalculateSecurityScore(data *common.CommonDevice) int {
if data == nil {
return 0
}
score := initialSecurityScore // 100
// Apply penalty for missing firewall rules
if len(data.FirewallRules) == 0 {
score -= firewallMissingPenalty // -20
}
// Apply penalty for management ports exposed on WAN
if hasManagementOnWAN(data) {
score -= managementOnWANPenalty // -30
}
// Check security-critical sysctl tunables
securityTunables := map[string]string{
"net.inet.ip.forwarding": "0",
"net.inet6.ip6.forwarding": "0",
"net.inet.tcp.blackhole": "2",
"net.inet.udp.blackhole": "1",
}
for tunable, expected := range securityTunables {
if !checkTunable(data.Sysctl, tunable, expected) {
score -= insecureTunablePenalty // -5 per tunable
}
}
// Check for default user accounts
for _, user := range data.Users {
if isDefaultUser(user) {
score -= defaultUserPenalty // -15 per default user
}
}
// Clamp score to valid range [0, 100]
if score < 0 {
score = 0
}
if score > maxSecurityScore {
score = maxSecurityScore
}
return score
}
The algorithm uses a penalty-based approach starting from 100 points, with penalties defined as constants. The score is clamped between 0 and 100 to ensure consistent ranges, and a nil document returns a score of 0.
Penalty System and Rules#
The security scoring algorithm applies penalties defined as constants for specific security misconfigurations:
| Security Issue | Penalty Points | Constant Name | Rationale |
|---|---|---|---|
| No Firewall Rules | -20 | firewallMissingPenalty | Missing basic firewall protection leaves the system completely exposed |
| Management on WAN | -30 | managementOnWANPenalty | Most severe penalty; management ports exposed to internet |
| Insecure Sysctl Setting | -5 each | insecureTunablePenalty | Per misconfigured system tunable |
| Default User Account | -15 each | defaultUserPenalty | Per default system account (admin, root, user) |
Penalty Accumulation#
Penalties accumulate additively. For example, a configuration with:
- No firewall rules (-20)
- Management on WAN (-30)
- 2 insecure sysctl settings (-10)
- 1 default user (-15)
Would receive a score of: 100 - 20 - 30 - 10 - 15 = 25
The score is clamped to ensure it never falls below 0 or exceeds 100, even with extreme penalty accumulation.
Test Coverage#
The algorithm includes comprehensive test coverage demonstrating each penalty type:
Perfect Security Score (100 points):
data := &common.CommonDevice{
FirewallRules: []common.FirewallRule{{Type: "pass"}},
Users: []common.User{},
Sysctl: []common.SysctlItem{
{Tunable: "net.inet.ip.forwarding", Value: "0"},
{Tunable: "net.inet6.ip6.forwarding", Value: "0"},
{Tunable: "net.inet.tcp.blackhole", Value: "2"},
{Tunable: "net.inet.udp.blackhole", Value: "1"},
},
}
// CalculateSecurityScore(data) returns 100
Multiple Penalties Combined:
data := &common.CommonDevice{
FirewallRules: []common.FirewallRule{}, // -20
Users: []common.User{
{Name: "admin"}, // -15
{Name: "root"}, // -15
},
Sysctl: []common.SysctlItem{
{Tunable: "net.inet.ip.forwarding", Value: "1"}, // Wrong: -5
{Tunable: "net.inet6.ip6.forwarding", Value: "1"}, // Wrong: -5
// Missing 2 other tunables: -10 (2 × 5)
},
}
// CalculateSecurityScore(data) returns 30
Risk Assessment Functions#
Beyond numerical scoring, the security module provides two complementary functions for qualitative risk assessment.
AssessRiskLevel()#
The AssessRiskLevel() function converts severity strings to consistent emoji + text labels:
func AssessRiskLevel(severity string) string {
switch strings.ToLower(strings.TrimSpace(severity)) {
case "critical":
return "🔴 Critical Risk"
case "high":
return "🟠 High Risk"
case "medium":
return "🟡 Medium Risk"
case "low":
return "🟢 Low Risk"
case "info", "informational":
return "ℹ️ Informational"
default:
return "⚪ Unknown Risk"
}
}
Severity Mappings#
| Input Severity | Output Label | Use Case |
|---|---|---|
critical | 🔴 Critical Risk | Immediate attention required |
high | 🟠 High Risk | High priority security concern |
medium | 🟡 Medium Risk | Moderate security concern |
low | 🟢 Low Risk | Low priority security issue |
info, informational | ℹ️ Informational | Informational finding |
| Other | ⚪ Unknown Risk | Unrecognized severity level |
The function performs case-insensitive matching with whitespace trimming, ensuring consistent handling of input variations.
AssessServiceRisk()#
The AssessServiceRisk() function maps service names to risk levels based on security implications:
func AssessServiceRisk(serviceName string) string {
riskServices := map[string]string{
"telnet": "critical",
"ftp": "high",
"vnc": "high",
"rdp": "medium",
"ssh": "low",
"https": "info",
}
name := strings.ToLower(serviceName)
for pattern, risk := range riskServices {
if strings.Contains(name, pattern) {
return AssessRiskLevel(risk)
}
}
return AssessRiskLevel("info")
}
Service Risk Mappings#
| Service Pattern | Risk Level | Label | Rationale |
|---|---|---|---|
telnet | critical | 🔴 Critical Risk | Unencrypted remote access protocol |
ftp | high | 🟠 High Risk | Unencrypted file transfer protocol |
vnc | high | 🟠 High Risk | Remote desktop with potential vulnerabilities |
rdp | medium | 🟡 Medium Risk | Remote desktop protocol with authentication risks |
ssh | low | 🟢 Low Risk | Secure shell with proper authentication |
https | info | ℹ️ Informational | Secure web services |
| Unknown/Custom | info | ℹ️ Informational | Services not in risk database |
The function uses substring matching (case-insensitive), so "openssh" matches "ssh", and "TELNET" matches "telnet".
Management Port Detection#
The hasManagementOnWAN() function detects when management interfaces are exposed on the WAN interface, triggering the most severe penalty (-30 points).
Detection Logic#
func hasManagementOnWAN(data *common.CommonDevice) bool {
mgmtPorts := []string{"443", "80", "22", "8080"}
for _, rule := range data.FirewallRules {
// Check if rule applies to WAN interface
if !slices.ContainsFunc(rule.Interfaces, func(s string) bool {
return strings.EqualFold(s, "wan")
}) {
continue
}
// Check direction (empty = inbound by default)
if rule.Direction != "" && !strings.EqualFold(rule.Direction, "in") {
continue
}
// Check for management ports
for _, port := range mgmtPorts {
if strings.Contains(rule.Destination.Port, port) {
return true
}
}
}
return false
}
Management Ports#
The management ports are defined as:
| Port | Service | Purpose |
|---|---|---|
| 22 | SSH | Secure Shell remote access |
| 80 | HTTP | Web GUI (unencrypted) |
| 443 | HTTPS | Web GUI (encrypted) |
| 8080 | Alternative HTTP | Common alternative web admin port |
Detection Criteria#
A rule triggers the management exposure penalty when ALL of the following are true:
- Interface Check: Rule applies to an interface named "wan" (case-insensitive)
- Direction Check:
- If
Directionis empty → treated as inbound (rule is flagged) - If
Directionis "in" (case-insensitive) → rule is flagged - If
Directionis "out" → rule is skipped
- If
- Port Check: Uses substring matching on
rule.Destination.Portto find management ports
Conservative Security Approach#
The function uses a conservative security approach:
- Empty direction defaults to inbound: Rules without explicit direction are treated as potentially exposing management interfaces
- Substring matching catches ranges: Port specifications like "range:80-90" correctly match port 80
- Case-insensitive interface matching: Handles variations like "WAN", "wan", "Wan"
Sysctl Security Checks#
The security algorithm evaluates exactly 4 security-critical sysctl tunables, applying a -5 point penalty for each misconfigured or missing tunable.
Security-Critical Tunables#
| Tunable | Expected Value | Security Impact |
|---|---|---|
net.inet.ip.forwarding | 0 | Prevents IPv4 forwarding unless explicitly needed for routing |
net.inet6.ip6.forwarding | 0 | Prevents IPv6 forwarding unless explicitly needed for routing |
net.inet.tcp.blackhole | 2 | Drops TCP packets to closed ports silently (prevents port scanning) |
net.inet.udp.blackhole | 1 | Drops UDP packets to closed ports silently (prevents port scanning) |
Validation Logic#
The checkTunable() helper function validates sysctl settings:
func checkTunable(tunables []common.SysctlItem, name, expected string) bool {
for _, tunable := range tunables {
if tunable.Tunable == name {
return tunable.Value == expected
}
}
return false // Missing tunable treated as misconfigured
}
Key Behaviors:
- Exact string matching: Values must match exactly (e.g., "2" ≠ "02")
- Missing tunables penalized: If a tunable is not present in the configuration, it's treated as misconfigured
- All-or-nothing per tunable: Each tunable is either correct (no penalty) or incorrect/missing (-5 points)
Blackhole Settings Explained#
The TCP and UDP blackhole settings are particularly important for security:
TCP Blackhole (net.inet.tcp.blackhole = 2):
- Value
2: Drop SYN packets to closed ports without sending RST responses - Prevents port scanning by not revealing which ports are closed
- Makes the firewall "stealthier" to attackers
UDP Blackhole (net.inet.udp.blackhole = 1):
- Value
1: Drop UDP packets to closed ports without sending ICMP Port Unreachable - Prevents UDP port scanning and reduces visibility to attackers
- Reduces network noise from scanning attempts
Default User Account Detection#
The isDefaultUser() function identifies default user accounts, applying a -15 point penalty for each detected account.
Detection Logic#
func isDefaultUser(u common.User) bool {
switch strings.ToLower(u.Name) {
case "admin", "root", "user":
return true
default:
return false
}
}
Default User Accounts#
The algorithm checks for exactly 3 default user names (case-insensitive):
| User Name | Security Risk | Rationale |
|---|---|---|
admin | Well-known default administrative account | Commonly targeted in brute-force attacks |
root | Unix/BSD superuser account | Ultimate privilege; should be renamed or disabled |
user | Generic default account name | Low-entropy username makes brute-forcing easier |
Rationale#
Default user accounts represent a significant security risk because:
- Predictable usernames: Attackers know to target these accounts
- Half of authentication: With the username known, attackers only need to guess the password
- Common in factory configurations: Many systems ship with these accounts enabled
- Brute-force target: Well-known accounts are prime targets for automated attacks
Best Practices#
The algorithm encourages administrators to:
- Rename default accounts to non-obvious usernames
- Disable unused default accounts entirely
- Create custom administrative accounts with unique names
- Implement strong authentication mechanisms (SSH keys, 2FA)
Implementation Architecture#
Design Philosophy#
The security scoring functions are implemented as standalone functions in the formatters package, with MarkdownBuilder delegating to them. This design ensures:
- Offline Operation: All functions operate completely offline with no external dependencies, making them suitable for air-gapped environments
- Conservative Heuristics: Designed for audit readability with intentionally conservative penalties to avoid false positives
- Type Safety: Direct Go method calls provide compile-time guarantees
- Reusability: Standalone functions can be used independently of report generation
Public API#
The security functions are exposed through the public programmatic API:
// Calculate an overall security score (0-100)
formatters.CalculateSecurityScore(data *common.CommonDevice) int
// Convert severity strings to risk level labels
formatters.AssessRiskLevel(severity string) string
// Evaluate security risk for a named service
formatters.AssessServiceRisk(serviceName string) string
// Filter tunables (true = include all, false = security-relevant only)
formatters.FilterSystemTunables(tunables []common.SysctlItem, includeTunables bool) []common.SysctlItem
MarkdownBuilder Integration#
The MarkdownBuilder provides convenience methods that delegate to the formatters package:
// Convenience methods (delegate to formatters)
builder.AssessRiskLevel("high") // → formatters.AssessRiskLevel("high")
builder.CalculateSecurityScore(doc) // → formatters.CalculateSecurityScore(doc)
builder.AssessServiceRisk(service) // → formatters.AssessServiceRisk(service)
Data Structures#
The security functions operate on common.CommonDevice, which contains:
type CommonDevice struct {
FirewallRules []FirewallRule // Array of firewall filter rules
Users []User // System user accounts
Sysctl []SysctlItem // Kernel tunable parameters
// ... other fields
}
Supporting structures:
- FirewallRule: Includes
Interfaces,Direction,Destination.Port - User: Includes
Name - SysctlItem: Includes
Tunable,Value
Integration with Report Generation#
The security scoring functions integrate with report generation in three ways:
Blue Team Reports:
- Focus on clarity, grouping, and actionability
- Include compliance matrices and remediation guidance
- Highlight security features and vulnerabilities
Red Team Reports:
- Focus on target prioritization and pivot surface discovery
- Emphasize attack vectors and exposure points
- Highlight management interfaces and weak configurations
Standard Reports:
- Balanced view of configuration security posture
- Include both security strengths and areas for improvement
- Provide clear recommendations for security hardening
Usage Examples#
Basic Security Scoring#
package main
import (
"fmt"
"github.com/EvilBit-Labs/opnDossier/internal/converter/formatters"
"github.com/EvilBit-Labs/opnDossier/internal/model/common"
)
func main() {
// Load or create configuration data
config := &common.CommonDevice{
FirewallRules: []common.FirewallRule{
{Type: "block", Interfaces: []string{"lan"}},
},
Users: []common.User{
{Name: "admin"}, // Default user: -15
},
Sysctl: []common.SysctlItem{
{Tunable: "net.inet.ip.forwarding", Value: "0"},
{Tunable: "net.inet6.ip6.forwarding", Value: "0"},
{Tunable: "net.inet.tcp.blackhole", Value: "2"},
{Tunable: "net.inet.udp.blackhole", Value: "1"},
},
}
// Calculate security score
score := formatters.CalculateSecurityScore(config)
fmt.Printf("Security Score: %d/100\n", score) // Output: 85/100
}
Risk Level Assessment#
// Assess severity levels
criticalRisk := formatters.AssessRiskLevel("critical")
fmt.Println(criticalRisk) // Output: 🔴 Critical Risk
highRisk := formatters.AssessRiskLevel("HIGH") // Case insensitive
fmt.Println(highRisk) // Output: 🟠 High Risk
unknownRisk := formatters.AssessRiskLevel("custom-severity")
fmt.Println(unknownRisk) // Output: ⚪ Unknown Risk
Service Risk Assessment#
// Assess service security risks
telnetRisk := formatters.AssessServiceRisk("telnet")
fmt.Println(telnetRisk) // Output: 🔴 Critical Risk
sshRisk := formatters.AssessServiceRisk("openssh") // Pattern matching
fmt.Println(sshRisk) // Output: 🟢 Low Risk
customRisk := formatters.AssessServiceRisk("my-custom-service")
fmt.Println(customRisk) // Output: ℹ️ Informational
Integration with MarkdownBuilder#
import (
"github.com/EvilBit-Labs/opnDossier/internal/converter"
)
func generateSecurityReport(config *common.CommonDevice) string {
builder := converter.NewMarkdownBuilder()
// Calculate and display security score
score := builder.CalculateSecurityScore(config)
builder.AddSection("Security Assessment", fmt.Sprintf(
"Overall Security Score: **%d/100**\n\n", score))
// Add risk assessments
for _, service := range config.Services {
risk := builder.AssessServiceRisk(service.Name)
builder.AddText(fmt.Sprintf("- %s: %s\n", service.Name, risk))
}
return builder.String()
}
Testing Security Configurations#
Test examples from the test suite:
func TestSecurityScore_NoFirewallRules(t *testing.T) {
config := &common.CommonDevice{
FirewallRules: []common.FirewallRule{}, // Empty: -20
Users: []common.User{},
Sysctl: []common.SysctlItem{}, // Missing all 4: -20
}
score := formatters.CalculateSecurityScore(config)
assert.Equal(t, 60, score) // 100 - 20 - 20 = 60
}
func TestSecurityScore_ManagementOnWAN(t *testing.T) {
config := &common.CommonDevice{
FirewallRules: []common.FirewallRule{
{
Interfaces: []string{"wan"},
Direction: "in",
Destination: common.RuleEndpoint{Port: "443"}, // HTTPS on WAN: -30
},
},
Users: []common.User{},
Sysctl: []common.SysctlItem{}, // Missing all 4: -20
}
score := formatters.CalculateSecurityScore(config)
assert.Equal(t, 50, score) // 100 - 30 - 20 = 50
}
Relevant Code Files#
| File Path | Purpose | Key Components |
|---|---|---|
internal/converter/formatters/security.go | Core security scoring implementation | CalculateSecurityScore(), AssessRiskLevel(), AssessServiceRisk(), hasManagementOnWAN(), checkTunable(), isDefaultUser() |
internal/converter/formatters/security_test.go | Comprehensive unit tests | 69+ test cases covering all penalty types and edge cases |
docs/user-guide/security-scoring.md | User-facing documentation | Algorithm explanation, penalty rules, usage examples |
internal/converter/builder/helpers.go | MarkdownBuilder delegation | Convenience methods that delegate to formatters |
internal/model/common/device.go | Data structures | CommonDevice type with FirewallRules, Users, Sysctl |
internal/model/common/firewall.go | Firewall data structures | FirewallRule with Interfaces, Direction, Destination |
internal/model/common/users.go | User data structures | User type with Name field |
internal/model/common/system.go | System configuration structures | SysctlItem with Tunable, Value |
internal/converter/markdown_security_test.go | Integration tests | MarkdownBuilder security function integration |
internal/converter/testdata/complete.json | Test fixtures | Example configuration with security settings |
Related Topics#
Security Assessment in opnDossier#
The Security Scoring Algorithm is part of a broader security assessment framework in opnDossier:
- Report Generation: The security functions integrate with MarkdownBuilder programmatic generation architecture for type-safe, compile-time guaranteed report generation
- Multi-Audience Reporting: Supports Blue Team (compliance-focused), Red Team (attack surface-focused), and Standard (balanced) report formats
- Configuration Analysis: Security scoring is one component of comprehensive OPNsense configuration analysis
OPNsense Configuration#
The algorithm analyzes OPNsense firewall configurations:
- Firewall Rules: Evaluates presence and configuration of packet filtering rules
- System Tunables (sysctl): Validates kernel-level security settings
- User Management: Identifies security risks in user account configuration
- Network Interfaces: Detects security-sensitive configurations on WAN interfaces
Compliance and Hardening#
The scoring algorithm supports security hardening and compliance efforts:
- Standardized Scoring: Provides consistent 0-100 scale for comparing configurations
- Audit Readability: Conservative heuristics designed for clear audit trails
- Actionable Findings: Each penalty corresponds to a specific, remediable security issue
- Offline Assessment: No external dependencies enables use in secure/air-gapped environments