///|
/// Repository-specific release policy loaded from `moonseal-policy.json`.
pub(all) struct AuditPolicy {
minimum_score : Int
minimum_source_lines : Int
maximum_warnings : Int
maximum_dependencies : Int
require_repository : Bool
require_ci : Bool
require_changelog : Bool
require_security_policy : Bool
require_pinned_dependencies : Bool
allowed_licenses : Array[String]
denied_licenses : Array[String]
required_files : Array[String]
} derive(Eq, Debug)
///|
/// One policy-specific violation. These use the `MP` prefix so they remain
/// distinguishable from built-in `MS` audit findings.
pub(all) struct PolicyViolation {
code : String
path : String
message : String
} derive(Eq, Debug)
///|
/// Result of applying repository policy to an audit.
pub(all) struct PolicyResult {
policy : AuditPolicy
violations : Array[PolicyViolation]
} derive(Eq, Debug)
///|
pub fn PolicyResult::is_allowed(self : PolicyResult) -> Bool {
self.violations.is_empty()
}
///|
pub fn default_policy() -> AuditPolicy {
{
minimum_score: 0,
minimum_source_lines: 1,
maximum_warnings: 2147483647,
maximum_dependencies: 2147483647,
require_repository: false,
require_ci: false,
require_changelog: false,
require_security_policy: false,
require_pinned_dependencies: false,
allowed_licenses: [],
denied_licenses: [],
required_files: [],
}
}
///|
fn policy_json_int(value : Json?, fallback : Int) -> Int {
match value {
Some(Number(number, ..)) => number.to_int()
_ => fallback
}
}
///|
fn policy_json_bool(value : Json?, fallback : Bool) -> Bool {
match value {
Some(true) => true
Some(false) => false
_ => fallback
}
}
///|
fn policy_json_strings(value : Json?) -> Array[String] {
let values = []
match value {
Some(Array(items)) =>
for item in items {
match item {
String(value) => values.push(value)
_ => ()
}
}
_ => ()
}
values
}
///|
/// Parse a MoonSeal policy document. Unknown keys are deliberately ignored so
/// that policy files remain forwards compatible.
pub fn parse_policy(text : StringView) -> AuditPolicy raise {
let value = @json.parse(text)
guard value is Object(fields) else {
fail("MoonSeal policy must be a JSON object")
}
let defaults = default_policy()
{
minimum_score: policy_json_int(
fields.get("minimumScore"),
defaults.minimum_score,
),
minimum_source_lines: policy_json_int(
fields.get("minimumSourceLines"),
defaults.minimum_source_lines,
),
maximum_warnings: policy_json_int(
fields.get("maximumWarnings"),
defaults.maximum_warnings,
),
maximum_dependencies: policy_json_int(
fields.get("maximumDependencies"),
defaults.maximum_dependencies,
),
require_repository: policy_json_bool(
fields.get("requireRepository"),
defaults.require_repository,
),
require_ci: policy_json_bool(fields.get("requireCi"), defaults.require_ci),
require_changelog: policy_json_bool(
fields.get("requireChangelog"),
defaults.require_changelog,
),
require_security_policy: policy_json_bool(
fields.get("requireSecurityPolicy"),
defaults.require_security_policy,
),
require_pinned_dependencies: policy_json_bool(
fields.get("requirePinnedDependencies"),
defaults.require_pinned_dependencies,
),
allowed_licenses: policy_json_strings(fields.get("allowedLicenses")),
denied_licenses: policy_json_strings(fields.get("deniedLicenses")),
required_files: policy_json_strings(fields.get("requiredFiles")),
}
}
///|
fn policy_normalize_path(path : StringView) -> String {
let normalized = path.replace_all(old="\\", new="/")
match normalized.strip_prefix("./") {
Some(relative) => relative.to_owned().to_lower()
None => normalized.to_owned().to_lower()
}
}
///|
fn policy_has_file(files : Array[String], expected : StringView) -> Bool {
let expected = policy_normalize_path(expected)
files.any(fn(file) { policy_normalize_path(file) == expected })
}
///|
fn policy_has_workflow(files : Array[String]) -> Bool {
files.any(fn(file) {
let path = policy_normalize_path(file)
path.has_prefix(".github/workflows/") &&
(path.has_suffix(".yml") || path.has_suffix(".yaml"))
})
}
///|
fn policy_has_changelog(files : Array[String]) -> Bool {
policy_has_file(files, "CHANGELOG.md") || policy_has_file(files, "CHANGES.md")
}
///|
fn policy_has_security(files : Array[String]) -> Bool {
policy_has_file(files, "SECURITY.md") ||
policy_has_file(files, ".github/SECURITY.md")
}
///|
fn policy_license_in(list : Array[String], license : StringView) -> Bool {
let normalized = normalize_license(license)
list.any(fn(candidate) { normalize_license(candidate) == normalized })
}
///|
fn add_policy_violation(
violations : Array[PolicyViolation],
code : String,
path : String,
message : String,
) -> Unit {
violations.push({ code, path, message })
}
///|
/// Apply repository policy to already-collected facts and an audit report.
pub fn evaluate_policy(
policy : AuditPolicy,
facts : ScanFacts,
report : AuditReport,
) -> PolicyResult {
let violations = []
if report.score < policy.minimum_score {
add_policy_violation(
violations,
"MP001",
"",
"Audit score \{report.score} is below required score \{policy.minimum_score}.",
)
}
if facts.moonbit_source_lines < policy.minimum_source_lines {
add_policy_violation(
violations,
"MP002",
"",
"MoonBit source has \{facts.moonbit_source_lines} lines; policy requires \{policy.minimum_source_lines}.",
)
}
if report.warning_count() > policy.maximum_warnings {
add_policy_violation(
violations,
"MP003",
"",
"Audit has \{report.warning_count()} warnings; policy allows \{policy.maximum_warnings}.",
)
}
if facts.project.dependencies.length() > policy.maximum_dependencies {
add_policy_violation(
violations,
"MP004",
"moon.mod",
"Project has \{facts.project.dependencies.length()} dependencies; policy allows \{policy.maximum_dependencies}.",
)
}
if policy.require_repository && facts.project.repository.is_empty() {
add_policy_violation(
violations, "MP005", "moon.mod", "Repository URL is required by policy.",
)
}
if policy.require_ci && !policy_has_workflow(facts.files) {
add_policy_violation(
violations, "MP006", ".github/workflows", "A GitHub Actions workflow is required by policy.",
)
}
if policy.require_changelog && !policy_has_changelog(facts.files) {
add_policy_violation(
violations, "MP007", "CHANGELOG.md", "A changelog is required by policy.",
)
}
if policy.require_security_policy && !policy_has_security(facts.files) {
add_policy_violation(
violations, "MP008", "SECURITY.md", "A security policy is required by policy.",
)
}
if !facts.project.license.is_empty() &&
!policy.allowed_licenses.is_empty() &&
!policy_license_in(policy.allowed_licenses, facts.project.license) {
add_policy_violation(
violations,
"MP009",
"moon.mod",
"License \{facts.project.license} is not in the policy allow list.",
)
}
if !facts.project.license.is_empty() &&
policy_license_in(policy.denied_licenses, facts.project.license) {
add_policy_violation(
violations,
"MP010",
"moon.mod",
"License \{facts.project.license} is denied by policy.",
)
}
if policy.require_pinned_dependencies {
for dependency in facts.project.dependencies {
if dependency.version.is_empty() {
add_policy_violation(
violations,
"MP011",
"moon.mod",
"Dependency \{dependency.name} does not have a pinned version.",
)
}
}
}
for required in policy.required_files {
if !policy_has_file(facts.files, required) {
add_policy_violation(
violations,
"MP012",
required,
"Required file is missing: \{required}.",
)
}
}
{ policy, violations }
}
///|
pub fn render_policy_text(result : PolicyResult) -> String {
let out = StringBuilder::new()
if result.is_allowed() {
out.write_string("POLICY PASSED All repository policy checks passed.\n")
} else {
out.write_string(
"POLICY BLOCKED \{result.violations.length()} violation(s)\n\n",
)
for violation in result.violations {
let path = if violation.path.is_empty() {
""
} else {
" [\{violation.path}]"
}
out.write_string("ERROR \{violation.code}\{path}: \{violation.message}\n")
}
}
out.to_string()
}
///|
pub fn render_policy_json(result : PolicyResult, indent? : Int = 2) -> String {
let violations : Array[Json] = result.violations.map(fn(violation) {
{
"code": violation.code,
"path": violation.path,
"message": violation.message,
}
})
let value : Json = {
"allowed": result.is_allowed(),
"summary": {
"violations": result.violations.length(),
"minimumScore": result.policy.minimum_score,
"minimumSourceLines": result.policy.minimum_source_lines,
"maximumWarnings": result.policy.maximum_warnings,
"maximumDependencies": result.policy.maximum_dependencies,
},
"violations": violations,
}
value.stringify(indent~)
}