///|
/// A portable baseline key. It deliberately contains no project paths.
pub struct BaselineEntry {
advisory_id : String
package_name : String
version : String
affected : String?
severity : String?
fixed_version : String?
} derive(Debug, Eq, ToJson, FromJson)
///|
/// A validated, versioned snapshot of affected direct dependencies.
pub struct ScanBaseline {
schema_version : String
entries : Array[BaselineEntry]
} derive(Debug, Eq, ToJson, FromJson)
///|
/// Whether an affected finding is new or already present in the supplied snapshot.
pub enum BaselineFindingStatus {
New
Existing
Updated
} derive(Debug, Eq, ToJson)
///|
/// Summary of a baseline comparison, including keys no longer observed.
pub struct BaselineSummary {
snapshot_entries : Int32
new_findings : Int32
existing_findings : Int32
updated_findings : Int32
legacy_snapshot : Bool
no_longer_detected : Array[BaselineEntry]
} derive(Debug, Eq, ToJson)
///|
pub(all) suberror BaselineValidationError {
UnsupportedBaselineSchemaVersion(String)
InvalidBaselineField(field~ : String, reason~ : String)
InvalidBaselineEntry(index~ : Int32, field~ : String, reason~ : String)
DuplicateBaselineEntry(index~ : Int32, advisory_id~ : String)
} derive(Debug, Eq)
///|
/// Parse and strictly validate a schema-v1 or schema-v2 baseline snapshot.
pub fn parse_baseline(text : String) -> ScanBaseline raise {
let json = @json.parse(text)
validate_baseline_shape(json)
let baseline : ScanBaseline = @json.from_json(json)
validate_baseline(baseline)
baseline
}
///|
/// Validate a baseline created by a caller or deserializer.
pub fn validate_baseline(baseline : ScanBaseline) -> Unit raise {
if baseline.schema_version != "1" && baseline.schema_version != "2" {
raise BaselineValidationError::UnsupportedBaselineSchemaVersion(
baseline.schema_version,
)
}
for index, entry in baseline.entries {
if entry.advisory_id.trim() == "" {
raise BaselineValidationError::InvalidBaselineEntry(
index~,
field="advisory_id",
reason="must not be empty",
)
}
if entry.package_name.trim() == "" {
raise BaselineValidationError::InvalidBaselineEntry(
index~,
field="package_name",
reason="must not be empty",
)
}
if !is_stable_version(entry.version) {
raise BaselineValidationError::InvalidBaselineEntry(
index~,
field="version",
reason="must be a stable three-part version",
)
}
if baseline.schema_version == "1" {
if entry.affected is Some(_) ||
entry.severity is Some(_) ||
entry.fixed_version is Some(_) {
raise BaselineValidationError::InvalidBaselineEntry(
index~,
field="risk_evidence",
reason="is not supported by schema version 1",
)
}
} else {
match entry.affected {
Some(affected) if is_supported_affected_range(affected) => ()
Some(_) =>
raise BaselineValidationError::InvalidBaselineEntry(
index~,
field="affected",
reason="must use the supported stable three-part range syntax",
)
None =>
raise BaselineValidationError::InvalidBaselineEntry(
index~,
field="affected",
reason="is required by schema version 2",
)
}
match entry.severity {
Some(severity) if is_policy_severity(severity) => ()
Some(_) =>
raise BaselineValidationError::InvalidBaselineEntry(
index~,
field="severity",
reason="must be one of critical, high, medium, low, or info",
)
None =>
raise BaselineValidationError::InvalidBaselineEntry(
index~,
field="severity",
reason="is required by schema version 2",
)
}
match entry.fixed_version {
Some(fixed_version) if fixed_version == "" ||
is_stable_version(fixed_version) => ()
Some(_) =>
raise BaselineValidationError::InvalidBaselineEntry(
index~,
field="fixed_version",
reason="must be empty or a stable three-part version",
)
None =>
raise BaselineValidationError::InvalidBaselineEntry(
index~,
field="fixed_version",
reason="is required by schema version 2",
)
}
}
for previous_index in 0.. ScanBaseline {
let entries : Array[BaselineEntry] = []
for finding in report.findings {
if finding.status is Affected {
let entry = {
advisory_id: finding.advisory.id,
package_name: finding.dependency.name,
version: finding.dependency.version,
affected: Some(finding.advisory.affected),
severity: Some(finding.advisory.severity),
fixed_version: Some(finding.advisory.fixed_version),
}
if !entries.any(fn(existing) { baseline_entry_equal(existing, entry) }) {
entries.push(entry)
}
}
}
entries.sort_by(baseline_entry_compare)
{ schema_version: "2", entries, }
}
///|
/// Serialize a validated snapshot as stable, indented JSON.
pub fn baseline_json(baseline : ScanBaseline) -> String raise {
validate_baseline(baseline)
let entries = baseline.entries.map(fn(entry) {
let fields : Map[String, Json] = {
"advisory_id": Json::string(entry.advisory_id),
"package_name": Json::string(entry.package_name),
"version": Json::string(entry.version),
}
if baseline.schema_version == "2" {
fields["affected"] = Json::string(entry.affected.unwrap())
fields["severity"] = Json::string(entry.severity.unwrap())
fields["fixed_version"] = Json::string(entry.fixed_version.unwrap())
}
Json::object(fields)
})
Json::object({
"schema_version": Json::string(baseline.schema_version),
"entries": Json::array(entries),
}).stringify(indent=2)
}
///|
/// Mark findings against a validated snapshot and report snapshot keys no
/// longer present in the current affected findings.
pub fn apply_baseline(
report : ScanReport,
baseline : ScanBaseline,
) -> ScanReport raise {
validate_baseline(baseline)
let matched : Array[Bool] = baseline.entries.map(fn(_) { false })
let findings : Array[Finding] = []
let mut new_findings = 0
let mut existing_findings = 0
let mut updated_findings = 0
for finding in report.findings {
let mut existing_index : Int32? = None
for index, entry in baseline.entries {
if baseline_entry_matches_finding(entry, finding) {
existing_index = Some(index)
}
}
let baseline_status = match existing_index {
Some(index) => {
matched[index] = true
if baseline_entry_risk_matches_finding(baseline.entries[index], finding) {
existing_findings += 1
Existing
} else {
updated_findings += 1
Updated
}
}
None => {
new_findings += 1
New
}
}
findings.push({
dependency: finding.dependency,
advisory: finding.advisory,
status: finding.status,
suppression: finding.suppression,
baseline_status: Some(baseline_status),
})
}
let no_longer_detected : Array[BaselineEntry] = []
for index, entry in baseline.entries {
if !matched[index] {
no_longer_detected.push(entry)
}
}
no_longer_detected.sort_by(baseline_entry_compare)
{
schema_version: "6",
dependencies: report.dependencies,
findings,
uncomparable: report.uncomparable,
conflicts: report.conflicts,
policy: report.policy,
baseline: Some({
snapshot_entries: baseline.entries.length(),
new_findings,
existing_findings,
updated_findings,
legacy_snapshot: baseline.schema_version == "1",
no_longer_detected,
}),
}
}
///|
fn validate_baseline_shape(json : Json) -> Unit raise {
match json {
Object(root) => {
match root.get("schema_version") {
Some(String(_)) => ()
None =>
raise BaselineValidationError::InvalidBaselineField(
field="schema_version",
reason="is required",
)
_ =>
raise BaselineValidationError::InvalidBaselineField(
field="schema_version",
reason="must be a string",
)
}
match root.get("entries") {
Some(Array(entries)) =>
for index, entry in entries {
match entry {
Object(fields) => {
let schema_version = match root.get("schema_version") {
Some(String(value)) => value
_ => ""
}
let required_fields = if schema_version == "2" {
[
"advisory_id", "package_name", "version", "affected", "severity",
"fixed_version",
]
} else {
["advisory_id", "package_name", "version"]
}
for field in required_fields {
match fields.get(field) {
Some(String(_)) => ()
None =>
raise BaselineValidationError::InvalidBaselineEntry(
index~,
field~,
reason="is required",
)
_ =>
raise BaselineValidationError::InvalidBaselineEntry(
index~,
field~,
reason="must be a string",
)
}
}
}
_ =>
raise BaselineValidationError::InvalidBaselineEntry(
index~,
field="$",
reason="must be an object",
)
}
}
None =>
raise BaselineValidationError::InvalidBaselineField(
field="entries",
reason="is required and must be an array",
)
_ =>
raise BaselineValidationError::InvalidBaselineField(
field="entries",
reason="must be an array",
)
}
}
_ =>
raise BaselineValidationError::InvalidBaselineField(
field="$",
reason="must be a JSON object",
)
}
}
///|
fn baseline_entry_equal(left : BaselineEntry, right : BaselineEntry) -> Bool {
left.advisory_id == right.advisory_id &&
left.package_name == right.package_name &&
left.version == right.version
}
///|
fn baseline_entry_compare(left : BaselineEntry, right : BaselineEntry) -> Int32 {
let advisory = lexicographic_compare(left.advisory_id, right.advisory_id)
if advisory != 0 {
return advisory
}
let package_order = lexicographic_compare(
left.package_name,
right.package_name,
)
if package_order != 0 {
return package_order
}
lexicographic_compare(left.version, right.version)
}
///|
fn baseline_entry_matches_finding(
entry : BaselineEntry,
finding : Finding,
) -> Bool {
entry.advisory_id == finding.advisory.id &&
entry.package_name == finding.dependency.name &&
entry.version == finding.dependency.version
}
///|
fn baseline_entry_risk_matches_finding(
entry : BaselineEntry,
finding : Finding,
) -> Bool {
match (entry.affected, entry.severity, entry.fixed_version) {
(Some(affected), Some(severity), Some(fixed_version)) =>
affected == finding.advisory.affected &&
severity == finding.advisory.severity &&
fixed_version == finding.advisory.fixed_version
_ => true
}
}