///|
/// Parse JSON and collect STIX validation issues.
pub fn parse_and_validate(
text : String,
) -> Result[(Document, Array[Issue]), StixError] {
let doc = match parse_document(text) {
Ok(value) => value
Err(err) => return Err(err)
}
Ok((doc, validate_document(doc)))
}
///|
pub fn validate_document(doc : Document) -> Array[Issue] {
let issues : Array[Issue] = []
match doc {
Object(obj) => validate_object(obj, "", issues)
Bundle(bundle) => validate_bundle(bundle, issues)
}
issues
}
///|
fn validate_bundle(bundle : StixBundle, issues : Array[Issue]) -> Unit {
match parse_stix_id(bundle.id) {
Err(err) => issues.push(issue("id", "id", err))
Ok(parsed) =>
if parsed.type_name != "bundle" {
issues.push(issue("id-type", "id", "bundle id must use type 'bundle'"))
}
}
let seen : Map[String, Int] = Map([])
for i = 0; i < bundle.objects.length(); i = i + 1 {
let obj = bundle.objects[i]
let path = index_path("objects", i)
if obj.stix_type == "bundle" {
issues.push(
issue("nested-bundle", path, "bundle must not contain another bundle"),
)
}
validate_object(obj, path, issues)
if obj.id.length() > 0 {
match seen.get(obj.id) {
Some(prev) =>
issues.push(
issue(
"duplicate-id",
path,
"id \{obj.id} already appears at \{index_path("objects", prev)}",
),
)
None => seen.set(obj.id, i)
}
}
}
check_bundle_refs(bundle, seen, issues)
}
///|
fn check_bundle_refs(
bundle : StixBundle,
seen : Map[String, Int],
issues : Array[Issue],
) -> Unit {
for i = 0; i < bundle.objects.length(); i = i + 1 {
let obj = bundle.objects[i]
let path = index_path("objects", i)
collect_ref_strings(obj.properties, path, seen, issues)
}
}
///|
fn collect_ref_strings(
fields : Map[String, Json],
path : String,
seen : Map[String, Int],
issues : Array[Issue],
) -> Unit {
fields.each(fn(key, value) {
check_ref_json(value, join_path(path, key), key, seen, issues)
})
}
///|
fn check_ref_json(
value : Json,
path : String,
key : String,
seen : Map[String, Int],
issues : Array[Issue],
) -> Unit {
if key.has_suffix("_ref") ||
key.has_suffix("_refs") ||
key == "object_refs" ||
key == "sample_refs" ||
key == "sighting_of_ref" {
match as_string(value) {
Some(text) => note_unresolved(text, path, seen, issues)
None =>
match as_array(value) {
None => ()
Some(items) =>
for i = 0; i < items.length(); i = i + 1 {
match as_string(items[i]) {
Some(text) =>
note_unresolved(text, index_path(path, i), seen, issues)
None => ()
}
}
}
}
} else {
match as_object(value) {
Some(fields) => collect_ref_strings(fields, path, seen, issues)
None =>
match as_array(value) {
None => ()
Some(items) =>
for i = 0; i < items.length(); i = i + 1 {
check_ref_json(items[i], index_path(path, i), key, seen, issues)
}
}
}
}
}
///|
fn note_unresolved(
id : String,
path : String,
seen : Map[String, Int],
issues : Array[Issue],
) -> Unit {
match parse_stix_id(id) {
Err(_) => ()
Ok(_) =>
if !seen.contains(id) {
issues.push(
issue(
"unresolved-ref",
path,
"identifier \{id} is not in this bundle",
),
)
}
}
}
///|
fn validate_object(
obj : StixObject,
path : String,
issues : Array[Issue],
) -> Unit {
if obj.stix_type.length() == 0 {
issues.push(issue("missing", join_path(path, "type"), "type is required"))
return
}
if !valid_stix_type_name(obj.stix_type) {
issues.push(
issue("type-name", join_path(path, "type"), "invalid STIX type name"),
)
}
let spec = match lookup_type_spec(obj.stix_type) {
Some(item) => item
None => custom_type_spec(obj.stix_type)
}
match object_string(obj.properties, "type") {
None =>
issues.push(issue("missing", join_path(path, "type"), "type is required"))
Some(text) =>
if text != obj.stix_type {
issues.push(
issue(
"type",
join_path(path, "type"),
"type field does not match object type",
),
)
}
}
match object_string(obj.properties, "id") {
None =>
issues.push(issue("missing", join_path(path, "id"), "id is required"))
Some(text) =>
match parse_stix_id(text) {
Err(err) => issues.push(issue("id", join_path(path, "id"), err))
Ok(parsed) =>
if parsed.type_name != obj.stix_type {
issues.push(
issue(
"id-type",
join_path(path, "id"),
"id prefix \{parsed.type_name} does not match type \{obj.stix_type}",
),
)
}
}
}
match object_string(obj.properties, "spec_version") {
None => ()
Some(version) =>
if version != "2.1" && version != "2.0" {
issues.push(
issue(
"spec-version",
join_path(path, "spec_version"),
"unsupported spec_version \{version}",
),
)
}
}
spec.fields.each(fn(field) {
match object_field(obj.properties, field.name) {
None =>
if field.required {
issues.push(
issue(
"missing",
join_path(path, field.name),
"\{field.name} is required",
),
)
}
Some(value) =>
validate_shape(value, field.shape, join_path(path, field.name), issues)
}
})
obj.properties.each(fn(key, _value) {
match field_by_name(spec, key) {
Some(_) => ()
None =>
issues.push(
issue(
"unknown-property",
join_path(path, key),
"property is not in the \{spec.type_name} table",
),
)
}
})
if spec.require_any.length() > 0 {
let mut found = false
spec.require_any.each(fn(name) {
match object_field(obj.properties, name) {
Some(_) => found = true
None => ()
}
})
if !found {
issues.push(
issue(
"require-any",
path,
"\{spec.type_name} needs one of \{join_names(spec.require_any)}",
),
)
}
}
extra_object_rules(obj, path, issues)
}
///|
fn join_names(names : Array[String]) -> String {
let mut out = ""
for i = 0; i < names.length(); i = i + 1 {
if i == 0 {
out = names[i]
} else {
out = "\{out}, \{names[i]}"
}
}
out
}
///|
fn extra_object_rules(
obj : StixObject,
path : String,
issues : Array[Issue],
) -> Unit {
check_created_modified(obj, path, issues)
match obj.stix_type {
"indicator" => extra_indicator(obj, path, issues)
"malware" => extra_malware(obj, path, issues)
"location" => extra_location(obj, path, issues)
"artifact" => extra_artifact(obj, path, issues)
"relationship" => extra_relationship(obj, path, issues)
"sighting" => extra_sighting(obj, path, issues)
"observed-data" => extra_observed_data(obj, path, issues)
"network-traffic" => extra_network_traffic(obj, path, issues)
"marking-definition" => extra_marking(obj, path, issues)
"file" => extra_file(obj, path, issues)
"campaign" | "infrastructure" | "intrusion-set" | "threat-actor" =>
extra_first_last_seen(obj, path, issues)
_ => ()
}
match object_field(obj.properties, "confidence") {
None => ()
Some(value) =>
match as_int(value) {
None => ()
Some(number) =>
if number > 100 {
issues.push(
issue(
"range",
join_path(path, "confidence"),
"confidence must be 0-100",
),
)
}
}
}
}
///|
fn check_created_modified(
obj : StixObject,
path : String,
issues : Array[Issue],
) -> Unit {
match
(
object_string(obj.properties, "created"),
object_string(obj.properties, "modified"),
) {
(Some(created_text), Some(modified_text)) =>
match (parse_timestamp(created_text), parse_timestamp(modified_text)) {
(Ok(created), Ok(modified)) =>
if timestamp_compare(created, modified) > 0 {
issues.push(
issue(
"order",
join_path(path, "modified"),
"modified is earlier than created",
),
)
}
_ => ()
}
_ => ()
}
}
///|
fn extra_first_last_seen(
obj : StixObject,
path : String,
issues : Array[Issue],
) -> Unit {
match
(
object_string(obj.properties, "first_seen"),
object_string(obj.properties, "last_seen"),
) {
(Some(first_text), Some(last_text)) =>
match (parse_timestamp(first_text), parse_timestamp(last_text)) {
(Ok(first), Ok(last)) =>
if timestamp_compare(first, last) > 0 {
issues.push(
issue(
"order",
join_path(path, "last_seen"),
"last_seen is earlier than first_seen",
),
)
}
_ => ()
}
_ => ()
}
}
///|
fn extra_indicator(
obj : StixObject,
path : String,
issues : Array[Issue],
) -> Unit {
let pattern_type = match object_string(obj.properties, "pattern_type") {
None => "stix"
Some(text) => text
}
match object_string(obj.properties, "pattern") {
None => ()
Some(text) =>
if pattern_type == "stix" {
match parse_pattern(text) {
Err(err) =>
issues.push(issue("pattern", join_path(path, "pattern"), err))
Ok(expr) => {
let path_issues = validate_pattern_paths(expr)
path_issues.each(fn(item) {
issues.push(
issue(item.code, join_path(path, "pattern"), item.message),
)
})
}
}
}
}
match
(
object_string(obj.properties, "valid_from"),
object_string(obj.properties, "valid_until"),
) {
(Some(from_text), Some(until_text)) =>
match (parse_timestamp(from_text), parse_timestamp(until_text)) {
(Ok(from), Ok(until)) =>
if timestamp_compare(from, until) > 0 {
issues.push(
issue(
"order",
join_path(path, "valid_until"),
"valid_until is earlier than valid_from",
),
)
}
_ => ()
}
_ => ()
}
}
///|
fn extra_malware(
obj : StixObject,
path : String,
issues : Array[Issue],
) -> Unit {
match object_bool(obj.properties, "is_family") {
Some(true) =>
match object_string(obj.properties, "name") {
None =>
issues.push(
issue(
"missing",
join_path(path, "name"),
"malware family requires name",
),
)
Some(_) => ()
}
_ => ()
}
}
///|
fn extra_location(
obj : StixObject,
path : String,
issues : Array[Issue],
) -> Unit {
let lat = object_field(obj.properties, "latitude")
let lon = object_field(obj.properties, "longitude")
match (lat, lon) {
(Some(_), None) =>
issues.push(
issue(
"pair",
join_path(path, "longitude"),
"longitude is required with latitude",
),
)
(None, Some(_)) =>
issues.push(
issue(
"pair",
join_path(path, "latitude"),
"latitude is required with longitude",
),
)
(Some(lat_value), Some(lon_value)) => {
check_number_range(
lat_value,
join_path(path, "latitude"),
-90.0,
90.0,
issues,
)
check_number_range(
lon_value,
join_path(path, "longitude"),
-180.0,
180.0,
issues,
)
}
(None, None) => ()
}
}
///|
fn extra_artifact(
obj : StixObject,
path : String,
issues : Array[Issue],
) -> Unit {
match
(
object_field(obj.properties, "payload_bin"),
object_field(obj.properties, "url"),
) {
(Some(_), Some(_)) =>
issues.push(
issue("mutex", path, "artifact must not set both payload_bin and url"),
)
_ => ()
}
}
///|
fn extra_relationship(
obj : StixObject,
path : String,
issues : Array[Issue],
) -> Unit {
let rel = match object_string(obj.properties, "relationship_type") {
None => return
Some(text) => text
}
if !is_common_relationship_type(rel) {
issues.push(
issue(
"relationship-type",
join_path(path, "relationship_type"),
"uncommon relationship_type \{rel}",
),
)
}
let source = match object_string(obj.properties, "source_ref") {
None => return
Some(text) => text
}
let target = match object_string(obj.properties, "target_ref") {
None => return
Some(text) => text
}
match (parse_stix_id(source), parse_stix_id(target)) {
(Ok(src), Ok(dst)) =>
if !relationship_allowed(src.type_name, rel, dst.type_name) {
issues.push(
issue(
"relationship-constraint",
path,
"\{src.type_name} \{rel} \{dst.type_name} is not in the STIX 2.1 table",
),
)
}
_ => ()
}
match
(
object_string(obj.properties, "start_time"),
object_string(obj.properties, "stop_time"),
) {
(Some(start_text), Some(stop_text)) =>
match (parse_timestamp(start_text), parse_timestamp(stop_text)) {
(Ok(start), Ok(stop)) =>
if timestamp_compare(start, stop) > 0 {
issues.push(
issue(
"order",
join_path(path, "stop_time"),
"stop_time is earlier than start_time",
),
)
}
_ => ()
}
_ => ()
}
}
///|
fn extra_sighting(
obj : StixObject,
path : String,
issues : Array[Issue],
) -> Unit {
match object_string(obj.properties, "sighting_of_ref") {
None => ()
Some(text) =>
match parse_stix_id(text) {
Err(_) => ()
Ok(parsed) =>
if object_kind_of(parsed.type_name) == Sco {
issues.push(
issue(
"sighting-target",
join_path(path, "sighting_of_ref"),
"sighting_of_ref should reference an SDO, not an SCO",
),
)
}
}
}
match object_field(obj.properties, "count") {
None => ()
Some(value) =>
match as_int(value) {
None => ()
Some(number) =>
if number > 999999999 {
issues.push(
issue(
"range",
join_path(path, "count"),
"sighting count exceeds 999999999",
),
)
}
}
}
extra_first_last_seen(obj, path, issues)
}
///|
fn extra_observed_data(
obj : StixObject,
path : String,
issues : Array[Issue],
) -> Unit {
match object_field(obj.properties, "number_observed") {
None => ()
Some(value) =>
match as_int(value) {
None => ()
Some(number) =>
if number < 1 {
issues.push(
issue(
"range",
join_path(path, "number_observed"),
"number_observed must be >= 1",
),
)
}
}
}
match
(
object_string(obj.properties, "first_observed"),
object_string(obj.properties, "last_observed"),
) {
(Some(first_text), Some(last_text)) =>
match (parse_timestamp(first_text), parse_timestamp(last_text)) {
(Ok(first), Ok(last)) =>
if timestamp_compare(first, last) > 0 {
issues.push(
issue(
"order",
join_path(path, "last_observed"),
"last_observed is earlier than first_observed",
),
)
}
_ => ()
}
_ => ()
}
}
///|
fn extra_network_traffic(
obj : StixObject,
path : String,
issues : Array[Issue],
) -> Unit {
check_port(obj, path, "src_port", issues)
check_port(obj, path, "dst_port", issues)
match
(
object_string(obj.properties, "start"),
object_string(obj.properties, "end"),
) {
(Some(start_text), Some(end_text)) =>
match (parse_timestamp(start_text), parse_timestamp(end_text)) {
(Ok(start), Ok(end)) =>
if timestamp_compare(start, end) > 0 {
issues.push(
issue(
"order",
join_path(path, "end"),
"end is earlier than start",
),
)
}
_ => ()
}
_ => ()
}
}
///|
fn extra_marking(
obj : StixObject,
path : String,
issues : Array[Issue],
) -> Unit {
match object_string(obj.properties, "definition_type") {
Some("tlp") | Some("statement") => ()
Some(text) =>
if !text.has_prefix("x-") {
issues.push(
issue(
"marking-type",
join_path(path, "definition_type"),
"definition_type should be tlp, statement, or a custom x-* type",
),
)
}
None => ()
}
}
///|
fn extra_file(obj : StixObject, path : String, issues : Array[Issue]) -> Unit {
match object_field(obj.properties, "size") {
None => ()
Some(value) =>
match as_int(value) {
None => ()
Some(number) =>
if number < 0 {
issues.push(
issue("range", join_path(path, "size"), "file size must be >= 0"),
)
}
}
}
}
///|
fn check_port(
obj : StixObject,
path : String,
name : String,
issues : Array[Issue],
) -> Unit {
match object_field(obj.properties, name) {
None => ()
Some(value) =>
match as_int(value) {
None => ()
Some(number) =>
if number > 65535 {
issues.push(
issue("range", join_path(path, name), "port must be 0-65535"),
)
}
}
}
}
///|
fn check_number_range(
value : Json,
path : String,
min : Double,
max : Double,
issues : Array[Issue],
) -> Unit {
match value {
Number(number, ..) =>
if number < min || number > max {
issues.push(issue("range", path, "number is out of range"))
}
_ => type_mismatch(path, "number", value, issues)
}
}
///|
fn timestamp_compare(left : Timestamp, right : Timestamp) -> Int {
let a = timestamp_key(left)
let b = timestamp_key(right)
if a < b {
-1
} else if a > b {
1
} else {
0
}
}
///|
fn timestamp_key(ts : Timestamp) -> Int64 {
let minutes = (
(ts.year.to_int64() * 12L + ts.month.to_int64()) * 32L + ts.day.to_int64()
) *
24L *
60L +
ts.hour.to_int64() * 60L +
ts.minute.to_int64() -
ts.offset_minutes.to_int64()
minutes * 60L + ts.second.to_int64()
}