///|
/// Offline dependency vulnerability matching for MoonBit projects.
pub struct Dependency {
name : String
version : String
source : String
aliases : Array[String]
scopes : Array[DependencyScope]
} derive(Debug, Eq, ToJson)
///|
pub fn Dependency::Dependency(
name~ : String,
version~ : String,
source~ : String,
aliases~ : Array[String] = [],
scopes~ : Array[DependencyScope] = [],
) -> Dependency {
{ name, version, source, aliases, scopes, }
}
///|
/// A declared module with multiple versions in the scanned project tree.
pub struct DependencyConflict {
name : String
versions : Array[String]
sources : Array[String]
} derive(Debug, Eq, ToJson)
///|
/// A deterministic, de-duplicated dependency inventory.
pub struct Inventory {
dependencies : Array[Dependency]
conflicts : Array[DependencyConflict]
} derive(Debug, Eq, ToJson)
///|
/// Normalise declarations into a deterministic inventory.
///
/// Declarations with the same package name and version are represented once.
/// A canonical manifest path becomes the evidence location.
pub fn inventory_from_dependencies(
dependencies : Array[Dependency],
) -> Inventory {
let normalized : Array[Dependency] = []
for dependency in dependencies {
let mut duplicate : Int32? = None
for index in 0.. {
let existing = normalized[index]
let aliases = merge_strings(existing.aliases, dependency.aliases)
let scopes = merge_scopes(existing.scopes, dependency.scopes)
let source = if lexicographic_compare(
dependency.source,
existing.source,
) <
0 {
dependency.source
} else {
existing.source
}
normalized[index] = Dependency(
name=existing.name,
version=existing.version,
source~,
aliases~,
scopes~,
)
}
None =>
normalized.push(
Dependency(
name=dependency.name,
version=dependency.version,
source=dependency.source,
aliases=merge_strings([], dependency.aliases),
scopes=merge_scopes([], dependency.scopes),
),
)
}
}
normalized.sort_by(dependency_compare)
{ dependencies: normalized, conflicts: dependency_conflicts(normalized), }
}
///|
/// Find modules declared at more than one version and sort their evidence.
pub fn dependency_conflicts(
dependencies : Array[Dependency],
) -> Array[DependencyConflict] {
let conflicts : Array[DependencyConflict] = []
let seen : Map[String, Bool] = Map([])
for dependency in dependencies {
if !seen.contains(dependency.name) {
seen[dependency.name] = true
let versions : Array[String] = []
let sources : Array[String] = []
for candidate in dependencies {
if candidate.name == dependency.name {
if !versions.contains(candidate.version) {
versions.push(candidate.version)
}
if !sources.contains(candidate.source) {
sources.push(candidate.source)
}
}
}
if versions.length() > 1 {
versions.sort_by(lexicographic_compare)
sources.sort_by(lexicographic_compare)
conflicts.push({ name: dependency.name, versions, sources, })
}
}
}
conflicts
}
///|
fn merge_scopes(
left : Array[DependencyScope],
right : Array[DependencyScope],
) -> Array[DependencyScope] {
let result : Array[DependencyScope] = []
for scope in [Runtime, Test, WhiteboxTest, Other] {
if left.contains(scope) || right.contains(scope) {
result.push(scope)
}
}
result
}
///|
fn merge_strings(left : Array[String], right : Array[String]) -> Array[String] {
let result : Array[String] = []
for value in left {
if value != "" && !result.contains(value) {
result.push(value)
}
}
for value in right {
if value != "" && !result.contains(value) {
result.push(value)
}
}
result.sort_by(lexicographic_compare)
result
}
///|
fn dependency_compare(left : Dependency, right : Dependency) -> Int32 {
let by_name = lexicographic_compare(left.name, right.name)
if by_name != 0 {
by_name
} else {
let by_version = lexicographic_compare(left.version, right.version)
if by_version != 0 {
by_version
} else {
lexicographic_compare(left.source, right.source)
}
}
}
///|
fn lexicographic_compare(left : String, right : String) -> Int32 {
let left_chars = left.to_array()
let right_chars = right.to_array()
let common_length = if left_chars.length() < right_chars.length() {
left_chars.length()
} else {
right_chars.length()
}
for index in 0.. right_chars.length() {
1
} else {
0
}
}
///|
pub struct Advisory {
id : String
package_name : String
affected : String
severity : String
summary : String
fixed_version : String
references : Array[String]
} derive(Debug, Eq, ToJson, FromJson)
///|
pub struct AdvisoryDatabase {
schema_version : String
advisories : Array[Advisory]
} derive(Debug, Eq, ToJson, FromJson)
///|
pub enum FindingStatus {
Affected
NotAffected
Uncomparable
} derive(Debug, Eq, ToJson)
///|
pub struct Finding {
dependency : Dependency
advisory : Advisory
status : FindingStatus
suppression : PolicySuppression?
baseline_status : BaselineFindingStatus?
} derive(Debug, Eq, ToJson)
///|
pub struct ScanReport {
schema_version : String
dependencies : Array[Dependency]
findings : Array[Finding]
uncomparable : Array[Finding]
conflicts : Array[DependencyConflict]
policy : PolicySummary?
baseline : BaselineSummary?
} derive(Debug, Eq, ToJson)
///|
pub fn parse_database(text : String) -> AdvisoryDatabase raise {
let json = @json.parse(text)
validate_database_shape(json)
let database : AdvisoryDatabase = @json.from_json(json)
validate_database(database)
database
}
///|
pub fn parse_manifest(
text : String,
source~ : String,
) -> Array[Dependency] raise {
let object : Map[String, Json] = @json.from_json(@json.parse(text))
let result : Array[Dependency] = []
match object.get("import") {
Some(Object(imports)) =>
for name, value in imports {
match value {
String(version) =>
result.push({ name, version, source, aliases: [], scopes: [], })
_ =>
raise ManifestParseError::InvalidManifest(
source~,
line=1,
reason="dependency \{name} must declare a string version",
)
}
}
_ => ()
}
result
}
///|
pub fn scan(
dependencies : Array[Dependency],
database : AdvisoryDatabase,
) -> ScanReport raise {
validate_database(database)
let findings : Array[Finding] = []
let uncomparable : Array[Finding] = []
for dependency in dependencies {
for advisory in database.advisories {
if dependency.name == advisory.package_name {
let status = match_version(dependency.version, advisory.affected)
let finding = {
dependency,
advisory,
status,
suppression: None,
baseline_status: None,
}
match status {
Affected => findings.push(finding)
Uncomparable => uncomparable.push(finding)
NotAffected => ()
}
}
}
}
{
schema_version: "6",
dependencies,
findings,
uncomparable,
conflicts: dependency_conflicts(dependencies),
policy: None,
baseline: None,
}
}
///|
pub fn report_json(report : ScanReport) -> String {
@json.to_json(report).stringify(indent=2)
}
///|
pub fn report_terminal(report : ScanReport) -> String {
let lines = [
"MoonVigil offline scan",
"Report schema: v\{report.schema_version}",
"Dependencies: \{report.dependencies.length()}",
"Affected: \{report.findings.length()}",
"Uncomparable: \{report.uncomparable.length()}",
"Conflicting modules: \{report.conflicts.length()}",
]
for conflict in report.conflicts {
let versions = conflict.versions.join(", ")
let sources = conflict.sources.join(", ")
lines.push(
"dependency conflict: \{conflict.name} versions \{versions} (sources: \{sources})",
)
}
match report.policy {
Some(policy) => {
lines.push(
"Policy: fail on \{policy.fail_on}; scopes \{policy.fail_on_scopes.join(",")}; \{policy.blocking_findings} blocking, \{policy.scope_excluded_findings} out of scope, \{policy.suppressed_findings} suppressed",
)
for suppression in policy.unmatched_suppressions {
lines.push(
"Warning: suppression did not match \{suppression.advisory_id} \{suppression.package_name}@\{suppression.version} (expires \{suppression.expires_on})",
)
}
}
None => ()
}
match report.baseline {
Some(baseline) => {
lines.push(
"Baseline: \{baseline.new_findings} new, \{baseline.existing_findings} existing, \{baseline.updated_findings} updated, \{baseline.no_longer_detected.length()} no longer detected",
)
if baseline.legacy_snapshot {
lines.push(
"Warning: schema-v1 baseline has no advisory risk evidence; risk changes cannot be detected for its entries.",
)
}
for entry in baseline.no_longer_detected {
lines.push(
"no longer detected: \{entry.advisory_id} \{entry.package_name}@\{entry.version}",
)
}
}
None => ()
}
for finding in report.findings {
lines.push(terminal_finding(finding, "affected"))
}
for finding in report.uncomparable {
lines.push(terminal_finding(finding, "uncomparable"))
}
lines.join("\n")
}
///|
pub fn report_sarif(report : ScanReport) -> String {
let results : Array[Json] = []
let rules : Array[Json] = []
let seen_rules : Map[String, Bool] = Map([])
for finding in report.findings {
results.push(sarif_result(finding))
add_sarif_rule(rules, seen_rules, finding.advisory)
}
for finding in report.uncomparable {
results.push(sarif_result(finding))
add_sarif_rule(rules, seen_rules, finding.advisory)
}
let run_properties : Map[String, Json] = {
"dependencyConflicts": Json::array(
report.conflicts.map(dependency_conflict_json),
),
}
match report.policy {
Some(policy) => run_properties["policy"] = policy_summary_json(policy)
None => ()
}
match report.baseline {
Some(baseline) =>
run_properties["moonvigilBaseline"] = baseline_summary_json(baseline)
None => ()
}
Json::object({
"version": Json::string("2.1.0"),
"$schema": Json::string("https://json.schemastore.org/sarif-2.1.0.json"),
"runs": Json::array([
Json::object({
"tool": Json::object({
"driver": Json::object({
"name": Json::string("MoonVigil"),
"rules": Json::array(rules),
}),
}),
"results": Json::array(results),
"properties": Json::object(run_properties),
}),
]),
}).stringify(indent=2)
}
///|
fn dependency_conflict_json(conflict : DependencyConflict) -> Json {
Json::object({
"package": Json::string(conflict.name),
"versions": Json::array(conflict.versions.map(Json::string)),
"sources": Json::array(conflict.sources.map(Json::string)),
})
}
///|
fn policy_summary_json(policy : PolicySummary) -> Json {
Json::object({
"failOn": Json::string(policy.fail_on),
"failOnScopes": Json::array(policy.fail_on_scopes.map(Json::string)),
"blockingFindings": Json::number(policy.blocking_findings.to_double()),
"scopeExcludedFindings": Json::number(
policy.scope_excluded_findings.to_double(),
),
"suppressedFindings": Json::number(policy.suppressed_findings.to_double()),
"unmatchedSuppressions": Json::array(
policy.unmatched_suppressions.map(@json.to_json),
),
})
}
///|
fn baseline_summary_json(baseline : BaselineSummary) -> Json {
Json::object({
"snapshotEntries": Json::number(baseline.snapshot_entries.to_double()),
"newFindings": Json::number(baseline.new_findings.to_double()),
"existingFindings": Json::number(baseline.existing_findings.to_double()),
"updatedFindings": Json::number(baseline.updated_findings.to_double()),
"legacySnapshot": Json::boolean(baseline.legacy_snapshot),
"noLongerDetected": Json::array(
baseline.no_longer_detected.map(@json.to_json),
),
})
}
///|
fn terminal_finding(finding : Finding, status : String) -> String {
let display_status = match (finding.suppression, finding.baseline_status) {
(Some(_), Some(New)) => "suppressed, new"
(Some(_), Some(Existing)) => "suppressed, existing"
(Some(_), Some(Updated)) => "suppressed, updated"
(Some(_), None) => "suppressed"
(None, Some(New)) => "new"
(None, Some(Existing)) => "existing"
(None, Some(Updated)) => "updated"
(None, None) => status
}
let fixed_version = if finding.advisory.fixed_version == "" {
"not available"
} else {
finding.advisory.fixed_version
}
let references = if finding.advisory.references.length() == 0 {
"none"
} else {
finding.advisory.references.join(", ")
}
(
$|[\{display_status}] [\{finding.advisory.severity}] \{finding.advisory.id}
$| package: \{finding.dependency.name}
$| installed: \{finding.dependency.version}
$| affected: \{finding.advisory.affected}
$| fixed: \{fixed_version}
$| source: \{finding.dependency.source}
$| aliases: \{finding.dependency.aliases.join(", ")}
$| scopes: \{dependency_scopes_text(finding.dependency.scopes)}
$| summary: \{finding.advisory.summary}
$| references: \{references}
$|\{suppression_text(finding.suppression)}
)
}
///|
fn suppression_text(suppression : PolicySuppression?) -> String {
match suppression {
Some(value) =>
" suppression: \{value.reason} (expires \{value.expires_on})"
None => ""
}
}
///|
fn dependency_scopes_text(scopes : Array[DependencyScope]) -> String {
if scopes.length() == 0 {
"unclassified"
} else {
scopes.map(dependency_scope_name).join(", ")
}
}
///|
fn sarif_result(finding : Finding) -> Json {
let level = match finding.status {
Affected => sarif_level(finding.advisory.severity)
Uncomparable => "note"
NotAffected => "none"
}
let message = match finding.status {
Affected => finding.advisory.summary
Uncomparable =>
"MoonVigil could not compare the installed version against this advisory range."
NotAffected => finding.advisory.summary
}
let properties : Map[String, Json] = {
"package": Json::string(finding.dependency.name),
"installedVersion": Json::string(finding.dependency.version),
"affectedRange": Json::string(finding.advisory.affected),
"fixedVersion": Json::string(finding.advisory.fixed_version),
"aliases": Json::array(finding.dependency.aliases.map(Json::string)),
"scopes": Json::array(
finding.dependency.scopes.map(fn(scope) {
Json::string(dependency_scope_name(scope))
}),
),
"references": Json::array(finding.advisory.references.map(Json::string)),
"matchStatus": Json::string(match_status_name(finding.status)),
}
let result : Map[String, Json] = {
"ruleId": Json::string(finding.advisory.id),
"level": Json::string(level),
"message": Json::object({ "text": Json::string(message) }),
"locations": Json::array([
Json::object({
"physicalLocation": Json::object({
"artifactLocation": Json::object({
"uri": Json::string(sarif_artifact_uri(finding.dependency.source)),
}),
}),
}),
]),
}
match finding.suppression {
Some(suppression) => {
properties["suppressionReason"] = Json::string(suppression.reason)
properties["suppressionExpiresOn"] = Json::string(suppression.expires_on)
result["suppressions"] = Json::array([
Json::object({
"kind": Json::string("external"),
"status": Json::string("accepted"),
"justification": Json::string(suppression.reason),
}),
])
}
None => ()
}
match finding.baseline_status {
Some(New) => result["baselineState"] = Json::string("new")
Some(Existing) => result["baselineState"] = Json::string("unchanged")
Some(Updated) => result["baselineState"] = Json::string("updated")
None => ()
}
result["properties"] = Json::object(properties)
Json::object(result)
}
///|
fn add_sarif_rule(
rules : Array[Json],
seen_rules : Map[String, Bool],
advisory : Advisory,
) -> Unit {
if !seen_rules.contains(advisory.id) {
seen_rules[advisory.id] = true
let rule : Map[String, Json] = {
"id": Json::string(advisory.id),
"shortDescription": Json::object({
"text": Json::string(advisory.summary),
}),
"fullDescription": Json::object({ "text": Json::string(advisory.summary) }),
}
for reference in advisory.references {
if is_sarif_help_reference(reference) {
rule["helpUri"] = Json::string(reference.trim().to_owned())
break
}
}
rules.push(Json::object(rule))
}
}
///|
// Keep optional help links within the ordinary HTTP URI subset. Every original
// reference remains available in result properties, including other URL forms.
fn is_sarif_help_reference(reference : String) -> Bool {
if !is_http_reference(reference) {
return false
}
let uri = reference.trim().to_owned()
let rest = uri.split("://").to_array().last().unwrap_or("").to_owned()
let authority = rest.split("/").to_array()[0].to_owned()
let authority = authority.split("?").to_array()[0].to_owned()
let authority = authority.split("#").to_array()[0].to_owned()
if authority.contains("@") {
return false
}
let chars = uri.to_array()
let mut fragment = false
let mut index = 0
while index < chars.length() {
match chars[index] {
'%' => {
if index + 2 >= chars.length() ||
!chars[index + 1:index + 3].all(fn(char) {
match char {
'0'..<='9' | 'a'..<='f' | 'A'..<='F' => true
_ => false
}
}) {
return false
}
index += 3
continue
}
'#' if !fragment => fragment = true
'a'..<='z'
| 'A'..<='Z'
| '0'..<='9'
| '-'
| '.'
| '_'
| '~'
| ':'
| '/'
| '?'
| '@'
| '!'
| '$'
| '&'
| '\''
| '('
| ')'
| '*'
| '+'
| ','
| ';'
| '=' => ()
_ => return false
}
index += 1
}
true
}
///|
fn match_status_name(status : FindingStatus) -> String {
match status {
Affected => "affected"
NotAffected => "not_affected"
Uncomparable => "uncomparable"
}
}
///|
pub fn sbom_json(dependencies : Array[Dependency]) -> String {
let components = inventory_from_dependencies(dependencies).dependencies.map(fn(
dependency,
) {
let package_name = percent_encode(dependency.name, allow_slash=true)
let version = if dependency.version == "" {
""
} else {
"@\{percent_encode(dependency.version)}"
}
Json::object({
"type": Json::string("library"),
"name": Json::string(dependency.name),
"version": Json::string(dependency.version),
"purl": Json::string("pkg:moonbit/\{package_name}\{version}"),
"properties": Json::array(dependency_properties(dependency)),
})
})
Json::object({
"bomFormat": Json::string("CycloneDX"),
"specVersion": Json::string("1.5"),
"version": Json::number(1),
"components": Json::array(components),
}).stringify(indent=2)
}
///|
fn sarif_artifact_uri(source : String) -> String {
let path = source.replace_all(old="\\", new="/")
if path.length() >= 3 && path[1] == ':' && path[2] == '/' {
"file:///\{path[0:1]}:\{percent_encode(path[2:].to_owned(), allow_slash=true)}"
} else if path.has_prefix("//") {
"file:\{percent_encode(path, allow_slash=true)}"
} else if path.has_prefix("/") {
"file://\{percent_encode(path, allow_slash=true)}"
} else {
percent_encode(path, allow_slash=true)
}
}
///|
// Escape URI data as UTF-8 octets, preserving path separators only when requested.
fn percent_encode(value : String, allow_slash~ : Bool = false) -> String {
let result = StringBuilder()
for char in value {
match char {
'a'..<='z' | 'A'..<='Z' | '0'..<='9' | '-' | '.' | '_' | '~' =>
result.write_char(char)
'/' if allow_slash => result.write_char(char)
_ => {
let codepoint = char.to_int()
if codepoint < 0x80 {
write_percent_octet(result, codepoint)
} else if codepoint < 0x800 {
write_percent_octet(result, 0xC0 + (codepoint >> 6))
write_percent_octet(result, 0x80 + (codepoint & 0x3F))
} else if codepoint < 0x10000 {
write_percent_octet(result, 0xE0 + (codepoint >> 12))
write_percent_octet(result, 0x80 + ((codepoint >> 6) & 0x3F))
write_percent_octet(result, 0x80 + (codepoint & 0x3F))
} else {
write_percent_octet(result, 0xF0 + (codepoint >> 18))
write_percent_octet(result, 0x80 + ((codepoint >> 12) & 0x3F))
write_percent_octet(result, 0x80 + ((codepoint >> 6) & 0x3F))
write_percent_octet(result, 0x80 + (codepoint & 0x3F))
}
}
}
}
result.to_string()
}
///|
fn write_percent_octet(result : StringBuilder, value : Int32) -> Unit {
let hex = "0123456789ABCDEF".to_array()
result.write_char('%')
result.write_char(hex[value >> 4])
result.write_char(hex[value & 0x0F])
}
///|
fn dependency_properties(dependency : Dependency) -> Array[Json] {
let properties : Array[Json] = []
for alias_name in dependency.aliases {
properties.push(
Json::object({
"name": Json::string("moonvigil:import-alias"),
"value": Json::string(alias_name),
}),
)
}
for scope in dependency.scopes {
properties.push(
Json::object({
"name": Json::string("moonvigil:dependency-scope"),
"value": Json::string(dependency_scope_name(scope)),
}),
)
}
properties
}
///|
fn sarif_level(severity : String) -> String {
match severity.to_lower() {
"critical" | "high" => "error"
"medium" => "warning"
_ => "note"
}
}