///|
/// One generated HTTP security header.
pub struct SecurityHeaderEntry {
name : String
value : String
} derive(Eq, @debug.Debug)
///|
/// A reusable security-header bundle for a deployment scenario.
pub struct SecurityHeaderBundle {
name : String
description : String
document_origin : String
headers : Array[SecurityHeaderEntry]
notes : Array[String]
} derive(Eq, @debug.Debug)
///|
/// Coverage summary for the headers audited by `permscope`.
pub struct SecurityCoverage {
required : Int
present : Int
strong : Int
missing : Array[String]
weak : Array[String]
} derive(Eq, @debug.Debug)
///|
/// One score change between two security audits.
pub struct SecurityRegression {
header : String
before_score : Int
after_score : Int
delta : Int
severity : String
message : String
} derive(Eq, @debug.Debug)
///|
/// CI-style gate result for a security audit.
pub struct SecurityGate {
ok : Bool
required_grade : String
actual_grade : String
allow_warnings : Bool
messages : Array[String]
} derive(Eq, @debug.Debug)
///|
/// Create a header entry.
pub fn security_header_entry(
name : String,
value : String,
) -> SecurityHeaderEntry {
{ name, value }
}
///|
/// Strict browser-facing bundle for high-sensitivity apps.
pub fn strict_security_bundle(document_origin : String) -> SecurityHeaderBundle {
{
name: "strict",
description: "Strict browser-facing defaults for public production apps",
document_origin,
headers: [
security_header_entry("Content-Security-Policy", strict_csp_header()),
security_header_entry(
"Strict-Transport-Security", "max-age=31536000; includeSubDomains; preload",
),
security_header_entry("Permissions-Policy", recommended_header()),
security_header_entry(
"Referrer-Policy", "strict-origin-when-cross-origin",
),
security_header_entry("X-Frame-Options", "DENY"),
security_header_entry("X-Content-Type-Options", "nosniff"),
security_header_entry("Cross-Origin-Opener-Policy", "same-origin"),
security_header_entry("Cross-Origin-Embedder-Policy", "require-corp"),
security_header_entry("Cross-Origin-Resource-Policy", "same-origin"),
],
notes: [
"Use for applications that control all scripts and subresources.", "COEP may require third-party resources to send CORP or CORS headers.",
"Frame embedding is disabled by CSP and X-Frame-Options.",
],
}
}
///|
/// Practical static-site bundle.
pub fn static_site_security_bundle(
document_origin : String,
) -> SecurityHeaderBundle {
{
name: "static-site",
description: "Balanced defaults for documentation and static sites",
document_origin,
headers: [
security_header_entry("Content-Security-Policy", strict_csp_header()),
security_header_entry(
"Strict-Transport-Security", "max-age=31536000; includeSubDomains",
),
security_header_entry("Permissions-Policy", recommended_header()),
security_header_entry(
"Referrer-Policy", "strict-origin-when-cross-origin",
),
security_header_entry("X-Frame-Options", "DENY"),
security_header_entry("X-Content-Type-Options", "nosniff"),
security_header_entry("Cross-Origin-Opener-Policy", "same-origin"),
security_header_entry("Cross-Origin-Resource-Policy", "same-origin"),
],
notes: [
"Preload is omitted so site owners can opt in deliberately.", "COEP is omitted because many static sites embed third-party assets.",
],
}
}
///|
/// API service bundle for JSON endpoints and machine clients.
pub fn api_service_security_bundle(
document_origin : String,
) -> SecurityHeaderBundle {
{
name: "api-service",
description: "Security headers for JSON APIs and service endpoints",
document_origin,
headers: [
security_header_entry(
"Content-Security-Policy", "default-src 'none'; script-src 'none'; style-src 'none'; img-src 'none'; connect-src 'none'; object-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'",
),
security_header_entry(
"Strict-Transport-Security", "max-age=31536000; includeSubDomains; preload",
),
security_header_entry("Permissions-Policy", recommended_header()),
security_header_entry("Referrer-Policy", "no-referrer"),
security_header_entry("X-Frame-Options", "DENY"),
security_header_entry("X-Content-Type-Options", "nosniff"),
security_header_entry("Cross-Origin-Opener-Policy", "same-origin"),
security_header_entry("Cross-Origin-Resource-Policy", "same-origin"),
],
notes: [
"CSP is intentionally restrictive because API responses should not execute.",
"Referrer data is disabled for machine-readable endpoints.",
],
}
}
///|
/// Media app bundle with trusted API and media origins.
pub fn media_security_bundle(
document_origin : String,
api_origins : Array[String],
media_origins : Array[String],
) -> SecurityHeaderBundle {
{
name: "media-app",
description: "Headers for media-heavy applications with trusted origins",
document_origin,
headers: [
security_header_entry(
"Content-Security-Policy",
app_csp_header(api_origins, media_origins),
),
security_header_entry(
"Strict-Transport-Security", "max-age=31536000; includeSubDomains",
),
security_header_entry(
"Permissions-Policy",
profile_header(media_app_profile(), media_origins),
),
security_header_entry(
"Referrer-Policy", "strict-origin-when-cross-origin",
),
security_header_entry("X-Frame-Options", "SAMEORIGIN"),
security_header_entry("X-Content-Type-Options", "nosniff"),
security_header_entry("Cross-Origin-Opener-Policy", "same-origin"),
security_header_entry("Cross-Origin-Resource-Policy", "same-site"),
],
notes: [
"Media origins are allowed in CSP img-src and in the media profile.", "X-Frame-Options is SAMEORIGIN to allow same-site media shells.",
],
}
}
///|
/// Hardware lab bundle for WebUSB, WebHID, Bluetooth, and serial demos.
pub fn device_lab_security_bundle(
document_origin : String,
trusted_device_origins : Array[String],
) -> SecurityHeaderBundle {
{
name: "device-lab",
description: "Headers for trusted hardware demo labs",
document_origin,
headers: [
security_header_entry("Content-Security-Policy", strict_csp_header()),
security_header_entry(
"Strict-Transport-Security", "max-age=31536000; includeSubDomains",
),
security_header_entry(
"Permissions-Policy",
profile_header(device_lab_profile(), trusted_device_origins),
),
security_header_entry(
"Referrer-Policy", "strict-origin-when-cross-origin",
),
security_header_entry("X-Frame-Options", "DENY"),
security_header_entry("X-Content-Type-Options", "nosniff"),
security_header_entry("Cross-Origin-Opener-Policy", "same-origin"),
security_header_entry("Cross-Origin-Embedder-Policy", "require-corp"),
security_header_entry("Cross-Origin-Resource-Policy", "same-origin"),
],
notes: [
"Device capabilities are delegated only to explicitly trusted origins.", "COOP and COEP help isolate device demo pages from unrelated contexts.",
],
}
}
///|
/// Return all built-in bundle names.
pub fn security_bundle_names() -> Array[String] {
["strict", "static-site", "api-service", "media-app", "device-lab"]
}
///|
/// Return the first value for a header in a bundle.
pub fn bundle_header_value(
bundle : SecurityHeaderBundle,
name : String,
) -> String? {
let wanted = name.to_lower()
for header in bundle.headers {
if header.name.to_lower() == wanted {
return Some(header.value)
}
}
None
}
///|
/// Render a header bundle as an HTTP response header block.
pub fn render_security_bundle(bundle : SecurityHeaderBundle) -> String {
let lines : Array[String] = []
lines.push("HTTP/2 200")
for header in bundle.headers {
lines.push(header.name + ": " + header.value)
}
lines.join("\n")
}
///|
/// Render bundle notes for documentation.
pub fn render_security_bundle_notes(bundle : SecurityHeaderBundle) -> String {
let lines : Array[String] = []
lines.push("bundle=" + bundle.name)
lines.push("description=" + bundle.description)
for note in bundle.notes {
lines.push("note=" + note)
}
lines.join("\n")
}
///|
/// Audit a generated bundle.
pub fn audit_security_bundle(bundle : SecurityHeaderBundle) -> SecurityAudit {
audit_security_header_block(
render_security_bundle(bundle),
bundle.document_origin,
)
}
///|
/// Summarize required header coverage.
pub fn security_coverage(audit : SecurityAudit) -> SecurityCoverage {
let missing : Array[String] = []
let weak : Array[String] = []
let mut present = 0
let mut strong = 0
for check in audit.checks {
if check.present {
present = present + 1
} else {
missing.push(check.header)
}
if check.score == check.max_score {
strong = strong + 1
} else if check.present {
weak.push(check.header)
}
}
{ required: audit.checks.length(), present, strong, missing, weak }
}
///|
/// Render header coverage as stable text.
pub fn render_security_coverage(coverage : SecurityCoverage) -> String {
"coverage required=" +
coverage.required.to_string() +
" present=" +
coverage.present.to_string() +
" strong=" +
coverage.strong.to_string() +
" missing=" +
coverage.missing.join("|") +
" weak=" +
coverage.weak.join("|")
}
///|
/// Compare two audits and return score regressions or improvements.
pub fn compare_security_audits(
before : SecurityAudit,
after : SecurityAudit,
) -> Array[SecurityRegression] {
let changes : Array[SecurityRegression] = []
for before_check in before.checks {
match security_check(after, before_check.header) {
None =>
changes.push({
header: before_check.header,
before_score: before_check.score,
after_score: 0,
delta: 0 - before_check.score,
severity: "high",
message: "header disappeared from the later audit",
})
Some(after_check) =>
if before_check.score != after_check.score {
let delta = after_check.score - before_check.score
changes.push({
header: before_check.header,
before_score: before_check.score,
after_score: after_check.score,
delta,
severity: regression_severity(delta, after_check),
message: regression_message(delta, after_check),
})
}
}
}
for after_check in after.checks {
if security_check(before, after_check.header) is None {
changes.push({
header: after_check.header,
before_score: 0,
after_score: after_check.score,
delta: after_check.score,
severity: "info",
message: "new header appears in the later audit",
})
}
}
changes
}
///|
/// Render audit changes as line-oriented text.
pub fn render_security_regressions(
changes : Array[SecurityRegression],
) -> String {
if changes.length() == 0 {
return "permscope security changes: no score changes"
}
let lines : Array[String] = ["permscope security changes:"]
for item in changes {
lines.push(
item.severity +
" " +
item.header +
" " +
item.before_score.to_string() +
"->" +
item.after_score.to_string() +
" delta=" +
item.delta.to_string() +
" - " +
item.message,
)
}
lines.join("\n")
}
///|
/// Build a CI-style gate for a security audit.
pub fn security_gate(
audit : SecurityAudit,
required_grade : String,
allow_warnings : Bool,
) -> SecurityGate {
let messages : Array[String] = []
if !security_grade_at_least(audit, required_grade) {
messages.push(
"grade " +
audit.score.grade +
" is below required " +
required_grade.to_upper(),
)
}
if audit.score.high > 0 {
messages.push("high severity findings are present")
}
if !allow_warnings && audit.score.warning > 0 {
messages.push("warning findings are present")
}
{
ok: messages.length() == 0,
required_grade: required_grade.to_upper(),
actual_grade: audit.score.grade,
allow_warnings,
messages,
}
}
///|
/// Render a CI gate result.
pub fn render_security_gate(gate : SecurityGate) -> String {
let lines : Array[String] = []
if gate.ok {
lines.push("permscope gate: pass")
} else {
lines.push("permscope gate: fail")
}
lines.push("required_grade=" + gate.required_grade)
lines.push("actual_grade=" + gate.actual_grade)
lines.push("allow_warnings=" + gate.allow_warnings.to_string())
for message in gate.messages {
lines.push("message=" + message)
}
lines.join("\n")
}
///|
/// Return the strongest bundle among built-in presets for a simple name.
pub fn named_security_bundle(
name : String,
document_origin : String,
) -> SecurityHeaderBundle {
match name.to_lower() {
"static-site" => static_site_security_bundle(document_origin)
"api-service" => api_service_security_bundle(document_origin)
"media-app" => media_security_bundle(document_origin, [], [])
"device-lab" => device_lab_security_bundle(document_origin, [])
_ => strict_security_bundle(document_origin)
}
}
///|
/// Render a concise one-line audit badge.
pub fn render_security_badge(audit : SecurityAudit) -> String {
"permscope grade " +
audit.score.grade +
" (" +
audit.score.points.to_string() +
"/" +
audit.score.max_points.to_string() +
", high=" +
audit.score.high.to_string() +
", warning=" +
audit.score.warning.to_string() +
")"
}
///|
fn regression_severity(delta : Int, after : HeaderCheck) -> String {
if delta < 0 && header_check_severity(after) == "high" {
"high"
} else if delta < 0 {
"warning"
} else {
"info"
}
}
///|
fn regression_message(delta : Int, after : HeaderCheck) -> String {
if delta < 0 {
"score decreased; current severity is " + header_check_severity(after)
} else {
"score improved"
}
}