///|
/// One Permissions-Policy allowlist token.
pub enum AllowToken {
TokenSelf
TokenAll
TokenOrigin(String)
} derive(Eq, @debug.Debug)
///|
/// A parsed Permissions-Policy directive such as `camera=()`.
pub struct Directive {
feature : String
tokens : Array[AllowToken]
raw : String
index : Int
} derive(Eq, @debug.Debug)
///|
/// A non-fatal parser warning. Warnings keep the policy usable in CI reports.
pub struct ParseWarning {
index : Int
message : String
text : String
} derive(Eq, @debug.Debug)
///|
/// Parsed modern Permissions-Policy header.
pub struct Policy {
directives : Array[Directive]
warnings : Array[ParseWarning]
} derive(Eq, @debug.Debug)
///|
/// Audit severity used by reports and CI output.
pub enum Severity {
SeverityInfo
SeverityWarning
SeverityHigh
} derive(Eq, @debug.Debug)
///|
/// One policy audit finding.
pub struct Finding {
severity : Severity
code : String
feature : String?
message : String
} derive(Eq, @debug.Debug)
///|
/// Project-specific security expectations.
pub struct Baseline {
document_origin : String
sensitive_features : Array[String]
deny_by_default : Array[String]
} derive(Eq, @debug.Debug)
///|
/// Result of auditing a parsed policy against a baseline.
pub struct AuditReport {
ok : Bool
findings : Array[Finding]
} derive(Eq, @debug.Debug)
///|
/// One mutable builder operation is represented as a value so callers can keep
/// configuration declarative.
pub enum PolicyIntent {
IntentDeny(String)
IntentSelf(String)
IntentAll(String)
IntentOrigins(String, Array[String])
IntentOriginsOnly(String, Array[String])
} derive(Eq, @debug.Debug)
///|
/// Difference severity between two policies.
pub enum DiffKind {
DiffAdded
DiffRemoved
DiffChanged
DiffLoosened
DiffTightened
} derive(Eq, @debug.Debug)
///|
/// One feature-level policy difference.
pub struct PolicyDiff {
kind : DiffKind
feature : String
before : String?
after : String?
} derive(Eq, @debug.Debug)
///|
/// Browser capability category used by the built-in feature catalog.
pub enum FeatureCategory {
CategorySensors
CategoryMedia
CategoryDevice
CategoryIdentity
CategoryPayment
CategoryDisplay
CategoryStorage
CategoryExperimental
CategoryOther
} derive(Eq, @debug.Debug)
///|
/// Approximate risk tier for a browser capability.
pub enum RiskLevel {
RiskLow
RiskMedium
RiskHigh
} derive(Eq, @debug.Debug)
///|
/// Metadata for a known Permissions-Policy feature.
pub struct FeatureSpec {
feature : String
category : FeatureCategory
risk : RiskLevel
recommended : PolicyIntent
rationale : String
} derive(Eq, @debug.Debug)
///|
/// Compact policy summary for dashboards and release notes.
pub struct PolicySummary {
disabled : Int
self_only : Int
wildcard : Int
explicit_origins : Int
missing_sensitive : Array[String]
} derive(Eq, @debug.Debug)
///|
/// Built-in policy profiles for common web application shapes.
pub enum BaselineProfile {
ProfileStrict
ProfileBalanced
ProfileMediaApp
ProfileDeviceLab
} derive(Eq, @debug.Debug)
///|
/// Kind of HTTP response header recognized by the header-block parser.
pub enum HeaderKind {
HeaderPermissionsPolicy
HeaderFeaturePolicy
HeaderOther
} derive(Eq, @debug.Debug)
///|
/// One parsed HTTP response header line.
pub struct HeaderLine {
name : String
value : String
index : Int
kind : HeaderKind
} derive(Eq, @debug.Debug)
///|
/// Parsed HTTP response header block, suitable for `curl -I` output.
pub struct HeaderSet {
lines : Array[HeaderLine]
warnings : Array[ParseWarning]
} derive(Eq, @debug.Debug)
///|
/// End-to-end audit result for a raw HTTP response header block.
pub struct HeaderAudit {
headers : HeaderSet
modern : Policy?
legacy : Policy?
effective : Policy
report : AuditReport
used_legacy : Bool
migration : String
} derive(Eq, @debug.Debug)
///|
/// One feature/origin access decision in a matrix report.
pub struct AccessCell {
feature : String
target_origin : String
allowed : Bool
} derive(Eq, @debug.Debug)
///|
/// Matrix view of many features across many target origins.
pub struct AccessMatrix {
document_origin : String
features : Array[String]
target_origins : Array[String]
cells : Array[AccessCell]
} derive(Eq, @debug.Debug)
///|
/// Create an intent that disables a feature.
pub fn deny(feature : String) -> PolicyIntent {
IntentDeny(feature)
}
///|
/// Create an intent that allows a feature only for the document origin.
pub fn self_only(feature : String) -> PolicyIntent {
IntentSelf(feature)
}
///|
/// Create an intent that allows a feature for every origin.
pub fn all_origins(feature : String) -> PolicyIntent {
IntentAll(feature)
}
///|
/// Create an intent that allows a feature for self plus explicit origins.
pub fn self_and_origins(
feature : String,
origins : Array[String],
) -> PolicyIntent {
IntentOrigins(feature, origins)
}
///|
/// Create an intent that allows a feature only for explicit origins.
pub fn origins_only(feature : String, origins : Array[String]) -> PolicyIntent {
IntentOriginsOnly(feature, origins)
}
///|
/// Return the built-in browser capability catalog.
pub fn known_features() -> Array[FeatureSpec] {
[
feature_spec(
"accelerometer",
CategorySensors,
RiskMedium,
deny("accelerometer"),
"motion sensors can reveal device movement",
),
feature_spec(
"ambient-light-sensor",
CategorySensors,
RiskMedium,
deny("ambient-light-sensor"),
"ambient sensors may expose user environment signals",
),
feature_spec(
"autoplay",
CategoryMedia,
RiskLow,
self_only("autoplay"),
"same-origin autoplay is usually easier to control",
),
feature_spec(
"bluetooth",
CategoryDevice,
RiskHigh,
deny("bluetooth"),
"device access should require explicit application review",
),
feature_spec(
"browsing-topics",
CategoryExperimental,
RiskMedium,
deny("browsing-topics"),
"interest topics should be disabled unless advertising logic needs them",
),
feature_spec(
"camera",
CategoryMedia,
RiskHigh,
deny("camera"),
"camera access is a high-risk user privacy capability",
),
feature_spec(
"clipboard-read",
CategoryDevice,
RiskHigh,
deny("clipboard-read"),
"clipboard read access may expose sensitive user data",
),
feature_spec(
"clipboard-write",
CategoryDevice,
RiskMedium,
self_only("clipboard-write"),
"same-origin clipboard writes are safer than broad delegation",
),
feature_spec(
"display-capture",
CategoryMedia,
RiskHigh,
deny("display-capture"),
"screen capture should be explicitly reviewed",
),
feature_spec(
"encrypted-media",
CategoryMedia,
RiskMedium,
self_only("encrypted-media"),
"DRM media access should be scoped to trusted origins",
),
feature_spec(
"fullscreen",
CategoryDisplay,
RiskLow,
self_only("fullscreen"),
"fullscreen is normally acceptable for the document origin",
),
feature_spec(
"gamepad",
CategoryDevice,
RiskMedium,
deny("gamepad"),
"gamepad access can reveal connected hardware state",
),
feature_spec(
"geolocation",
CategorySensors,
RiskHigh,
deny("geolocation"),
"location access is high-risk and should be denied by default",
),
feature_spec(
"gyroscope",
CategorySensors,
RiskMedium,
deny("gyroscope"),
"motion sensors should be opt-in",
),
feature_spec(
"hid",
CategoryDevice,
RiskHigh,
deny("hid"),
"human-interface device access reaches local hardware",
),
feature_spec(
"idle-detection",
CategoryIdentity,
RiskMedium,
deny("idle-detection"),
"idle signals can reveal user presence",
),
feature_spec(
"local-fonts",
CategoryStorage,
RiskMedium,
deny("local-fonts"),
"local font enumeration may contribute to fingerprinting",
),
feature_spec(
"magnetometer",
CategorySensors,
RiskMedium,
deny("magnetometer"),
"motion and compass sensors should be opt-in",
),
feature_spec(
"microphone",
CategoryMedia,
RiskHigh,
deny("microphone"),
"microphone access is a high-risk user privacy capability",
),
feature_spec(
"midi",
CategoryDevice,
RiskMedium,
deny("midi"),
"MIDI device access should not be delegated broadly",
),
feature_spec(
"payment",
CategoryPayment,
RiskHigh,
deny("payment"),
"payment access should only be enabled after product review",
),
feature_spec(
"publickey-credentials-get",
CategoryIdentity,
RiskHigh,
self_only("publickey-credentials-get"),
"passkey requests should stay on trusted document origins",
),
feature_spec(
"serial",
CategoryDevice,
RiskHigh,
deny("serial"),
"serial ports expose local hardware resources",
),
feature_spec(
"speaker-selection",
CategoryMedia,
RiskMedium,
self_only("speaker-selection"),
"output device selection should be scoped",
),
feature_spec(
"storage-access",
CategoryStorage,
RiskMedium,
deny("storage-access"),
"cross-site storage access should be deliberate",
),
feature_spec(
"usb",
CategoryDevice,
RiskHigh,
deny("usb"),
"USB access reaches local hardware and should be denied by default",
),
feature_spec(
"web-share",
CategoryOther,
RiskLow,
self_only("web-share"),
"same-origin sharing keeps delegation predictable",
),
feature_spec(
"window-management",
CategoryDisplay,
RiskMedium,
deny("window-management"),
"window placement should be opt-in for trusted apps",
),
feature_spec(
"xr-spatial-tracking",
CategorySensors,
RiskHigh,
deny("xr-spatial-tracking"),
"XR tracking can reveal physical-space information",
),
]
}
///|
/// Find metadata for one known feature.
pub fn known_feature(feature : String) -> FeatureSpec? {
let wanted = normalize_feature(feature)
for spec in known_features() {
if spec.feature == wanted {
return Some(spec)
}
}
None
}
///|
/// Return a normalized list of known feature names.
pub fn known_feature_names() -> Array[String] {
let names : Array[String] = []
for spec in known_features() {
names.push(spec.feature)
}
names
}
///|
/// Render the built-in recommended header.
pub fn recommended_header() -> String {
build(recommended_intents())
}
///|
/// Build a baseline from the feature catalog instead of a hard-coded list.
pub fn catalog_baseline(document_origin : String) -> Baseline {
let sensitive_features : Array[String] = []
let deny_by_default : Array[String] = []
for spec in known_features() {
if spec.risk == RiskHigh {
sensitive_features.push(spec.feature)
}
if intent_disables(spec.recommended) {
deny_by_default.push(spec.feature)
}
}
{ document_origin, sensitive_features, deny_by_default }
}
///|
/// Summarize a policy against a baseline.
pub fn summarize(policy : Policy, baseline : Baseline) -> PolicySummary {
let mut disabled = 0
let mut self_only = 0
let mut wildcard = 0
let mut explicit_origins = 0
let missing_sensitive : Array[String] = []
for item in policy.directives {
match directive_shape(item) {
"disabled" => disabled = disabled + 1
"self" => self_only = self_only + 1
"wildcard" => wildcard = wildcard + 1
"explicit" => explicit_origins = explicit_origins + 1
_ => ()
}
}
for feature in baseline.deny_by_default {
if directive(policy, feature) is None {
missing_sensitive.push(feature)
}
}
{ disabled, self_only, wildcard, explicit_origins, missing_sensitive }
}
///|
/// Render a compact summary for CLI output.
pub fn render_summary(summary : PolicySummary) -> String {
"summary disabled=" +
summary.disabled.to_string() +
" self=" +
summary.self_only.to_string() +
" wildcard=" +
summary.wildcard.to_string() +
" explicit=" +
summary.explicit_origins.to_string() +
" missing_sensitive=" +
summary.missing_sensitive.join("|")
}
///|
/// Return all built-in policy profiles.
pub fn built_in_profiles() -> Array[BaselineProfile] {
[ProfileStrict, ProfileBalanced, ProfileMediaApp, ProfileDeviceLab]
}
///|
/// Profile that disables every known browser capability.
pub fn strict_profile() -> BaselineProfile {
ProfileStrict
}
///|
/// Profile that follows the package's default recommendation catalog.
pub fn balanced_profile() -> BaselineProfile {
ProfileBalanced
}
///|
/// Profile for media-heavy sites that need same-origin playback features.
pub fn media_app_profile() -> BaselineProfile {
ProfileMediaApp
}
///|
/// Profile for hardware labs that delegate device features to trusted origins.
pub fn device_lab_profile() -> BaselineProfile {
ProfileDeviceLab
}
///|
/// Return a stable name for a built-in profile.
pub fn profile_name(profile : BaselineProfile) -> String {
match profile {
ProfileStrict => "strict"
ProfileBalanced => "balanced"
ProfileMediaApp => "media-app"
ProfileDeviceLab => "device-lab"
}
}
///|
/// Build declarative intents for a built-in profile.
pub fn profile_intents(
profile : BaselineProfile,
trusted_origins : Array[String],
) -> Array[PolicyIntent] {
let intents : Array[PolicyIntent] = []
for spec in known_features() {
intents.push(profile_intent(profile, spec.feature, trusted_origins))
}
intents
}
///|
/// Render a complete header for a built-in profile.
pub fn profile_header(
profile : BaselineProfile,
trusted_origins : Array[String],
) -> String {
build(profile_intents(profile, trusted_origins))
}
///|
/// Build a parsed policy for a built-in profile.
pub fn profile_policy(
profile : BaselineProfile,
trusted_origins : Array[String],
) -> Policy {
build_policy(profile_intents(profile, trusted_origins))
}
///|
/// Build an audit baseline that matches a built-in profile.
pub fn profile_baseline(
profile : BaselineProfile,
document_origin : String,
) -> Baseline {
let sensitive_features : Array[String] = []
let deny_by_default : Array[String] = []
for spec in known_features() {
if profile_marks_sensitive(profile, spec) {
sensitive_features.push(spec.feature)
}
if profile_requires_cross_site_guard(profile, spec.feature) {
deny_by_default.push(spec.feature)
}
}
{
document_origin: normalize_origin(document_origin),
sensitive_features,
deny_by_default,
}
}
///|
/// Render profile metadata and generated header for documentation or logs.
pub fn render_profile(
profile : BaselineProfile,
trusted_origins : Array[String],
) -> String {
let lines : Array[String] = []
lines.push("profile=" + profile_name(profile))
lines.push("trusted_origins=" + trusted_origins.join("|"))
lines.push("header=" + profile_header(profile, trusted_origins))
lines.join("\n")
}
///|
/// Parse an HTTP response header block such as `curl -I` output.
pub fn parse_header_block(block : String) -> HeaderSet {
let lines : Array[HeaderLine] = []
let warnings : Array[ParseWarning] = []
let mut index = 0
for line_view in block.split("\n") {
index = index + 1
let raw = line_view.to_owned()
let clean = trim(raw)
if clean == "" {
continue
}
if clean.to_lower().has_prefix("http/") {
continue
}
if raw.has_prefix(" ") || raw.has_prefix("\t") {
warnings.push({
index,
message: "folded header continuation is ignored",
text: clean,
})
continue
}
match clean.find(":") {
None =>
warnings.push({
index,
message: "header line is missing ':'",
text: clean,
})
Some(colon) => {
let name = normalize_header_name(clean[:colon].to_owned())
let value = trim(clean[colon + 1:].to_owned())
if name == "" {
warnings.push({ index, message: "empty header name", text: clean })
} else {
lines.push({ name, value, index, kind: classify_header_name(name) })
}
}
}
}
{ lines, warnings }
}
///|
/// Return combined modern `Permissions-Policy` header value, if present.
pub fn permissions_policy_header(headers : HeaderSet) -> String? {
joined_header_value(headers, HeaderPermissionsPolicy, ", ")
}
///|
/// Return combined legacy `Feature-Policy` header value, if present.
pub fn feature_policy_header(headers : HeaderSet) -> String? {
joined_header_value(headers, HeaderFeaturePolicy, "; ")
}
///|
/// Choose the effective policy from parsed headers.
///
/// Modern `Permissions-Policy` takes precedence. Legacy `Feature-Policy` is
/// used only when no modern header is present.
pub fn policy_from_headers(headers : HeaderSet) -> Policy {
match permissions_policy_header(headers) {
Some(value) => prepend_warnings(parse(value), headers.warnings)
None =>
match feature_policy_header(headers) {
Some(value) =>
prepend_warnings(parse_feature_policy(value), headers.warnings)
None => prepend_warnings(parse(""), headers.warnings)
}
}
}
///|
/// Audit a raw HTTP response header block.
pub fn audit_header_block(block : String, baseline : Baseline) -> HeaderAudit {
let headers = parse_header_block(block)
let modern_value = permissions_policy_header(headers)
let legacy_value = feature_policy_header(headers)
let modern = match modern_value {
Some(value) => Some(parse(value))
None => None
}
let legacy = match legacy_value {
Some(value) => Some(parse_feature_policy(value))
None => None
}
let effective = policy_from_headers(headers)
let report = audit(effective, baseline)
let migration = match legacy_value {
Some(value) => migrate_feature_policy(value)
None => ""
}
{
headers,
modern,
legacy,
effective,
report,
used_legacy: modern_value is None && legacy_value is Some(_),
migration,
}
}
///|
/// Render an end-to-end header audit result.
pub fn render_header_audit(audit : HeaderAudit) -> String {
let lines : Array[String] = []
lines.push("permscope header audit")
lines.push("headers=" + audit.headers.lines.length().to_string())
match audit.modern {
Some(_) => lines.push("effective=permissions-policy")
None =>
if audit.used_legacy {
lines.push("effective=feature-policy-migration")
} else {
lines.push("effective=missing")
}
}
if audit.migration != "" {
lines.push("migration=" + audit.migration)
}
lines.push(render_report(audit.report))
lines.join("\n")
}
///|
/// Build a many-feature, many-origin access matrix.
pub fn access_matrix(
policy : Policy,
features : Array[String],
document_origin : String,
target_origins : Array[String],
) -> AccessMatrix {
let normalized_document = normalize_origin(document_origin)
let normalized_features : Array[String] = []
let normalized_targets : Array[String] = []
let cells : Array[AccessCell] = []
for feature in features {
let normalized = normalize_feature(feature)
if normalized != "" {
normalized_features.push(normalized)
}
}
for target in target_origins {
let normalized = normalize_origin(target)
if normalized != "" {
normalized_targets.push(normalized)
}
}
for feature in normalized_features {
for target in normalized_targets {
cells.push({
feature,
target_origin: target,
allowed: allows(policy, feature, normalized_document, target),
})
}
}
{
document_origin: normalized_document,
features: normalized_features,
target_origins: normalized_targets,
cells,
}
}
///|
/// Find one cell in an access matrix.
pub fn matrix_cell(
matrix : AccessMatrix,
feature : String,
target_origin : String,
) -> AccessCell? {
let wanted_feature = normalize_feature(feature)
let wanted_origin = normalize_origin(target_origin)
for cell in matrix.cells {
if cell.feature == wanted_feature && cell.target_origin == wanted_origin {
return Some(cell)
}
}
None
}
///|
/// Render an access matrix as compact line-oriented text.
pub fn render_access_matrix(matrix : AccessMatrix) -> String {
let lines : Array[String] = []
lines.push("permscope access matrix document=" + matrix.document_origin)
let header : Array[String] = ["feature"]
for target in matrix.target_origins {
header.push(target)
}
lines.push(header.join(" "))
for feature in matrix.features {
let row : Array[String] = [feature]
for target in matrix.target_origins {
match matrix_cell(matrix, feature, target) {
None => row.push("n/a")
Some(cell) => {
let mut label = "deny"
if cell.allowed {
label = "allow"
}
row.push(label)
}
}
}
lines.push(row.join(" "))
}
lines.join("\n")
}
///|
/// Parse a modern `Permissions-Policy` header.
pub fn parse(header : String) -> Policy {
let directives : Array[Directive] = []
let warnings : Array[ParseWarning] = []
let clean = trim(header)
if clean == "" {
warnings.push({ index: 0, message: "empty header", text: header })
return { directives, warnings }
}
let mut index = 0
for part_view in clean.split(",") {
index = index + 1
let part = trim(part_view.to_owned())
if part == "" {
warnings.push({
index,
message: "empty directive segment",
text: part_view.to_owned(),
})
continue
}
match parse_modern_directive(part, index, warnings) {
None => ()
Some(directive) => {
if has_feature(directives, directive.feature) {
warnings.push({
index,
message: "duplicate directive for feature: " + directive.feature,
text: part,
})
}
directives.push(directive)
}
}
}
{ directives, warnings }
}
///|
/// Parse a legacy `Feature-Policy` header and normalize it to this package's
/// modern directive representation.
pub fn parse_feature_policy(header : String) -> Policy {
let directives : Array[Directive] = []
let warnings : Array[ParseWarning] = []
let clean = trim(header)
if clean == "" {
warnings.push({ index: 0, message: "empty legacy header", text: header })
return { directives, warnings }
}
let mut index = 0
for part_view in clean.split(";") {
index = index + 1
let part = trim(part_view.to_owned())
if part == "" {
continue
}
match parse_legacy_directive(part, index, warnings) {
None => ()
Some(directive) => {
if has_feature(directives, directive.feature) {
warnings.push({
index,
message: "duplicate legacy directive for feature: " +
directive.feature,
text: part,
})
}
directives.push(directive)
}
}
}
{ directives, warnings }
}
///|
/// Return the first directive for `feature`, if one is present.
pub fn directive(policy : Policy, feature : String) -> Directive? {
let wanted = normalize_feature(feature)
for d in policy.directives {
if d.feature == wanted {
return Some(d)
}
}
None
}
///|
/// Decide whether a feature is allowed for a target origin.
///
/// Missing directives are treated as allowed because the header does not impose
/// a restriction for that feature. Use `audit` to flag missing restrictions for
/// high-risk browser capabilities.
pub fn allows(
policy : Policy,
feature : String,
document_origin : String,
target_origin : String,
) -> Bool {
match directive(policy, feature) {
None => true
Some(directive) =>
tokens_allow(
directive.tokens,
normalize_origin(document_origin),
normalize_origin(target_origin),
)
}
}
///|
/// Render a policy back to normalized modern header syntax.
pub fn render(policy : Policy) -> String {
let parts : Array[String] = []
for d in policy.directives {
parts.push(render_directive(d))
}
parts.join(", ")
}
///|
/// Recommended baseline for public web applications.
pub fn default_baseline(document_origin : String) -> Baseline {
{
document_origin,
sensitive_features: [
"accelerometer", "ambient-light-sensor", "autoplay", "bluetooth", "browsing-topics",
"camera", "clipboard-read", "clipboard-write", "display-capture", "encrypted-media",
"fullscreen", "gamepad", "geolocation", "gyroscope", "hid", "idle-detection",
"local-fonts", "magnetometer", "microphone", "midi", "payment", "publickey-credentials-get",
"serial", "usb", "xr-spatial-tracking",
],
deny_by_default: [
"bluetooth", "camera", "geolocation", "hid", "microphone", "payment", "serial",
"usb",
],
}
}
///|
/// Audit a parsed policy for risky defaults and overly broad allowlists.
pub fn audit(policy : Policy, baseline : Baseline) -> AuditReport {
let findings : Array[Finding] = []
if policy.directives.length() == 0 {
findings.push({
severity: SeverityHigh,
code: "missing-header",
feature: None,
message: "no Permissions-Policy directive is present",
})
}
for warning in policy.warnings {
findings.push({
severity: SeverityWarning,
code: "parse-warning",
feature: None,
message: "segment " + warning.index.to_string() + ": " + warning.message,
})
}
for directive in policy.directives {
audit_directive(directive, baseline, findings)
}
for feature in baseline.deny_by_default {
if allows(
policy,
feature,
baseline.document_origin,
"https://cross-site.invalid",
) {
findings.push({
severity: SeverityHigh,
code: "not-denied-by-default",
feature: Some(feature),
message: "sensitive feature is not explicitly blocked for cross-site contexts",
})
}
}
if findings.length() == 0 {
findings.push({
severity: SeverityInfo,
code: "baseline-satisfied",
feature: None,
message: "policy satisfies the selected baseline",
})
}
{ ok: !has_high(findings), findings }
}
///|
/// Build a modern header from declarative feature intents.
pub fn build(intents : Array[PolicyIntent]) -> String {
let policy = build_policy(intents)
render(policy)
}
///|
/// Build a policy from declarative feature intents.
pub fn build_policy(intents : Array[PolicyIntent]) -> Policy {
let directives : Array[Directive] = []
let warnings : Array[ParseWarning] = []
let mut index = 0
for intent in intents {
index = index + 1
let directive = directive_from_intent(intent, index)
if has_feature(directives, directive.feature) {
warnings.push({
index,
message: "duplicate builder intent for feature: " + directive.feature,
text: render_directive(directive),
})
}
directives.push(directive)
}
{ directives, warnings }
}
///|
/// Compare two policies feature by feature.
pub fn diff(before : Policy, after : Policy) -> Array[PolicyDiff] {
let diffs : Array[PolicyDiff] = []
for before_directive in before.directives {
match directive(after, before_directive.feature) {
None =>
diffs.push({
kind: DiffRemoved,
feature: before_directive.feature,
before: Some(render_directive(before_directive)),
after: None,
})
Some(after_directive) =>
if render_directive(before_directive) !=
render_directive(after_directive) {
diffs.push({
kind: classify_diff(before_directive, after_directive),
feature: before_directive.feature,
before: Some(render_directive(before_directive)),
after: Some(render_directive(after_directive)),
})
}
}
}
for after_directive in after.directives {
if directive(before, after_directive.feature) is None {
diffs.push({
kind: DiffAdded,
feature: after_directive.feature,
before: None,
after: Some(render_directive(after_directive)),
})
}
}
diffs
}
///|
/// Render policy diffs as stable line-oriented text.
pub fn render_diffs(diffs : Array[PolicyDiff]) -> String {
if diffs.length() == 0 {
return "permscope diff: no changes"
}
let lines : Array[String] = ["permscope diff:"]
for item in diffs {
lines.push(
diff_label(item.kind) +
" " +
item.feature +
" before=" +
item.before.unwrap_or("-") +
" after=" +
item.after.unwrap_or("-"),
)
}
lines.join("\n")
}
///|
/// Convert legacy `Feature-Policy` syntax into modern `Permissions-Policy`.
pub fn migrate_feature_policy(header : String) -> String {
render(parse_feature_policy(header))
}
///|
/// Render an audit report as line-oriented text for examples and CI logs.
pub fn render_report(report : AuditReport) -> String {
let lines : Array[String] = []
if report.ok {
lines.push("permscope: pass")
} else {
lines.push("permscope: fail")
}
for finding in report.findings {
let feature = match finding.feature {
None => "-"
Some(value) => value
}
lines.push(
severity_label(finding.severity) +
" " +
finding.code +
" " +
feature +
" - " +
finding.message,
)
}
lines.join("\n")
}
///|
fn parse_modern_directive(
part : String,
index : Int,
warnings : Array[ParseWarning],
) -> Directive? {
match part.find("=") {
None => {
warnings.push({ index, message: "directive is missing '='", text: part })
None
}
Some(eq) => {
let feature = normalize_feature(part[:eq].to_owned())
let value = trim(part[eq + 1:].to_owned())
if feature == "" {
warnings.push({ index, message: "empty feature name", text: part })
return None
}
if !feature_name_ok(feature) {
warnings.push({
index,
message: "feature name contains unusual characters",
text: part,
})
}
if !value.has_prefix("(") || !value.has_suffix(")") {
warnings.push({
index,
message: "allowlist should be wrapped in parentheses",
text: part,
})
return Some({
feature,
tokens: parse_token_list(value, index, warnings),
raw: part,
index,
})
}
let body = value[1:value.length() - 1].to_owned()
Some({
feature,
tokens: parse_token_list(body, index, warnings),
raw: part,
index,
})
}
}
}
///|
fn parse_legacy_directive(
part : String,
index : Int,
warnings : Array[ParseWarning],
) -> Directive? {
match part.find(" ") {
None => {
let feature = normalize_feature(part)
if feature == "" {
warnings.push({
index,
message: "empty legacy feature name",
text: part,
})
None
} else {
warnings.push({
index,
message: "legacy directive has no allowlist; treating as disabled",
text: part,
})
Some({ feature, tokens: [], raw: part, index })
}
}
Some(space) => {
let feature = normalize_feature(part[:space].to_owned())
let value = trim(part[space + 1:].to_owned())
if feature == "" {
warnings.push({
index,
message: "empty legacy feature name",
text: part,
})
None
} else {
warnings.push({
index,
message: "legacy Feature-Policy syntax parsed for migration",
text: part,
})
Some({
feature,
tokens: parse_token_list(value, index, warnings),
raw: part,
index,
})
}
}
}
}
///|
fn parse_token_list(
body : String,
index : Int,
warnings : Array[ParseWarning],
) -> Array[AllowToken] {
let tokens : Array[AllowToken] = []
let mut saw_none = false
for token_view in body.split(" ") {
let token = strip_quotes(trim(token_view.to_owned())).to_lower()
if token == "" {
continue
}
match token {
"*" => tokens.push(TokenAll)
"self" => tokens.push(TokenSelf)
"'self'" => tokens.push(TokenSelf)
"none" | "'none'" => saw_none = true
_ => {
if !origin_like(token) {
warnings.push({
index,
message: "allowlist token is not self, none, wildcard, or an origin",
text: token,
})
}
tokens.push(TokenOrigin(token))
}
}
}
if saw_none && tokens.length() > 0 {
warnings.push({
index,
message: "none is mixed with other allowlist tokens; none wins",
text: body,
})
[]
} else {
tokens
}
}
///|
fn tokens_allow(
tokens : Array[AllowToken],
document_origin : String,
target_origin : String,
) -> Bool {
for token in tokens {
match token {
TokenAll => return true
TokenSelf => if document_origin == target_origin { return true }
TokenOrigin(origin) =>
if normalize_origin(origin) == target_origin {
return true
}
}
}
false
}
///|
fn render_directive(directive : Directive) -> String {
if directive.tokens.length() == 0 {
directive.feature + "=()"
} else {
let tokens : Array[String] = []
for token in directive.tokens {
tokens.push(render_token(token))
}
directive.feature + "=(" + tokens.join(" ") + ")"
}
}
///|
fn render_token(token : AllowToken) -> String {
match token {
TokenAll => "*"
TokenSelf => "self"
TokenOrigin(origin) => "\"" + normalize_origin(origin) + "\""
}
}
///|
fn directive_from_intent(intent : PolicyIntent, index : Int) -> Directive {
match intent {
IntentDeny(feature) =>
{ feature: normalize_feature(feature), tokens: [], raw: "", index }
IntentSelf(feature) =>
{
feature: normalize_feature(feature),
tokens: [TokenSelf],
raw: "",
index,
}
IntentAll(feature) =>
{
feature: normalize_feature(feature),
tokens: [TokenAll],
raw: "",
index,
}
IntentOrigins(feature, origins) => {
let tokens : Array[AllowToken] = []
tokens.push(TokenSelf)
for origin in origins {
tokens.push(TokenOrigin(normalize_origin(origin)))
}
{ feature: normalize_feature(feature), tokens, raw: "", index }
}
IntentOriginsOnly(feature, origins) => {
let tokens : Array[AllowToken] = []
for origin in origins {
tokens.push(TokenOrigin(normalize_origin(origin)))
}
{ feature: normalize_feature(feature), tokens, raw: "", index }
}
}
}
///|
fn feature_spec(
feature : String,
category : FeatureCategory,
risk : RiskLevel,
recommended : PolicyIntent,
rationale : String,
) -> FeatureSpec {
{
feature: normalize_feature(feature),
category,
risk,
recommended,
rationale,
}
}
///|
fn recommended_intents() -> Array[PolicyIntent] {
let intents : Array[PolicyIntent] = []
for spec in known_features() {
intents.push(spec.recommended)
}
intents
}
///|
fn profile_intent(
profile : BaselineProfile,
feature : String,
trusted_origins : Array[String],
) -> PolicyIntent {
match profile {
ProfileStrict => deny(feature)
ProfileBalanced =>
match known_feature(feature) {
Some(spec) => spec.recommended
None => deny(feature)
}
ProfileMediaApp => media_profile_intent(feature)
ProfileDeviceLab => device_lab_profile_intent(feature, trusted_origins)
}
}
///|
fn media_profile_intent(feature : String) -> PolicyIntent {
match normalize_feature(feature) {
"autoplay"
| "encrypted-media"
| "fullscreen"
| "speaker-selection"
| "web-share" => self_only(feature)
_ => deny(feature)
}
}
///|
fn device_lab_profile_intent(
feature : String,
trusted_origins : Array[String],
) -> PolicyIntent {
match normalize_feature(feature) {
"bluetooth" | "hid" | "serial" | "usb" =>
self_and_origins(feature, trusted_origins)
"clipboard-write" | "fullscreen" | "web-share" => self_only(feature)
_ => deny(feature)
}
}
///|
fn profile_marks_sensitive(
profile : BaselineProfile,
spec : FeatureSpec,
) -> Bool {
match profile {
ProfileStrict => true
ProfileBalanced => spec.risk != RiskLow
ProfileMediaApp => spec.risk != RiskLow
ProfileDeviceLab => spec.risk != RiskLow
}
}
///|
fn profile_requires_cross_site_guard(
profile : BaselineProfile,
feature : String,
) -> Bool {
match profile {
ProfileStrict => true
ProfileBalanced =>
match known_feature(feature) {
Some(spec) => spec.risk != RiskLow || intent_disables(spec.recommended)
None => true
}
ProfileMediaApp =>
match normalize_feature(feature) {
"autoplay"
| "encrypted-media"
| "fullscreen"
| "speaker-selection"
| "web-share" => false
_ => true
}
ProfileDeviceLab =>
match normalize_feature(feature) {
"bluetooth"
| "hid"
| "serial"
| "usb"
| "clipboard-write"
| "fullscreen"
| "web-share" => false
_ => true
}
}
}
///|
fn intent_disables(intent : PolicyIntent) -> Bool {
match intent {
IntentDeny(_) => true
_ => false
}
}
///|
fn directive_shape(directive : Directive) -> String {
if directive.tokens.length() == 0 {
return "disabled"
}
if has_wildcard(directive.tokens) {
return "wildcard"
}
if directive.tokens.length() == 1 && directive.tokens[0] == TokenSelf {
return "self"
}
"explicit"
}
///|
fn classify_diff(before : Directive, after : Directive) -> DiffKind {
let before_power = allow_power(before.tokens)
let after_power = allow_power(after.tokens)
if after_power > before_power {
DiffLoosened
} else if after_power < before_power {
DiffTightened
} else {
DiffChanged
}
}
///|
fn allow_power(tokens : Array[AllowToken]) -> Int {
let mut score = tokens.length()
for token in tokens {
match token {
TokenAll => return 10000
TokenSelf => score = score + 10
TokenOrigin(_) => score = score + 20
}
}
score
}
///|
fn diff_label(kind : DiffKind) -> String {
match kind {
DiffAdded => "added"
DiffRemoved => "removed"
DiffChanged => "changed"
DiffLoosened => "loosened"
DiffTightened => "tightened"
}
}
///|
fn audit_directive(
directive : Directive,
baseline : Baseline,
findings : Array[Finding],
) -> Unit {
if directive.tokens.length() == 0 {
findings.push({
severity: SeverityInfo,
code: "feature-disabled",
feature: Some(directive.feature),
message: "feature is explicitly disabled",
})
return
}
if feature_in(baseline.sensitive_features, directive.feature) &&
has_wildcard(directive.tokens) {
findings.push({
severity: SeverityHigh,
code: "sensitive-wildcard",
feature: Some(directive.feature),
message: "sensitive browser capability is allowed for every origin",
})
}
for token in directive.tokens {
match token {
TokenOrigin(origin) =>
if insecure_external_origin(origin) {
findings.push({
severity: SeverityWarning,
code: "insecure-origin",
feature: Some(directive.feature),
message: "allowlist origin should use HTTPS unless it is localhost",
})
}
_ => ()
}
}
}
///|
fn has_wildcard(tokens : Array[AllowToken]) -> Bool {
for token in tokens {
if token == TokenAll {
return true
}
}
false
}
///|
fn has_high(findings : Array[Finding]) -> Bool {
for finding in findings {
if finding.severity == SeverityHigh {
return true
}
}
false
}
///|
fn feature_in(features : Array[String], feature : String) -> Bool {
for candidate in features {
if normalize_feature(candidate) == feature {
return true
}
}
false
}
///|
fn insecure_external_origin(origin : String) -> Bool {
let clean = normalize_origin(origin)
clean.has_prefix("http://") &&
!clean.has_prefix("http://localhost") &&
!clean.has_prefix("http://127.0.0.1")
}
///|
fn severity_label(severity : Severity) -> String {
match severity {
SeverityInfo => "info"
SeverityWarning => "warning"
SeverityHigh => "high"
}
}
///|
fn joined_header_value(
headers : HeaderSet,
kind : HeaderKind,
separator : String,
) -> String? {
let values : Array[String] = []
for line in headers.lines {
if line.kind == kind {
values.push(line.value)
}
}
if values.length() == 0 {
None
} else {
Some(values.join(separator))
}
}
///|
fn prepend_warnings(policy : Policy, warnings : Array[ParseWarning]) -> Policy {
if warnings.length() == 0 {
return policy
}
let merged : Array[ParseWarning] = []
for warning in warnings {
merged.push(warning)
}
for warning in policy.warnings {
merged.push(warning)
}
{ directives: policy.directives, warnings: merged }
}
///|
fn normalize_header_name(value : String) -> String {
trim(value).to_lower()
}
///|
fn classify_header_name(name : String) -> HeaderKind {
match normalize_header_name(name) {
"permissions-policy" => HeaderPermissionsPolicy
"feature-policy" => HeaderFeaturePolicy
_ => HeaderOther
}
}
///|
fn has_feature(directives : Array[Directive], feature : String) -> Bool {
for directive in directives {
if directive.feature == feature {
return true
}
}
false
}
///|
fn normalize_feature(value : String) -> String {
trim(value).to_lower()
}
///|
fn normalize_origin(value : String) -> String {
let clean = strip_quotes(trim(value)).to_lower()
if clean.has_suffix("/") {
clean[:clean.length() - 1].to_owned()
} else {
clean
}
}
///|
fn strip_quotes(value : String) -> String {
let clean = trim(value)
if clean.length() >= 2 &&
(
(clean.has_prefix("\"") && clean.has_suffix("\"")) ||
(clean.has_prefix("'") && clean.has_suffix("'"))
) {
clean[1:clean.length() - 1].to_owned()
} else {
clean
}
}
///|
fn origin_like(value : String) -> Bool {
value.has_prefix("https://") ||
value.has_prefix("http://localhost") ||
value.has_prefix("http://127.0.0.1")
}
///|
fn feature_name_ok(feature : String) -> Bool {
if feature == "" {
return false
}
for ch in feature {
if ch == '-' || ch.is_ascii_digit() {
continue
}
let piece = ch.to_string()
if piece < "a" || piece > "z" {
return false
}
}
true
}
///|
fn trim(value : String) -> String {
value.trim(chars=" \t\r\n").to_owned()
}