///|
/// CSPKit is a MoonBit-native Content-Security-Policy parser and auditor.
///
/// It focuses on the security policy text itself, not on HTTP transport. The
/// core API parses a CSP header into structured directives, computes effective
/// fetch-source fallback, reports risky patterns, and compares two policies.
///|
pub(all) enum ParseError {
EmptyPolicy
EmptyDirective
InvalidDirectiveName(name~ : String)
InvalidDirectiveValue(value~ : String)
} derive(Eq, Debug)
///|
pub(all) enum Severity {
Info
Warning
High
} derive(Eq, Debug)
///|
pub(all) struct Directive {
name : String
values : Array[String]
order : Int
} derive(Eq, Debug)
///|
pub(all) struct Policy {
directives : Array[Directive]
} derive(Eq, Debug)
///|
pub(all) struct Finding {
code : String
severity : Severity
directive : String?
message : String
} derive(Eq, Debug)
///|
pub(all) struct DirectiveChange {
name : String
before : Array[String]
after : Array[String]
} derive(Eq, Debug)
///|
pub(all) struct PolicyDiff {
added : Array[Directive]
removed : Array[Directive]
changed : Array[DirectiveChange]
} derive(Eq, Debug)
///|
let ch_space = 32
///|
let ch_tab = 9
///|
let ch_lf = 10
///|
let ch_cr = 13
///|
let ch_semicolon = 59
///|
fn code_at(input : String, index : Int) -> Int {
input.unsafe_get(index).to_int()
}
///|
fn trim_csp(input : StringView) -> String {
input.trim(chars=" \t\n\r").to_owned()
}
///|
fn lower_ascii(input : StringView) -> String {
let buf = StringBuilder(size_hint=input.length())
for c in input {
buf.write_char(c.to_ascii_lowercase())
}
buf.to_string()
}
///|
fn is_alpha(n : Int) -> Bool {
(n >= 65 && n <= 90) || (n >= 97 && n <= 122)
}
///|
fn is_digit(n : Int) -> Bool {
n >= 48 && n <= 57
}
///|
fn is_ws(n : Int) -> Bool {
n == ch_space || n == ch_tab || n == ch_lf || n == ch_cr
}
///|
fn is_directive_name_char(n : Int) -> Bool {
is_alpha(n) || is_digit(n) || n == 45
}
///|
fn is_visible_ascii(n : Int) -> Bool {
n >= 33 && n <= 126
}
///|
fn starts_with(input : String, prefix : String) -> Bool {
prefix.length() <= input.length() &&
input[0:prefix.length()].to_owned() == prefix
}
///|
fn split_directives(input : String) -> Array[String] {
let parts : Array[String] = []
let mut start = 0
for i in 0.. Array[String] {
let tokens : Array[String] = []
let mut start = -1
for i in 0..= 0 {
tokens.push(input[start:i].to_owned())
start = -1
}
} else if start < 0 {
start = i
}
}
if start >= 0 {
tokens.push(input[start:input.length()].to_owned())
}
tokens
}
///|
fn normalize_directive_name(raw : StringView) -> Result[String, ParseError] {
let name = lower_ascii(raw.trim(chars=" \t\n\r"))
guard name.length() > 0 else { return Err(EmptyDirective) }
for i in 0.. Result[String, ParseError] {
guard value.length() > 0 else { return Err(InvalidDirectiveValue(value~)) }
for i in 0.. Result[Directive, ParseError] {
let segment = trim_csp(raw[:])
guard segment.length() > 0 else { return Err(EmptyDirective) }
let tokens = split_ws(segment)
guard tokens.length() > 0 else { return Err(EmptyDirective) }
let name = match normalize_directive_name(tokens[0][:]) {
Ok(name) => name
Err(err) => return Err(err)
}
let values : Array[String] = []
for i in 1.. values.push(value)
Err(err) => return Err(err)
}
}
Ok({ name, values, order })
}
///|
pub fn parse_policy(input : StringView) -> Result[Policy, ParseError] {
let raw = trim_csp(input)
guard raw.length() > 0 else { return Err(EmptyPolicy) }
let directives : Array[Directive] = []
for part in split_directives(raw) {
let segment = trim_csp(part[:])
if segment.length() > 0 {
match parse_directive(segment, directives.length()) {
Ok(directive) => directives.push(directive)
Err(err) => return Err(err)
}
}
}
guard directives.length() > 0 else { return Err(EmptyPolicy) }
Ok({ directives, })
}
///|
pub fn Policy::directive(self : Policy, name : StringView) -> Directive? {
let key = lower_ascii(name)
for directive in self.directives {
if directive.name == key {
return Some(directive)
}
}
None
}
///|
pub fn Policy::directives_named(
self : Policy,
name : StringView,
) -> Array[Directive] {
let key = lower_ascii(name)
let matches : Array[Directive] = []
for directive in self.directives {
if directive.name == key {
matches.push(directive)
}
}
matches
}
///|
pub fn Policy::contains(self : Policy, name : StringView) -> Bool {
self.directive(name) is Some(_)
}
///|
pub fn Directive::contains(self : Directive, value : StringView) -> Bool {
let key = lower_ascii(value)
for item in self.values {
if lower_ascii(item[:]) == key {
return true
}
}
false
}
///|
fn is_fetch_directive(name : String) -> Bool {
name == "child-src" ||
name == "connect-src" ||
name == "default-src" ||
name == "font-src" ||
name == "frame-src" ||
name == "img-src" ||
name == "manifest-src" ||
name == "media-src" ||
name == "object-src" ||
name == "prefetch-src" ||
name == "script-src" ||
name == "script-src-elem" ||
name == "script-src-attr" ||
name == "style-src" ||
name == "style-src-elem" ||
name == "style-src-attr" ||
name == "worker-src"
}
///|
pub fn Policy::effective_sources(
self : Policy,
name : StringView,
) -> Array[String]? {
let key = lower_ascii(name)
match self.directive(key[:]) {
Some(directive) => Some(directive.values)
None =>
if is_fetch_directive(key) {
match self.directive("default-src") {
Some(default_src) => Some(default_src.values)
None => None
}
} else {
None
}
}
}
///|
fn finding(
code : String,
severity : Severity,
directive : String?,
message : String,
) -> Finding {
{ code, severity, directive, message }
}
///|
fn add_if_missing(
findings : Array[Finding],
policy : Policy,
name : String,
severity : Severity,
message : String,
) -> Unit {
if !policy.contains(name[:]) {
findings.push(finding("missing-" + name, severity, Some(name), message))
}
}
///|
fn has_more_than_one(values : Array[String]) -> Bool {
values.length() > 1
}
///|
fn audit_directive_values(
findings : Array[Finding],
directive : Directive,
) -> Unit {
if directive.contains("'none'") && has_more_than_one(directive.values) {
findings.push(
finding(
"none-with-other-sources",
Warning,
Some(directive.name),
"'none' should not be mixed with other source expressions.",
),
)
}
for value in directive.values {
let lower = lower_ascii(value[:])
if lower == "*" {
findings.push(
finding(
"wildcard-source",
High,
Some(directive.name),
"Wildcard source allows any origin for this directive.",
),
)
}
if lower == "'unsafe-inline'" {
let severity = if starts_with(directive.name, "script-src") {
High
} else {
Warning
}
findings.push(
finding(
"unsafe-inline",
severity,
Some(directive.name),
"'unsafe-inline' weakens injection protection.",
),
)
}
if lower == "'unsafe-eval'" || lower == "'wasm-unsafe-eval'" {
findings.push(
finding(
"unsafe-eval",
High,
Some(directive.name),
"Dynamic code evaluation is allowed.",
),
)
}
if lower == "data:" &&
(
starts_with(directive.name, "script-src") ||
directive.name == "object-src" ||
directive.name == "default-src"
) {
findings.push(
finding(
"data-source-sensitive-context",
High,
Some(directive.name),
"data: is risky for script, object, or default sources.",
),
)
}
if lower == "http:" || starts_with(lower, "http://") {
findings.push(
finding(
"insecure-http-source",
Warning,
Some(directive.name),
"Plain HTTP source can be modified in transit.",
),
)
}
if starts_with(lower, "'nonce-") ||
starts_with(lower, "'sha256-") ||
starts_with(lower, "'sha384-") ||
starts_with(lower, "'sha512-") {
findings.push(
finding(
"strict-source-token",
Info,
Some(directive.name),
"Nonce or hash source expression is present.",
),
)
}
}
}
///|
fn audit_duplicates(policy : Policy, findings : Array[Finding]) -> Unit {
for i in 0.. Array[Finding] {
let findings : Array[Finding] = []
audit_duplicates(self, findings)
add_if_missing(
findings,
self,
"default-src",
High,
"default-src is the baseline fallback for fetch directives.",
)
add_if_missing(
findings,
self,
"object-src",
Warning,
"object-src 'none' is recommended for modern web applications.",
)
add_if_missing(
findings,
self,
"base-uri",
Warning,
"base-uri limits attacker-controlled base tag injection.",
)
add_if_missing(
findings,
self,
"frame-ancestors",
Warning,
"frame-ancestors protects against unwanted embedding.",
)
for directive in self.directives {
audit_directive_values(findings, directive)
}
findings
}
///|
fn values_equal(left : Array[String], right : Array[String]) -> Bool {
guard left.length() == right.length() else { return false }
for i in 0.. PolicyDiff {
let added : Array[Directive] = []
let removed : Array[Directive] = []
let changed : Array[DirectiveChange] = []
for new_directive in after.directives {
match before.directive(new_directive.name[:]) {
None => added.push(new_directive)
Some(old_directive) =>
if !values_equal(old_directive.values, new_directive.values) {
changed.push({
name: new_directive.name,
before: old_directive.values,
after: new_directive.values,
})
}
}
}
for old_directive in before.directives {
if after.directive(old_directive.name[:]) is None {
removed.push(old_directive)
}
}
{ added, removed, changed }
}
///|
pub fn Directive::to_header(self : Directive) -> String {
if self.values.length() == 0 {
self.name
} else {
self.name + " " + self.values.join(" ")
}
}
///|
pub fn Policy::to_header(self : Policy) -> String {
self.directives.map(directive => directive.to_header()).join("; ")
}
///|
pub fn Finding::is_blocking(self : Finding) -> Bool {
self.severity == High
}
///|
pub fn Policy::blocking_findings(self : Policy) -> Array[Finding] {
self.audit().filter(f => f.is_blocking())
}