///|
/// Deeper Content-Security-Policy source analysis.
///
/// The core parser keeps CSP syntax lightweight. This module adds a second
/// layer that classifies source expressions and explains why a policy is broad
/// or narrow enough for production review.
pub(all) enum CspSourceKind {
CspSourceNone
CspSourceSelf
CspSourceUnsafeInline
CspSourceUnsafeEval
CspSourceStrictDynamic
CspSourceNonce
CspSourceHash
CspSourceHttpsScheme
CspSourceHttpScheme
CspSourceDataScheme
CspSourceBlobScheme
CspSourceFilesystemScheme
CspSourceWildcard
CspSourceHost
CspSourceKeyword
CspSourceUnknown
} derive(Eq, Debug)
///|
pub(all) enum CspDirectiveFamily {
CspFamilyFetch
CspFamilyDocument
CspFamilyNavigation
CspFamilyReporting
CspFamilySandbox
CspFamilyMixedContent
CspFamilyOther
} derive(Eq, Debug)
///|
pub(all) struct CspSourceExpression {
directive : String
raw : String
normalized : String
kind : CspSourceKind
secure_transport : Bool
broad : Bool
note : String
} derive(Eq, Debug)
///|
pub(all) struct CspDirectiveSummary {
name : String
family : CspDirectiveFamily
explicit : Bool
value_count : Int
source_count : Int
has_none : Bool
has_self : Bool
has_wildcard : Bool
has_http : Bool
has_inline : Bool
has_eval : Bool
has_nonce : Bool
has_hash : Bool
notes : Array[String]
} derive(Eq, Debug)
///|
pub(all) struct CspObservation {
id : String
severity : Severity
directive : String
source : String
message : String
recommendation : String
} derive(Eq, Debug)
///|
pub(all) struct CspAnalysis {
score : Int
policy : CspPolicy
observations : Array[CspObservation]
directives : Array[CspDirectiveSummary]
sources : Array[CspSourceExpression]
} derive(Eq, Debug)
///|
pub fn CspSourceKind::label(self : CspSourceKind) -> String {
match self {
CspSourceNone => "none"
CspSourceSelf => "self"
CspSourceUnsafeInline => "unsafe-inline"
CspSourceUnsafeEval => "unsafe-eval"
CspSourceStrictDynamic => "strict-dynamic"
CspSourceNonce => "nonce"
CspSourceHash => "hash"
CspSourceHttpsScheme => "https-scheme"
CspSourceHttpScheme => "http-scheme"
CspSourceDataScheme => "data-scheme"
CspSourceBlobScheme => "blob-scheme"
CspSourceFilesystemScheme => "filesystem-scheme"
CspSourceWildcard => "wildcard"
CspSourceHost => "host"
CspSourceKeyword => "keyword"
CspSourceUnknown => "unknown"
}
}
///|
pub fn CspDirectiveFamily::label(self : CspDirectiveFamily) -> String {
match self {
CspFamilyFetch => "fetch"
CspFamilyDocument => "document"
CspFamilyNavigation => "navigation"
CspFamilyReporting => "reporting"
CspFamilySandbox => "sandbox"
CspFamilyMixedContent => "mixed-content"
CspFamilyOther => "other"
}
}
///|
pub fn csp_source_directives() -> Array[String] {
[
"default-src", "script-src", "script-src-elem", "script-src-attr", "style-src",
"style-src-elem", "style-src-attr", "img-src", "font-src", "connect-src", "media-src",
"object-src", "manifest-src", "worker-src", "child-src", "frame-src", "prefetch-src",
]
}
///|
pub fn csp_document_directives() -> Array[String] {
["base-uri", "sandbox", "plugin-types"]
}
///|
pub fn csp_navigation_directives() -> Array[String] {
["form-action", "frame-ancestors", "navigate-to"]
}
///|
pub fn csp_reporting_directives() -> Array[String] {
["report-uri", "report-to"]
}
///|
pub fn csp_mixed_content_directives() -> Array[String] {
["upgrade-insecure-requests", "block-all-mixed-content"]
}
///|
pub fn csp_all_interesting_directives() -> Array[String] {
let names : Array[String] = []
for name in csp_source_directives() {
names.push(name)
}
for name in csp_document_directives() {
names.push(name)
}
for name in csp_navigation_directives() {
names.push(name)
}
for name in csp_reporting_directives() {
names.push(name)
}
for name in csp_mixed_content_directives() {
names.push(name)
}
names
}
///|
pub fn csp_directive_family(name : StringView) -> CspDirectiveFamily {
let n = csp_normalize_name(name)
if csp_source_directive(n) {
CspFamilyFetch
} else if csp_document_directive(n) {
CspFamilyDocument
} else if csp_navigation_directive(n) {
CspFamilyNavigation
} else if csp_reporting_directive(n) {
CspFamilyReporting
} else if csp_mixed_content_directive(n) {
CspFamilyMixedContent
} else if n == "sandbox" {
CspFamilySandbox
} else {
CspFamilyOther
}
}
///|
pub fn csp_source_directive(name : StringView) -> Bool {
let n = csp_normalize_name(name)
csp_source_directives().any(item => item == n)
}
///|
pub fn csp_document_directive(name : StringView) -> Bool {
let n = csp_normalize_name(name)
csp_document_directives().any(item => item == n)
}
///|
pub fn csp_navigation_directive(name : StringView) -> Bool {
let n = csp_normalize_name(name)
csp_navigation_directives().any(item => item == n)
}
///|
pub fn csp_reporting_directive(name : StringView) -> Bool {
let n = csp_normalize_name(name)
csp_reporting_directives().any(item => item == n)
}
///|
pub fn csp_mixed_content_directive(name : StringView) -> Bool {
let n = csp_normalize_name(name)
csp_mixed_content_directives().any(item => item == n)
}
///|
pub fn classify_csp_source(token : StringView) -> CspSourceKind {
let value = csp_normalize_source(token)
if value == "'none'" {
CspSourceNone
} else if value == "'self'" {
CspSourceSelf
} else if value == "'unsafe-inline'" {
CspSourceUnsafeInline
} else if value == "'unsafe-eval'" {
CspSourceUnsafeEval
} else if value == "'strict-dynamic'" {
CspSourceStrictDynamic
} else if value.has_prefix("'nonce-") {
CspSourceNonce
} else if value.has_prefix("'sha256-") ||
value.has_prefix("'sha384-") ||
value.has_prefix("'sha512-") {
CspSourceHash
} else if value == "https:" {
CspSourceHttpsScheme
} else if value == "http:" {
CspSourceHttpScheme
} else if value == "data:" {
CspSourceDataScheme
} else if value == "blob:" {
CspSourceBlobScheme
} else if value == "filesystem:" {
CspSourceFilesystemScheme
} else if value == "*" ||
value.has_prefix("*.") ||
value.contains("://*") ||
value.contains("*.") {
CspSourceWildcard
} else if value.has_prefix("'") {
CspSourceKeyword
} else if value.contains("://") ||
value.contains(".") ||
value == "localhost" ||
value.has_prefix("localhost:") {
CspSourceHost
} else {
CspSourceUnknown
}
}
///|
pub fn csp_source_expression(
directive : StringView,
token : StringView,
) -> CspSourceExpression {
let normalized = csp_normalize_source(token)
let kind = classify_csp_source(normalized)
{
directive: csp_normalize_name(directive),
raw: token.trim().to_owned(),
normalized,
kind,
secure_transport: csp_source_secure_transport(kind, normalized),
broad: csp_source_is_broad(kind, normalized),
note: csp_source_note(kind, normalized),
}
}
///|
pub fn CspSourceExpression::risk_label(self : CspSourceExpression) -> String {
if self.broad {
"broad"
} else if !self.secure_transport {
"insecure-transport"
} else {
"narrow"
}
}
///|
pub fn CspSourceExpression::to_markdown_row(
self : CspSourceExpression,
) -> String {
"| `" +
self.directive +
"` | `" +
self.raw.replace_all(old="|", new="\\|") +
"` | " +
self.kind.label() +
" | " +
self.risk_label() +
" | " +
self.note.replace_all(old="|", new="\\|") +
" |"
}
///|
pub fn CspSourceExpression::to_json_string(
self : CspSourceExpression,
) -> String {
Json::object(
Map([
("directive", Json::string(self.directive)),
("raw", Json::string(self.raw)),
("kind", Json::string(self.kind.label())),
("risk", Json::string(self.risk_label())),
("note", Json::string(self.note)),
]),
).stringify(indent=2)
}
///|
pub fn CspPolicy::directive_names(self : CspPolicy) -> Array[String] {
self.directives.map(d => d.name)
}
///|
pub fn CspPolicy::has_nonce(self : CspPolicy) -> Bool {
self.all_sources().any(src => src.kind == CspSourceNonce)
}
///|
pub fn CspPolicy::has_hash(self : CspPolicy) -> Bool {
self.all_sources().any(src => src.kind == CspSourceHash)
}
///|
pub fn CspPolicy::has_strict_dynamic(self : CspPolicy) -> Bool {
self.all_sources().any(src => src.kind == CspSourceStrictDynamic)
}
///|
pub fn CspPolicy::all_sources(self : CspPolicy) -> Array[CspSourceExpression] {
let sources : Array[CspSourceExpression] = []
for directive in self.directives {
if csp_source_directive(directive.name) ||
csp_document_directive(directive.name) ||
csp_navigation_directive(directive.name) {
for token in directive.values {
sources.push(csp_source_expression(directive.name, token))
}
}
}
sources
}
///|
pub fn CspPolicy::sources_for(
self : CspPolicy,
directive : StringView,
) -> Array[CspSourceExpression] {
let name = csp_normalize_name(directive)
self
.effective_source_values(name)
.map(token => csp_source_expression(name, token))
}
///|
pub fn CspPolicy::explicit_sources_for(
self : CspPolicy,
directive : StringView,
) -> Array[CspSourceExpression] {
let name = csp_normalize_name(directive)
self.values_for(name).map(token => csp_source_expression(name, token))
}
///|
pub fn CspPolicy::effective_source_values(
self : CspPolicy,
directive : StringView,
) -> Array[String] {
csp_effective_source_values(self, csp_normalize_name(directive), 0)
}
///|
fn csp_effective_source_values(
policy : CspPolicy,
directive : String,
depth : Int,
) -> Array[String] {
if depth > 4 {
[]
} else {
match policy.directive(directive) {
Some(directive_data) => directive_data.values.copy()
None =>
match csp_fallback_directive(directive) {
Some(parent) => csp_effective_source_values(policy, parent, depth + 1)
None => []
}
}
}
}
///|
pub fn csp_fallback_directive(directive : StringView) -> String? {
let name = csp_normalize_name(directive)
if name == "script-src-elem" || name == "script-src-attr" {
Some("script-src")
} else if name == "style-src-elem" || name == "style-src-attr" {
Some("style-src")
} else if name == "worker-src" || name == "frame-src" {
Some("child-src")
} else if name == "script-src" ||
name == "style-src" ||
name == "img-src" ||
name == "font-src" ||
name == "connect-src" ||
name == "media-src" ||
name == "object-src" ||
name == "manifest-src" ||
name == "prefetch-src" ||
name == "child-src" {
Some("default-src")
} else {
None
}
}
///|
pub fn CspPolicy::directive_summary(
self : CspPolicy,
directive : StringView,
) -> CspDirectiveSummary {
let name = csp_normalize_name(directive)
let explicit = self.has_directive(name)
let values = self.effective_source_values(name)
let sources = values.map(token => csp_source_expression(name, token))
let notes : Array[String] = []
if !explicit {
match csp_fallback_directive(name) {
Some(parent) => notes.push("falls back to " + parent)
None => notes.push("not declared")
}
}
if sources.any(src => src.kind == CspSourceWildcard) {
notes.push("allows wildcard source")
}
if sources.any(src => src.kind == CspSourceHttpScheme) {
notes.push("allows insecure transport")
}
if sources.any(src => src.kind == CspSourceDataScheme) {
notes.push("allows data: URLs")
}
if sources.any(src => src.kind == CspSourceUnsafeInline) {
notes.push("allows inline execution")
}
if sources.any(src => src.kind == CspSourceNonce) ||
sources.any(src => src.kind == CspSourceHash) {
notes.push("uses nonce or hash based trust")
}
{
name,
family: csp_directive_family(name),
explicit,
value_count: values.length(),
source_count: sources.length(),
has_none: sources.any(src => src.kind == CspSourceNone),
has_self: sources.any(src => src.kind == CspSourceSelf),
has_wildcard: sources.any(src => src.kind == CspSourceWildcard),
has_http: sources.any(src => src.kind == CspSourceHttpScheme),
has_inline: sources.any(src => src.kind == CspSourceUnsafeInline),
has_eval: sources.any(src => src.kind == CspSourceUnsafeEval),
has_nonce: sources.any(src => src.kind == CspSourceNonce),
has_hash: sources.any(src => src.kind == CspSourceHash),
notes,
}
}
///|
pub fn CspDirectiveSummary::stance(self : CspDirectiveSummary) -> String {
if self.has_none {
"blocked"
} else if self.has_wildcard ||
self.has_http ||
self.has_inline ||
self.has_eval {
"broad"
} else if self.has_nonce || self.has_hash {
"tokenized"
} else if self.has_self && self.source_count <= 2 {
"same-origin"
} else if self.source_count == 0 {
"missing"
} else {
"explicit"
}
}
///|
pub fn CspDirectiveSummary::to_markdown_row(
self : CspDirectiveSummary,
) -> String {
"| `" +
self.name +
"` | " +
self.family.label() +
" | " +
bool_word(self.explicit) +
" | " +
self.stance() +
" | " +
self.notes.join("; ").replace_all(old="|", new="\\|") +
" |"
}
///|
pub fn CspDirectiveSummary::to_json_string(
self : CspDirectiveSummary,
) -> String {
Json::object(
Map([
("name", Json::string(self.name)),
("family", Json::string(self.family.label())),
("explicit", Json::string(bool_word(self.explicit))),
("stance", Json::string(self.stance())),
(
"value_count",
Json::number(
self.value_count.to_double(),
repr=self.value_count.to_string(),
),
),
("notes", Json::array(self.notes.map(note => Json::string(note)))),
]),
).stringify(indent=2)
}
///|
pub fn analyze_csp_header(value : StringView) -> CspAnalysis {
analyze_csp_policy(parse_csp(value))
}
///|
pub fn analyze_csp_policy(policy : CspPolicy) -> CspAnalysis {
let observations : Array[CspObservation] = []
let directives : Array[CspDirectiveSummary] = []
for issue in policy.issues {
observations.push({
id: "parse." + issue.kind.label(),
severity: Low,
directive: issue.name,
source: "",
message: issue.message,
recommendation: "Declare each CSP directive once and keep empty directive fragments out of the policy.",
})
}
for name in csp_all_interesting_directives() {
directives.push(policy.directive_summary(name))
}
csp_check_default_src(policy, observations)
csp_check_script_src(policy, observations)
csp_check_style_src(policy, observations)
csp_check_object_src(policy, observations)
csp_check_base_uri(policy, observations)
csp_check_frame_ancestors(policy, observations)
csp_check_form_action(policy, observations)
csp_check_connect_src(policy, observations)
csp_check_frame_src(policy, observations)
csp_check_img_src(policy, observations)
csp_check_reporting(policy, observations)
csp_check_mixed_content(policy, observations)
let score = csp_analysis_score(observations)
{ score, policy, observations, directives, sources: policy.all_sources() }
}
///|
fn csp_check_default_src(
policy : CspPolicy,
observations : Array[CspObservation],
) -> Unit {
let values = policy.values_for("default-src")
if values.is_empty() {
observations.push(
csp_observation(
"default-src.missing",
Medium,
"default-src",
"",
"default-src is missing, so fallback behavior is harder to reason about.",
"Add default-src 'self' or default-src 'none' and open only the directives that are needed.",
),
)
} else {
csp_check_source_values("default-src", values, observations)
if csp_values_have(values, "*") {
observations.push(
csp_observation(
"default-src.wildcard",
High,
"default-src",
values.join(" "),
"default-src allows every origin.",
"Replace * with explicit origins or a narrower fallback such as 'self'.",
),
)
}
}
}
///|
fn csp_check_script_src(
policy : CspPolicy,
observations : Array[CspObservation],
) -> Unit {
let values = policy.effective_source_values("script-src")
if values.is_empty() {
observations.push(
csp_observation(
"script-src.missing",
High,
"script-src",
"",
"script execution has no explicit source list.",
"Declare script-src with trusted hosts, nonces, or hashes.",
),
)
} else {
csp_check_source_values("script-src", values, observations)
if csp_values_have(values, "'unsafe-inline'") {
observations.push(
csp_observation(
"script-src.unsafe-inline",
High,
"script-src",
values.join(" "),
"script-src permits inline script execution.",
"Use nonce or hash based script trust and remove 'unsafe-inline'.",
),
)
}
if csp_values_have(values, "'unsafe-eval'") {
observations.push(
csp_observation(
"script-src.unsafe-eval",
High,
"script-src",
values.join(" "),
"script-src permits dynamic code evaluation.",
"Remove eval-style code paths or isolate them behind a more limited runtime.",
),
)
}
if csp_values_have(values, "data:") {
observations.push(
csp_observation(
"script-src.data",
High,
"script-src",
values.join(" "),
"data: scripts are executable content with very broad injection risk.",
"Remove data: from script-src.",
),
)
}
if csp_values_have(values, "blob:") {
observations.push(
csp_observation(
"script-src.blob",
Medium,
"script-src",
values.join(" "),
"blob: scripts are hard to inventory and review.",
"Prefer signed external scripts or nonce-protected inline bootstrap code.",
),
)
}
if policy.has_strict_dynamic() && !policy.has_nonce() && !policy.has_hash() {
observations.push(
csp_observation(
"script-src.strict-dynamic-without-token",
Medium,
"script-src",
values.join(" "),
"strict-dynamic appears without a nonce or hash root of trust.",
"Pair strict-dynamic with nonce or hash based bootstrap scripts.",
),
)
}
}
}
///|
fn csp_check_style_src(
policy : CspPolicy,
observations : Array[CspObservation],
) -> Unit {
let values = policy.effective_source_values("style-src")
if !values.is_empty() {
csp_check_source_values("style-src", values, observations)
if csp_values_have(values, "'unsafe-inline'") &&
!csp_values_have_kind(values, CspSourceNonce) &&
!csp_values_have_kind(values, CspSourceHash) {
observations.push(
csp_observation(
"style-src.unsafe-inline",
Medium,
"style-src",
values.join(" "),
"style-src allows inline CSS without a nonce or hash.",
"Move CSS into trusted stylesheets or protect inline styles with hashes.",
),
)
}
}
}
///|
fn csp_check_object_src(
policy : CspPolicy,
observations : Array[CspObservation],
) -> Unit {
let values = policy.effective_source_values("object-src")
if values.length() != 1 || values[0] != "'none'" {
observations.push(
csp_observation(
"object-src.not-none",
Medium,
"object-src",
values.join(" "),
"object-src is not locked to 'none'.",
"Set object-src 'none' unless legacy plugin content is deliberately required.",
),
)
}
}
///|
fn csp_check_base_uri(
policy : CspPolicy,
observations : Array[CspObservation],
) -> Unit {
let values = policy.values_for("base-uri")
if values.is_empty() {
observations.push(
csp_observation(
"base-uri.missing",
Medium,
"base-uri",
"",
"base-uri is missing.",
"Add base-uri 'self' or base-uri 'none' to prevent injected base tags from rewriting links.",
),
)
} else if !csp_values_have(values, "'self'") &&
!csp_values_have(values, "'none'") {
observations.push(
csp_observation(
"base-uri.broad",
Medium,
"base-uri",
values.join(" "),
"base-uri allows external base targets.",
"Restrict base-uri to 'self' or 'none'.",
),
)
}
}
///|
fn csp_check_frame_ancestors(
policy : CspPolicy,
observations : Array[CspObservation],
) -> Unit {
let values = policy.values_for("frame-ancestors")
if values.is_empty() {
observations.push(
csp_observation(
"frame-ancestors.missing",
Medium,
"frame-ancestors",
"",
"frame-ancestors is missing.",
"Add frame-ancestors 'none' for non-embeddable pages or a tight allowlist for embed flows.",
),
)
} else {
csp_check_source_values("frame-ancestors", values, observations)
if csp_values_have(values, "*") {
observations.push(
csp_observation(
"frame-ancestors.wildcard",
High,
"frame-ancestors",
values.join(" "),
"The page can be framed by any origin.",
"Use frame-ancestors 'none', 'self', or a narrow partner allowlist.",
),
)
}
}
}
///|
fn csp_check_form_action(
policy : CspPolicy,
observations : Array[CspObservation],
) -> Unit {
let values = policy.values_for("form-action")
if values.is_empty() {
observations.push(
csp_observation(
"form-action.missing",
Low,
"form-action",
"",
"form submissions are not constrained by CSP.",
"Add form-action 'self' for apps that submit credentials or personal data.",
),
)
} else {
csp_check_source_values("form-action", values, observations)
}
}
///|
fn csp_check_connect_src(
policy : CspPolicy,
observations : Array[CspObservation],
) -> Unit {
let values = policy.effective_source_values("connect-src")
if !values.is_empty() {
csp_check_source_values("connect-src", values, observations)
if csp_values_have(values, "*") {
observations.push(
csp_observation(
"connect-src.wildcard",
Medium,
"connect-src",
values.join(" "),
"connect-src allows requests to every origin.",
"List API, metrics, and realtime endpoints explicitly.",
),
)
}
}
}
///|
fn csp_check_frame_src(
policy : CspPolicy,
observations : Array[CspObservation],
) -> Unit {
let values = policy.effective_source_values("frame-src")
if !values.is_empty() {
csp_check_source_values("frame-src", values, observations)
if csp_values_have(values, "*") {
observations.push(
csp_observation(
"frame-src.wildcard",
Medium,
"frame-src",
values.join(" "),
"frame-src allows embedding arbitrary content.",
"Use a narrow payment, video, or partner frame allowlist.",
),
)
}
}
}
///|
fn csp_check_img_src(
policy : CspPolicy,
observations : Array[CspObservation],
) -> Unit {
let values = policy.effective_source_values("img-src")
if !values.is_empty() {
csp_check_source_values("img-src", values, observations)
if csp_values_have(values, "data:") {
observations.push(
csp_observation(
"img-src.data",
Low,
"img-src",
values.join(" "),
"img-src permits inline data: images.",
"Keep data: only when placeholders or generated images are intentionally required.",
),
)
}
}
}
///|
fn csp_check_reporting(
policy : CspPolicy,
observations : Array[CspObservation],
) -> Unit {
if !policy.has_directive("report-uri") && !policy.has_directive("report-to") {
observations.push(
csp_observation(
"reporting.missing",
Info,
"report-uri",
"",
"CSP violation reporting is not configured.",
"Add report-to or report-uri in monitoring deployments.",
),
)
}
}
///|
fn csp_check_mixed_content(
policy : CspPolicy,
observations : Array[CspObservation],
) -> Unit {
let all = policy.all_sources()
let has_http = all.any(src => {
src.kind == CspSourceHttpScheme || src.normalized.has_prefix("http://")
})
if has_http && !policy.has_directive("upgrade-insecure-requests") {
observations.push(
csp_observation(
"mixed-content.no-upgrade",
Medium,
"upgrade-insecure-requests",
"",
"The policy allows HTTP sources without upgrade-insecure-requests.",
"Prefer HTTPS sources and add upgrade-insecure-requests during migration.",
),
)
}
}
///|
fn csp_check_source_values(
directive : String,
values : Array[String],
observations : Array[CspObservation],
) -> Unit {
for value in values {
let source = csp_source_expression(directive, value)
if source.kind == CspSourceWildcard {
observations.push(
csp_observation(
directive + ".wildcard-source",
Medium,
directive,
source.raw,
directive + " contains a wildcard source.",
"Replace wildcard sources with explicit schemes or hosts.",
),
)
}
if source.kind == CspSourceHttpScheme ||
source.normalized.has_prefix("http://") {
observations.push(
csp_observation(
directive + ".http-source",
Medium,
directive,
source.raw,
directive + " contains an insecure HTTP source.",
"Use HTTPS-only source expressions.",
),
)
}
if source.kind == CspSourceFilesystemScheme {
observations.push(
csp_observation(
directive + ".filesystem-source",
Low,
directive,
source.raw,
directive + " allows filesystem: URLs.",
"Remove filesystem: unless a controlled legacy browser surface requires it.",
),
)
}
}
}
///|
pub fn CspAnalysis::to_findings(self : CspAnalysis) -> Array[Finding] {
self.observations.map(obs => obs.to_finding())
}
///|
pub fn CspObservation::to_finding(self : CspObservation) -> Finding {
{
id: "csp.deep." + self.id,
severity: self.severity,
header: "content-security-policy",
message: self.message,
evidence: if self.source.is_empty() {
self.directive
} else {
self.source
},
remediation: self.recommendation,
}
}
///|
pub fn audit_headers_with_csp_analysis(raw : StringView) -> AuditReport {
let base = audit_headers(raw)
let findings = base.findings.copy()
match base.headers.get("content-security-policy") {
Some(value) => {
let analysis = analyze_csp_header(value)
for finding in analysis.to_findings() {
if !csp_finding_id_exists(findings, finding.id) {
findings.push(finding)
}
}
}
None => ()
}
let score = score_findings(findings)
{ score, grade: grade_score(score), findings, headers: base.headers }
}
///|
pub fn CspAnalysis::count_by_severity(
self : CspAnalysis,
severity : Severity,
) -> Int {
self.observations.count_if(obs => obs.severity == severity)
}
///|
pub fn CspAnalysis::has_observation(
self : CspAnalysis,
id : StringView,
) -> Bool {
let expected = id.to_owned()
self.observations.any(obs => obs.id == expected)
}
///|
pub fn CspAnalysis::summary(self : CspAnalysis) -> String {
"score=" +
self.score.to_string() +
", directives=" +
self.directives.length().to_string() +
", sources=" +
self.sources.length().to_string() +
", observations=" +
self.observations.length().to_string()
}
///|
pub fn CspAnalysis::to_markdown(self : CspAnalysis) -> String {
let lines : Array[String] = []
lines.push("# CSP Analysis")
lines.push("")
lines.push("- " + self.summary())
lines.push("- critical=" + self.count_by_severity(Critical).to_string())
lines.push("- high=" + self.count_by_severity(High).to_string())
lines.push("- medium=" + self.count_by_severity(Medium).to_string())
lines.push("- low=" + self.count_by_severity(Low).to_string())
lines.push("")
lines.push("## Directive Summary")
lines.push("")
lines.push("| Directive | Family | Explicit | Stance | Notes |")
lines.push("| --- | --- | --- | --- | --- |")
for directive in self.directives {
lines.push(directive.to_markdown_row())
}
lines.push("")
lines.push("## Sources")
lines.push("")
lines.push("| Directive | Source | Kind | Risk | Note |")
lines.push("| --- | --- | --- | --- | --- |")
for source in self.sources {
lines.push(source.to_markdown_row())
}
lines.push("")
lines.push("## Observations")
lines.push("")
for obs in self.observations {
lines.push(
"- " + obs.severity.label() + " `" + obs.id + "`: " + obs.message,
)
}
lines.join("\n")
}
///|
pub fn CspAnalysis::to_json_string(self : CspAnalysis) -> String {
Json::object(
Map([
(
"score",
Json::number(self.score.to_double(), repr=self.score.to_string()),
),
("summary", Json::string(self.summary())),
(
"directives",
Json::array(
self.directives.map(item => csp_directive_summary_json(item)),
),
),
(
"sources",
Json::array(self.sources.map(item => csp_source_expression_json(item))),
),
(
"observations",
Json::array(self.observations.map(item => csp_observation_json(item))),
),
]),
).stringify(indent=2)
}
///|
fn csp_directive_summary_json(item : CspDirectiveSummary) -> Json {
Json::object(
Map([
("name", Json::string(item.name)),
("family", Json::string(item.family.label())),
("explicit", Json::string(bool_word(item.explicit))),
("stance", Json::string(item.stance())),
("notes", Json::array(item.notes.map(note => Json::string(note)))),
]),
)
}
///|
fn csp_source_expression_json(item : CspSourceExpression) -> Json {
Json::object(
Map([
("directive", Json::string(item.directive)),
("raw", Json::string(item.raw)),
("kind", Json::string(item.kind.label())),
("risk", Json::string(item.risk_label())),
("note", Json::string(item.note)),
]),
)
}
///|
fn csp_observation_json(item : CspObservation) -> Json {
Json::object(
Map([
("id", Json::string(item.id)),
("severity", Json::string(item.severity.label())),
("directive", Json::string(item.directive)),
("source", Json::string(item.source)),
("message", Json::string(item.message)),
("recommendation", Json::string(item.recommendation)),
]),
)
}
///|
fn csp_analysis_score(observations : Array[CspObservation]) -> Int {
let mut score = 100
for obs in observations {
score = score - obs.severity.weight()
}
if score < 0 {
0
} else {
score
}
}
///|
fn csp_observation(
id : String,
severity : Severity,
directive : String,
source : String,
message : String,
recommendation : String,
) -> CspObservation {
{ id, severity, directive, source, message, recommendation }
}
///|
fn csp_source_secure_transport(
kind : CspSourceKind,
normalized : String,
) -> Bool {
match kind {
CspSourceHttpScheme => false
CspSourceHost => !normalized.has_prefix("http://")
_ => true
}
}
///|
fn csp_source_is_broad(kind : CspSourceKind, normalized : String) -> Bool {
kind == CspSourceWildcard ||
kind == CspSourceUnsafeInline ||
kind == CspSourceUnsafeEval ||
kind == CspSourceDataScheme ||
normalized.contains("://*") ||
normalized == "*"
}
///|
fn csp_source_note(kind : CspSourceKind, normalized : String) -> String {
match kind {
CspSourceNone => "blocks the directive completely"
CspSourceSelf => "same-origin source"
CspSourceUnsafeInline => "allows inline code or styles"
CspSourceUnsafeEval => "allows dynamic code evaluation"
CspSourceStrictDynamic =>
"delegates trust from nonce or hash bootstrap code"
CspSourceNonce => "nonce based source token"
CspSourceHash => "hash based source token"
CspSourceHttpsScheme => "allows any HTTPS origin"
CspSourceHttpScheme => "allows insecure HTTP origins"
CspSourceDataScheme => "allows inline data URLs"
CspSourceBlobScheme => "allows generated blob URLs"
CspSourceFilesystemScheme => "allows filesystem URLs"
CspSourceWildcard => "matches a broad set of origins"
CspSourceHost =>
if normalized.has_prefix("https://") {
"explicit HTTPS host"
} else {
"explicit host"
}
CspSourceKeyword => "CSP keyword source"
CspSourceUnknown => "unclassified source expression"
}
}
///|
fn csp_values_have(values : Array[String], token : String) -> Bool {
let expected = csp_normalize_source(token)
values.any(value => csp_normalize_source(value) == expected)
}
///|
fn csp_values_have_kind(values : Array[String], kind : CspSourceKind) -> Bool {
values.any(value => classify_csp_source(value) == kind)
}
///|
fn csp_finding_id_exists(findings : Array[Finding], id : String) -> Bool {
findings.any(finding => finding.id == id)
}
///|
fn csp_normalize_name(name : StringView) -> String {
name.trim().to_lower().to_owned()
}
///|
fn csp_normalize_source(token : StringView) -> String {
token.trim().to_lower().to_owned()
}
///|
fn bool_word(flag : Bool) -> String {
if flag {
"yes"
} else {
"no"
}
}